はじめに
私自身は今年の 7 月にドメイン駆動設計(DDD)を実践する企業に転職したばかりで DDD 実践歴は浅いのだが、最近は開発業務の他にも中途採用者の DDD 教育や 現場で DDD!2nd のドライバー役をする機会を頂くなど、DDD の布教活動にも少し関わっている。
その中で「DDD ムズイ」という言葉をよく聞いたので、DDD の実践に悩んでいる人向けにサンプル問題の解説を通して、実は DDD 自体は難しくないんだよってことを教える目的で本記事を書いた。
TL;DR(最初に結論)
-
DDD 自体はドメインを中心にモデリングと実装をイテレーティブに繰り返す設計プロセスであり、モデリングと OOP の理解があれば誰でもできる。
-
難しいのは DDD 自体ではなくて、モデリングまたは OOP である。特に「良いモデル」を得ることは非常に難しい。
-
なので「DDD ムズイ」と感じる人はモデリングと OOP の勉強をすると DDD ができるようになるかも?
サンプル問題
現場で DDD!2nd の増田氏のハンズオンで題材になった JR の料金計算問題 をサンプル問題として取り上げる。
実業務を扱った問題のためやや複雑で、注意深く設計しないとコードが複雑化してしまう。
料金計算について
仕様に明記していない箇所は以下のルールであるものとして実装した。
-
往復割引のほか団体割引でも 10 円未満の端数は切り捨てるしようとした。
-
団体割引で 31 人以上の団体も 8 人以上の団体が受ける割引を受ける。また大人と子供が混在している場合は、大人を優先して無料にする。
回答
Kotlin でのサンプル問題の回答を GitHub に公開した。
ただし画面は用意していないので bootRun
したあと curl コマンドを実行する必要がある。。
curl コマンドのサンプル
curl -D - -H "Content-type: application/json" -X GET -d '{"destination":"shinosaka", "trainType":"nozomi", "seatType":"reserved", "adults":2, "children":3, "departureDate":"2020-09-04", "tripType":"round-trip"}' localhost:8080/jr-pricing/apply
モデリング
まずは JR の料金を計算する上で必要な情報を洗い出そう。例えば「料金」「運賃」「特急料金」「乗車人数」などがあるだろう。
一方「申込者」や「申込経路(Web からなのか窓口なのかとか)」などは JR の業務には必要な情報だと思うが、料金計算の業務には不要な情報である。
こんな感じで必要な情報を拾い上げ、情報間の関係を整理すると以下のようなドメインモデルができる。
パッケージごとに責務や設計意図などを説明する。
price
料金を計算するクラス群を集めたパッケージ。
fare
運賃を計算するクラス群を集めたパッケージ。
super_express_surcharge
特急料金を計算するクラス群を集めたパッケージ。先ほどのせたドメインモデルをもう少し詳細化すると以下のようなモデルになる。
列車区分(のぞみ|ひかり)と座席区分(自由|指定)の組み合わせごとに特急料金計算サービスを用意し、あとは料金計算サービスの calculate
を呼び出すと目的地(新大阪|姫路)までの特急料金が返る仕組み。デザインパターンを知っている人には factory パターンを使ったと言えば伝わると思う。
実装見るまでイメージ湧かない人もいるかもしれないが、特急料金を計算するサービスを 1 つ用意して、そいつに列車区分と座席区分と目的地に応じて特急料金を計算させる構成にすると、SuperExpressSurchargeCalculationService
が if
文まみれでごちゃごちゃしちゃうので、このような構成にした。
Evans 氏も以下のようにオブジェクトの組み立て操作自体 1 つの責務として設計すべき(時もある)と言っている。
オブジェクトの生成は、それ自体が主要な操作になり得るが、複雑な組み立て操作は、生成されるオブジェクトの責務には合わない。そういう責務を組み合わせてしまうと、理解しにくく不格好な設計が作り出されるかもしれない。
ちなみに Season
の置き場は悩んだが、私の中で「季節変動」と「割引」は違う概念であり、Season
の利用者は SuperExpressSurcharge
のみであることから本パッケージに置いた。
discount
割引に関するクラス群を集めたパッケージ。
団体を
-
8 人以上 30 人以下の少人数グループ
-
31 人以上の大人数グループ
の 2 つにグルーピングした。理由は前者は「割引率」、後者は「割り引く人数」というように、同じ割引でも扱う対象が少し違うためである。
モデリングのアウトプット
Web にある多くの記事がドメインモデルのみを提示して実装に移るが、モデリングに不慣れの人はモデルからコードに繋げることができないと思う。
その場合は詳細なクラス図、シーケンス図など、各自の理解度に応じて実装に必要な追加情報をアウトプットすれば良い。別にモデリングのアウトプットは UML に限定されず、例えば料金計算がよくわからないのであれば以下のような表を作成しても良い。
目的地 | 列車 | 座席 | 大人 | 子供 | 出発日 | 旅行区分 | 運賃 | 特急料金 | 料金 |
---|---|---|---|---|---|---|---|---|---|
新大阪 | のぞみ | 自由 | 1 | 0 | 2019-09-04 | 片道 | 8910 | 5280 | 14190 |
新大阪 | のぞみ | 自由 | 1 | 0 | 2019-09-04 | 往復 | - | - | 28380 |
ちなみに私のチームも基本はドメインモデルのみということが多いが、メンバの理解度に応じて適宜詳細なクラス図やシーケンス図を作成することもある。
実装
ここまでくると実装を開始できる。
今回は DDD では有名なレイヤー化アーキテクチャを採用する。ただし今回用意するのは presentation 層、application 層、 domain 層の 3 層のみ。
domain 層の実装
domain 層の主要なクラスの実装を見ながら、オブジェクト指向プログラミングの考え方を紹介する。
料金計算サービス
まずは主要クラスである料金計算サービスの実装を見る。
package com.example.jrpricing.domain.price
import com.example.jrpricing.domain.core.*
import com.example.jrpricing.domain.discount.GroupDiscountPolicy
import com.example.jrpricing.domain.discount.RoundTripDiscountPolicy
import com.example.jrpricing.domain.fare.Fare
import com.example.jrpricing.domain.fare.FareCalculationService
import com.example.jrpricing.domain.super_express_surcharge.SuperExpressSurcharge
import com.example.jrpricing.domain.super_express_surcharge.SuperExpressSurchargeCalculationServiceFactory
class PriceCalculationService(
private val destination: Destination,
private val trainType: TrainType,
private val seatType: SeatType,
private val passengers: Passengers,
private val departureDate: DepartureDate,
private val tripType: TripType
) {
fun calculate(): Price {
val passengers = this.passengers.exclude(GroupDiscountPolicy.getComplimentaryPassengers(this.passengers))
val totalPrice =
(calculatePriceForChild() * passengers.children) + (calculatePriceForAdult() * passengers.adults)
return when {
tripType.isOneWay() -> totalPrice
else -> totalPrice.forRoundTrip()
}
}
private fun calculatePriceForChild(): Price {
val fare = applyDiscount(FareCalculationService(destination).calculate())
val superExpressSurcharge = applyDiscount(
SuperExpressSurchargeCalculationServiceFactory.create(
trainType,
seatType,
destination,
departureDate
).calculate()
)
return Price(fare.forChild() + superExpressSurcharge.forChild())
}
private fun calculatePriceForAdult(): Price {
val fare = applyDiscount(FareCalculationService(destination).calculate())
val superExpressSurcharge = applyDiscount(
SuperExpressSurchargeCalculationServiceFactory.create(
trainType,
seatType,
destination,
departureDate
).calculate()
)
return Price(fare.forAdult() + superExpressSurcharge.forAdult())
}
private fun applyDiscount(fare: Fare) =
GroupDiscountPolicy.applySmallGroupDiscountPolicy(
RoundTripDiscountPolicy.apply(fare, tripType, destination), passengers, departureDate
)
private fun applyDiscount(superExpressSurcharge: SuperExpressSurcharge) =
GroupDiscountPolicy.applySmallGroupDiscountPolicy(superExpressSurcharge, passengers, departureDate)
}
少し長いが、実はこのクラス自身は大したことをしていない。入力値をコンストラクタで受け取り、割引やら運賃やら特急料金を計算する責務を持ったクラスを呼び出すだけである。オブジェクト指向ではこれを 委譲 と呼ぶ。
専門用語を出すと難しく感じるかもしれないが、多くの人が普段やっている仕事の依頼と同じである。依頼者がある仕事をする責務を他の人に委ねるのと同じく、料金計算サービスは割引やら運賃やら特急料金の計算を専任の計算サービスに委ねている。
特急料金計算サービス
続いて特急料金計算サービスの実装を見よう。例えば「のぞみ自由席」の特急料金を計算するサービスの実装は以下のようになっている。単に目的地と特急料金の対応関係( map
)を持つだけの小さなクラスである。
package com.example.jrpricing.domain.super_express_surcharge
import com.example.jrpricing.domain.core.Destination
import java.lang.RuntimeException
class SuperExpressSurchargeCalculationServiceForNozomiFreeSeat(
private val destination: Destination
) : SuperExpressSurchargeCalculationService {
private val map = mapOf(
Destination.SHINOSAKA to 5280,
Destination.HIMEJI to 5920
)
override fun calculate() =
when {
map.containsKey(destination) -> SuperExpressSurcharge(map.getValue(destination))
else -> throw RuntimeException("unknown destination $destination")
}
}
ちなみにこれら特急料金計算サービスを生成する factory の実装は以下の通り。
package com.example.jrpricing.domain.super_express_surcharge
import com.example.jrpricing.domain.core.DepartureDate
import com.example.jrpricing.domain.core.Destination
import com.example.jrpricing.domain.core.SeatType
import com.example.jrpricing.domain.core.TrainType
class SuperExpressSurchargeCalculationServiceFactory {
companion object {
fun create(trainType: TrainType, seatType: SeatType, destination: Destination, departureDate: DepartureDate) =
when {
trainType.isNozomi() -> createServiceForNozomi(seatType, destination, departureDate)
else -> createServiceForHikari(seatType, destination, departureDate)
}
private fun createServiceForNozomi(seatType: SeatType, destination: Destination, departureDate: DepartureDate) =
when {
seatType.isFree() -> SuperExpressSurchargeCalculationServiceForNozomiFreeSeat(destination)
else -> SuperExpressSurchargeCalculationServiceForNozomiReservedSeat(destination, departureDate)
}
private fun createServiceForHikari(seatType: SeatType, destination: Destination, departureDate: DepartureDate) =
when {
seatType.isFree() -> SuperExpressSurchargeCalculationServiceForHikariFreeSeat(destination)
else -> SuperExpressSurchargeCalculationServiceForHikariReservedSeat(destination, departureDate)
}
}
}
モデリングの時はイメージが湧かなかったかもしれないが、もしこれをクラス分けせず 1 つの特急料金計算サービスに実装したら if
文まみれの辛いコードになっていただろう。個人的には if
文が 1 つのクラスまたはメソッドに集中しないよう設計/実装をするよう心がけている。
割引
続いて割引に移ろう。割引については「往復割引を適用するクラス」「団体割引を適用するクラス」の 2 つに分割した。両者に共通するのは tell-don't-ask の原則 に従っていることである。つまり
-
料金計算サービスが割引を適用するクラスに割引可能であるか尋ねる。
-
可能な場合は料金計算サービスが割引を適用するクラスの割引処理を呼び出す。
とはせず、いきなり「割引して!」とお願いしている。これで呼び出し元に if
文を書かなくて済む。「結局呼び出し先で if
文書くから同じじゃん」と思うかもしれないが、料金計算サービスに 4 つ if
文が増えるのと、割引の各クラスに if
文が 1 つずつ増えるのと、どちらが見やすい(保守しやすい)だろうか?
反復する
さて、これで終わりではなくて、実装で得た知見をモデルにフィードバックする。
例えば「601km を超える場合は」の部分をコードでは Destination#isTooFar
と微妙な名前にしてある。これは私がこの部分の業務をよく理解できておらず、適切な表現が浮かばなかったためである。
他にも application 層は不要で presentation 層と domain 層の 2 層で良いのでは?などなど、いくつか修正すべき点がある。
まとめ
DDD は単に
-
業務を学んで
-
理解したことをモデルで表現して
-
モデルの通りに実装する
を繰り返すプロセスである。その際必要となるのは
-
モデリングスキル
-
オブジェクト指向設計/プログラミングのスキル
であり、別に DDD 固有のスキルは必要ない。なので DDD できない!ムズイ!と感じる人は、1 と 2 のどちらが原因であるかを分析して対策すると、DDD ができるようになるかもしれない。
DDD くらい今年中にできるようになろう。