はじめに
この記事では
- 分岐を雑に扱ったコードがなぜ悪いのか
- 分岐を丁寧に扱うため方法とは
これらについて、
現場で役立つシステム設計の原則第2章 場合わけのロジックを整理するを参考に、
9年間ほど現場で設計やリファクタリングをしてきた自分なりの考えを交えながら
まとめました。
現場で役立つシステム設計の原則を読んでの気づきなどのまとめ記事を作ったので
こちらも合わせてご覧ください。

保守性を低下させ技術負債増加スピードを向上させる分岐の扱い
ブロンズ会員ならポイント1倍で、
ゴールド会員ならポイント2倍で・・・
支払方法がクレジットカードならポイントは付与されず、
支払方法が残高から支払い場合はポイント1.5倍で・・・
このように種別や区分毎に業務ロジックが分かれることは
開発の現場ではよくあります。
この時にif文やswitch文を利用して分岐させますが、
分岐の取り扱い方が保守性を左右します。
例えば以下仕様を満たす場合取引実行のユースケースを
どのようにコーディング記述するでしょうか。
- 会員種別
- 前月の月間利用金額が1,000円より小さければノーマル会員
- 前月の月間利用金額が10,000円より小さければシルバー会員
- 前月の月間利用金額が10,000円以上ならゴールド会員
- 送料
- ノーマル会員なら100円
- シルバー会員なら50円
- ゴールド会員なら無料
- 付与ポイント率
- ノーマル会員なら取引金額の0.5%
- シルバー会員なら取引金額の1%
- ゴールド会員なら取引金額の1.5%
- 当日発送
- ノーマル会員なら利用不可
- シルバー会員なら利用不可
- ゴールド会員なら利用可能
例えば、
こんな感じでコーディングしたとします。
(あくまで説明のためのコードくらいでざっくり見ていただければと。)
// 取引実行
fun tradeExecution() {
// 会員を取得したりするところは省略
// 取引実行対象のメンバーのランクを判定
val memberRank =
if (member.previousMonthUsageAmount < 1000) {
MemberRank.NORMAL_MEMBER
} else if (member.previousMonthUsageAmount < 10000) {
MemberRank.SILBER_MEMBER
} else {
MemberRank.GOLD_MEMBER
}
// 取引金額に応じて加算ポイントを付与
when(memberRank) {
MemberRank.NORMAL_MEMBER -> grantPoint(point = member.tradeAmount * 0.5)
MemberRank.SILBER_MEMBER -> grantPoint(point = member.tradeAmount * 1.0)
MemberRank.GOLD_MEMBER -> grantPoint(point = member.tradeAmount * 1.5)
}
// 商品を発送
when(memberRank) {
MemberRank.NORMAL_MEMBER -> goodsDelivery(postage = 100, canTodayDelivery = false)
MemberRank.SILBER_MEMBER -> goodsDelivery(postage = 50, canTodayDelivery = false)
MemberRank.GOLD_MEMBER -> goodsDelivery(postage = 0, canTodayDelivery = true)
}
}
では、これでリリースしてしばらく運用した後、
プラチナ会員という会員種別が追加されたらどうでしょう??
- 会員種別
- 前月の月間利用金額が30,000円以上ならプラチナ会員
- 送料
- プラチナ会員なら無料
- 付与ポイント率
- ゴールド会員なら取引金額の2.0%
- 当日発送
- プラチナ会員なら利用可能
さらに今後も会員種別は増えることが予想され、
送料や付与ポイント率の算出条件は追加になることが想定される場合
(例えば前月の購入回数が10回以上であれば無条件でゴールド会員など)
どうでしょう??
分岐が増えていってさらに分岐の条件にor条件が入って・・・
最初に作った人なら保守できるかもしれませんが、
途中から参画したメンバーは保守しやすいでしょうか??
このまま分岐が増えていくと、
だんだん素早く変更ができなくなり、
変更の影響の波及が広くなり、
保守性がものすごいスピードで低下していきます。
分岐を丁寧に扱う方法とは
改めてですが仕様は以下で・・・
- 会員種別
- 前月の月間利用金額が1,000円より小さければノーマル会員
- 前月の月間利用金額が10,000円より小さければシルバー会員
- 前月の月間利用金額が10,000円以上ならゴールド会員
- 送料
- ノーマル会員なら100円
- シルバー会員なら50円
- ゴールド会員なら無料
- 付与ポイント率
- ノーマル会員なら取引金額の0.5%
- シルバー会員なら取引金額の1%
- ゴールド会員なら取引金額の1.5%
- 当日発送
- ノーマル会員なら利用不可
- シルバー会員なら利用不可
- ゴールド会員なら利用可能
さらに、この辺りは頻繁に変更が入る前提とします。
- 変更頻度の高い箇所や変更が予測できる部分
- 会員種別は今後も増える
- 付与ポイントや送料算出時の条件は頻繁に変更が入る
では、
先ほどのコードを僕ならどのように保守性を考慮して
分岐を丁寧に扱うかというのを説明していきます。
会員には、
ノーマル会員、シルバー会員、ゴールド会員があり、
それぞれの会員は共通して送料、付与ポイント率、当日発送可否
が算出できるように小さいシンプルなクラスに分けていきます。
class NormalMember() {
private val pointRate: Double = 0.5
private val canTodayDelivery: Boolean = false
private val postage: Int = 100
fun calculatePoint(transactionAmount: Int): Int {
return floor(transactionAmount * this.pointRate).toInt()
}
fun canTodayDelivery(): Boolean {
return this.canTodayDelivery
}
fun calculatePostage(): Int {
return this.postage
}
}
class SilverMember() {
private val pointRate: Double = 1.0
private val canTodayDelivery: Boolean = false
private val postage: Int = 50
fun calculatePoint(transactionAmount: Int): Int {
return floor(transactionAmount * this.pointRate).toInt()
}
fun canTodayDelivery(): Boolean {
return this.canTodayDelivery
}
fun calculatePostage(): Int {
return this.postage
}
}
class GoldMember() {
private val pointRate: Double = 1.5
private val canTodayDelivery: Boolean = true
private val postage: Int = 0
fun calculatePoint(transactionAmount: Int): Int {
return floor(transactionAmount * this.pointRate).toInt()
}
fun canTodayDelivery(): Boolean {
return this.canTodayDelivery
}
fun calculatePostage(): Int {
return this.postage
}
}
今まで分岐させていた会員のランクによる判定や計算を
小さく分けることでシンプルで、変更時の影響範囲を小さくできます。
例えば、後からシルバー会員だけポイント算出を特殊な方法で行うとなっても、
SilverMemberのcalculatePointの部分のみに影響が留められます。
例えば、後からプラチナ会員が増えても、
PlutinumMemberクラスを増やせば既存へは影響は一切ありません。
次に、
使う側がシルバー会員、ゴールド会員などを意識しなくて済むように、
Interfaceを使ってそれぞれの会員種別を同じように扱えるようにします。
個別に作成したノーマル会員やシルバー会員などは
インタフェースを実装するようにします。
interface Member {
fun calculatePoint(transactionAmount: Int): Int
fun canTodayDelivery(): Boolean
fun calculatePostage(): Int
}
class NormalMember:Member {
private val pointRate: Double = 0.5
private val canTodayDelivery: Boolean = false
private val postage: Int = 100
override fun calculatePoint(transactionAmount: Int): Int {
return floor(transactionAmount * this.pointRate).toInt()
}
override fun canTodayDelivery(): Boolean {
return this.canTodayDelivery
}
override fun calculatePostage(): Int {
return this.postage
}
}
・・・
最後は、
ノーマル会員なのかシルバー会員なのかゴールド会員なのか、
どれを生成すれば良いかを判定して会員インタフェースの実態を生成する部分です。
会員生成ファクトリーを作ります。
interface Member {
fun calculatePoint(transactionAmount: Int): Int
fun canTodayDelivery(): Boolean
fun calculatePostage(): Int
companion object {
fun memberFactory(previousMonthUsageAmount: Int): Member {
if (previousMonthUsageAmount < 1000) return NormalMember()
if (previousMonthUsageAmount < 10000) return SilverMember()
return GoldMember()
}
}
}
このファクトリと先ほどのインタフェース、
個別に作成した小さな会員クラスを使って、
会員のランク判定や、
ランクによる付与ポイントや送料の算出時の分岐をなくして、
取引実行のユースケース(使う側)がシンプルになります。
// 取引実行
fun tradeExecution() {
// 会員をリポジトリから取得したりするところは省略
// 前月利用実績から該当ランクのメンバーを生成(実態はこの場合Silver会員)
val member = Member.memberFactory(previousMonthUsageAmount = 1000)
// 取引金額に応じて加算ポイントを付与
grantPoint(point = member.calculatePoint(transactionAmount = param.amount))
// 商品を発送
goodsDelivery(postage = member.calculatePostage(), canTodayDelivery = member.canTodayDelivery())
}
もう少し改善の余地はあるかもしれませんが、
今回のサンプルコードの紹介はここまでとします。
分岐を丁寧に扱うことで得られることと保守性
わざわざクラスを小さく分けて、
インタフェースを切ってファクトリを作って・・・
ここまでやることによるメリットについて最後に見ていきます。
まず、今回の話は
大前提としてシステムを開発してリリースして終わりではなく、
リリースしてから長期間保守開発で、
追加機能対応や仕様変更などが行い続けることを想定しています。
作って終わりなら、
仕様変更や機能追加について考える必要はないので、
今回のようにインタフェースを切ったり、ファクトリーを作ったり
そこまでやる必要はないので、最初のコードで十分。以上となってしまうので、
今回の話の目指す場所はリリース後の保守開発をやりやすくことにある
ということを改めて持っていただければと思います。
その前提のもと、
今回のように分岐を丁寧に扱うようにコードを修正することで、
僕は以下のことを得られたと思っています。
- 分岐が増えた際に他への影響がない
- 判定や算出ロジックの変更に伴う影響範囲が限定できる
- 使う側が今後増えても至る所に同じ分岐を書かなくて良い(使う側がシンプル)
まず、
今回の作りにしたことによって、
プラチナ会員のようなランクが増えた場合、
PlutinumMemberクラスを追加して、
memberFactoryの1箇所のみ分岐ロジックを追加するだけで済みます。
interface Member {
fun calculatePoint(transactionAmount: Int): Int
fun canTodayDelivery(): Boolean
fun calculatePostage(): Int
companion object {
fun memberFactory(previousMonthUsageAmount: Int): Member {
if (previousMonthUsageAmount < 1000) return NormalMember()
if (previousMonthUsageAmount < 10000) return SilverMember()
// 分岐追加
if (previousMonthUsageAmount < 100000) return GoldMember()
return PlatinumMember()
}
}
}
// クラスを追加
class PlatinumMember:Member {
private val pointRate: Double = 2.5
private val canTodayDelivery: Boolean = true
private val postage: Int = 0
override fun calculatePoint(transactionAmount: Int): Int {
return floor(transactionAmount * this.pointRate).toInt()
}
override fun canTodayDelivery(): Boolean {
return this.canTodayDelivery
}
override fun calculatePostage(): Int {
return this.postage
}
}
分岐が追加になっても修正はこれだけで、
使う側であるユースケースの修正は不要です。
また、今回の例だと会員を利用しているユースケースは1つのみでしたが、
現場で扱うようなプロダクションコードでは、
使う側であるユースケースは複数あるケースが多く、
そういうケースではさらに効果を発揮します。
例えば、取引実行ユースケースとよく似ている、
特殊な取引実行ユースケースを作ることになり、
そちらにもポイント算出がそちらでも必要だったとします。
その場合、
最初のコードでは、分岐を取引実行ユースケースに書いていたため、
開発者はそれを参考にほぼ間違いなくコピペで同じ処理を
特殊取引実行ユースケースにも作ると思います。
そうなると・・・
追加仕様でプラチナ会員を追加する場合は、
普通の取引実行ユースケースと、
特殊取引実行ユースケースの両方に対応が必要となります。
このように、
リファクタリング前のコードだと、
使う側であるユースケースが1つ増えるだけで、
技術負債がちょっとした変更で増加する危険性あるのに対して、
リファクタリング後のコードだと、
その危険性が抑えられていると思います。
また、
今回ポイント計算、送料計算をクラス毎分けており、
例えばシルバー会員のポイント計算ロジックが変更になっても、
ノーマル会員やゴールド会員のポイント計算ロジックに一切影響を与えることなく
シルバー会員クラスのポイント計算メソッドが修正できます。
この点でも保守性が良いと言えると思います。
僕の経験上、
開発者は基本的に既存の似たようなコードや仕掛けを探して流用しようとします。
そのため、
綺麗にクラスを小さく分けて使いまわせるようにしておけば
それを使っている箇所を参考に同じように使い回してくれます。
しかし、
汚くて大きなクラスがあってそれがそのまま使いまわせないとなれば、
似たような汚い大きなクラスがいつの間にか量産されしまいます。
最初に分岐を雑に扱うか、丁寧に扱うかで
技術負債の増加スピードが全く違ってくるのです。
保守開発を見据えて丁寧に分岐を扱うことの大切さについて
僕はこのように考えています。
参考文献