DDDで集約を跨いだ情報でロジックを構築するための「getter高階関数パターン」の紹介
はじめに
今回はDDDで集約を跨いだ情報でロジックを構築するためのパターンについて紹介していきます。
DDD(ドメイン駆動設計)における「集約(Aggregate)」とは、関連するオブジェクト(エンティティや値オブジェクト)を一つにまとめた単位のことを指します。集約はドメインのビジネスロジックの適用や整合性を維持するために定義されます。永続化は集約単位で行われます。
例えばECサイトの注文という集約には、一つの注文に複数の注文明細があるとします。この注文と注文明細はそれぞれがエンティティであり、このとき集約は「複数の注文明細を持つ一つの注文」という単位で管理され永続化されます。一つの集約の中に二種類のエンティティがあるということです。
Amazonで注文した時に、化粧水を2個、洗顔料を1個まとめて買ったときの注文を一つの集約として取り扱っているイメージです。
class Order(
val id: OrderId,
val orderLines: List<OrderLine>, // 注文明細
)
class OrderLine(
val id: OrderLineId,
val productId: ProductId,
val price: Int, // 注文明細毎の料金
val quantity: Int, // 数量
)
この注文は他集約である商品(Product)への依存をもっていて、各注文明細は各々の小計料金(price)を持っています。
⚠️ 説明をよりシンプルにする都合上、注文モデルにあるべき情報をかなりカットしています。厳密にはpriceなどは値オブジェクトで表現すべきですが、説明を簡易にする都合上省略しています。
class Product(
val id: ProductId,
val name: String, // 商品名
val unitPrice: Int, // 単価
)
注文時には各商品明細の現在の料金と数量をかけて注文明細毎の料金を計算します。
化粧水注文明細(400円) = 化粧水(単価200円) × 2個
洗顔料注文明細(100円) = 洗顔料(単価100円) × 1個
この時に注文という集約への操作は商品という別集約にある情報を参照する必要が出てきます。今回はそのようなケースにおいて高階関数を用いたシンプルな実装パターンを提案します。
今回取り扱わないものとして集約をまたいだ整合性を担保することは取り扱いません。あくまで他の集約の情報を加味して一つの集約の変更を行うというテーマに留めます。
getter高階関数パターン
はじめに最終版を書いておくとこのような感じの実装になります。慣れていないと違和感がある実装だと思いますが、なぜこの書き方が最適解に近いと思っているのかは順に説明していきます。
// ドメイン層
class Order(...) {
companion object {
fun create(
lineParams: List<CreateOrderLineParam>,
getProduct: (ProductId) -> Product, // 関数を引数で渡す
): Order {
val orderLines = lineParams.map { lineParam ->
// 注文明細が持つ商品IDから商品を取得
val product = getProduct(lineParam.productId)
// 明細毎の料金を計算
val price = product.unitPrice * lineParam.quantity
OrderLine(
id = OrderLineId.generate(),
productId = lineParam.productId,
price = price,
quantity = lineParam.quantity,
)
}
return Order(
id = OrderId.generate(),
orderLines = orderLines,
)
}
}
}
data class CreateOrderLineParam(
val productId: ProductId,
val quantity: Int,
)
getProduct
という別集約のインスタンスを取得する関数を引数として渡しているのが今回最大のポイントで、便宜上この記事ではこのパターンを「getter高階関数パターン」と呼びます。
注文作成時には注文明細の料金を計算する際に商品の単価情報を取得する必要が出てきます。つまり集約をまたいだ操作が必要になります。今回は別集約を取得するという操作をgetProduct
という関数で表現しています。
fun create(
lineParams: List<CreateOrderLineParam>,
getProduct: (ProductId) -> Product,
): Order {...}
対象の商品はどう取得するのかというと注文明細がもっている商品IDを使います。ECサイトでは画面上で商品を選んで買うわけですから注文明細が商品のIDを持っているのは不思議なことではないですね。
fun create(
lineParams: List<CreateOrderLineParam>,
getProduct: (ProductId) -> Product,
): Order {
val orderLines = lineParams.map { lineParam ->
// 注文明細が持つ商品IDから商品を取得
val product = getProduct(lineParam.productId)
// 明細毎の料金を計算
val price = product.unitPrice * lineParam.quantity
OrderLine(...)
}
...
}
このときのgetProduct
は要は「商品IDを渡すと商品を教えてくれる君」です。
それ以上でもそれ以下でもなくDBにアクセスするとかキャッシュから持ってくるとかそのようなことは考えなくて大丈夫です。ただの商品IDを渡すと商品を教えてくれる君です。
商品さえ手元にあればあとは簡単で、明細毎の料金を単価 × 数量で掛け合わせて計算します。
呼び出し元
ではこの Order.create
メソッドをどう呼び出すべきでしょうか?今回はアプリケーション層からドメイン層の注文作成メソッドを呼び出すことにします。
先にコードの全容をお見せします。
// アプリケーション層
class CreateOrderUseCase(
private val orderRepository: OrderRepository,
private val productRepository: ProductRepository,
) {
fun execute(dto: CreateOrderDto) {
// 今回の注文で対象となる商品のIDを習得
val targetProductIds: List<ProductId> = dto.lineParams.map { line ->
line.productId
}
// 対象の商品だけDBから取得し、商品IDで商品を取得できるようMapを作成
val productIdMap: Map<ProductId, Product> =
productRepository.listBy(targetProductIds)
.associateBy { product -> product.id }
// 注文を作成する
val newOrder = Order.create(
lineParams = dto.lineParams,
getProduct = { productId ->
productIdMap[productId] ?: throw NotFoundException("商品ID${productId.value}は存在しません。")
}
)
// 新しい注文をDBに保存
orderRepository.insert(newOrder)
}
}
class CreateOrderDto(
// 厳密にはここは層独自のCreateOrderLineDtoにするべきだがコードを短くするために省略
val lineParams: List<CreateOrderLineParam>,
)
class NotFoundException(override val message: String?) : Exception(message)
アプリケーション層ではパフォーマンス要件を考慮したDBからのデータ取得を実装します。
先ほどドメイン層ではDBにアクセスするのかキャッシュから持ってくるのかとかそんなことは考えなくて大丈夫と言いましたが、インフラ層からデータをどのようなタイミング、範囲で取得するかはアプリケーション層の責務です。(実際に外部のシステムにどのように保存し取得するかの具体の実装はインフラストラクチャー層の責務です。)
今回のケースでは特に以下2つの問題を回避する必要があります。
- 商品毎にDBアクセスをしてしまうN+1問題の回避する
- メモリの考慮。ECサイト内の全ての商品をメモリに乗せないようにする
この2つの問題を回避するために今回はメモリ上に今回対象となる商品だけを含んだMap
を使ってgetProduct
を作っています。
たとえばgetProduct
にリポジトリのメソッドをそのまま入れてしまうと商品の取得する度にDBアクセスが走るのでN+1問題が発生します。
// 今回の要件ではNG
val newOrder = Order.create(
lineParams = dto.lineParams,
getProduct = { productId ->
productRepository.findBy(productId) ?: throw NotFoundException("商品ID${productId.value}は存在しません。")
}
)
そのため今回はO(1)で商品の取得ができるようにMap<ProductId, Product>
のインスタンスを作成しています。
// Good: Mapを使って高速アクセスを実現している
val productIdMap: Map<ProductId, Product> =
productRepository.listBy(targetProductIds))
.associateBy { product -> product.id }
val newOrder = Order.create(
lineParams = dto.lineParams,
// Mapのため商品に高速アクセスが可能
getProduct = { productId ->
productIdMap[productId] ?: throw NotFoundException("商品ID${productId.value}は存在しません。")
}
)
そして、2点目としてECサイト上の全ての商品をメモリに乗せるわけにはいかないので、事前に注文の対象となる商品でメモリに乗せるべき商品を選定しています。
// NG: 全ての商品を取得していまっている。
val productIdMap: Map<ProductId, Product> =
productRepository.listAll())
.associateBy { product -> product.id }
// Good: 今回の注文で対象となる商品のIDを習得
val targetProductIds: List<ProductId> = dto.lineParams.map { line ->
line.productId
}
// 対象の商品だけDBから取得し、商品IDで商品を取得できるようMapを作成
val productIdMap: Map<ProductId, Product> =
productRepository.listBy(targetProductIds))
.associateBy { product -> product.id }
このようにアプリケーション層ではシステム要件にあったデータの取得方法を考えます。もちろんどのDBを使うか、どういうSQLを使うかなどの具体的な取得方法はインフラストラクチャー層が担当します。
ちなみに今回getProduct
は(ProductId) -> Product
なのでドメイン上では商品IDを渡せば必ず商品を渡してくれるものだと思われています。これは今回お題としているECサイトの商品が論理削除されて物理削除されないとしているためです。
ドメイン上商品が存在しないことは考慮されていないので万が一なかった場合のハンドリングはアプリケーション層に担当させています。
ドメイン上商品が物理削除され、存在しないことがあり得るならgetProduct
は(ProductId) -> Product?
としてnull
を返し得るようにすれば良いですね。
Kotlinでは後ろに?をつけるとNullableな型であることを表現できます。
引数を入力と依存関係に分ける
この実装パターンを理解する上で重要なことを説明していきます。それはメソッドの引数を入力と依存関係に分けるということです。
メソッド及び関数は入力があり出力を返すものと表現することができます。
例えばメモというエンティティを作成するcreateMemo
という関数は文字列(入力)を渡してメモのインスタンス(出力)を作ります。
fun createMemo(
text: String // 入力
): Memo // 出力
しかし入力と出力だけで処理が完結できるほどシステムは単純ではなく実際は多くの依存関係を有することになります。
ここでいう依存関係というのは集約の外の世界との依存です。今回の例では注文の外の世界にあるデータである商品がそれに該当します。
fun create(
lineParams: List<CreateOrderLineParam>, // 入力
getProduct: (ProductId) -> Product, // 依存関係
): Order // 出力
Scott Wlaschin氏による「関数型ドメインモデリング」という本では、この入力とは別に依存関係というものを定義しています。依存関係を関数で注入することで、入力とは扱いを明確に分けて管理することを推奨しています。詳細が気になる方は同書の7章をぜひお読みください。
ではこの依存関係をなぜ関数で注入したほうがいいのかを別選択肢と比較しながら説明していきます。
別選択肢①: 値として依存を渡す
一つ目の選択肢は別集約をそのまま値として渡すことです。
商品をList
で渡してみましょう。
fun create(
lineParams: List<CreateOrderLineParam>,
products: List<Product>,
): Order {
val orderLines = lineParams.map { lineParam ->
// 注文明細が持つ商品IDから商品を取得
val product = products.find { product ->
product.id == lineParam.productId
} ?: throw NotFoundExpection("...")
val price = product.unitPrice * lineParam.quantity
OrderLine(...)
}
...
}
ただこの場合、商品には何が渡されるべきなのでしょうか?全ての商品でしょうか?今回対象となる商品だけなのでしょうか?
val order = Order.create(
lineParams = dto.lineParams,
products = // 何を渡すべき?
)
呼び出し側からはこの商品一覧がどのように使われるかわからずどのようなリストを渡すべきかわかりません。
一見すると入力のようにも見えるのでList
で渡したproducts
をベースに注文明細を作ってしまう可能性もあります。
fun create(
lineParams: List<CreateOrderLineParam>,
products: List<Product>,
): Order {
// 誤って引数の商品をベースにループを回してしまった
val orderLines = products.map { product ->
// 注文明細が持つ商品IDから商品を取得
val orderLineParam = lineParams.find { lineParam ->
product.id == lineParam.productId
} ?: throw NotFoundExpection("...")
val price = product.unitPrice * lineParam.quantity
OrderLine(...)
}
...
}
このミスの非常に厄介なところはproducts
が綺麗に対象の商品だけ絞って引数に渡している場合は正しく動いてしまうということです。
単体テストもケースとして考慮してなく正常系のテストしか書いていないということもあります。
このようなミスを防ぐために場合によっては引数のproducts
を検証する処理を入れるかもしれません。
fun create(
lineParams: List<CreateOrderLineParam>,
products: List<Product>,
): Order {
// productsとlineParamsで指定している商品IDが整合していなければエラー
if (products.map { product -> product.id }.toSet != lineParams.map { lineParam .productId }) {
throw IllegalArgumentException("productsの値がlineParamsと一致しません")
}
val orderLines = products.map { product ->
...
}
...
}
しかし高階関数を使ったパターンに比べてこれは完全に無駄な検証となります。
似た選択肢として、Map
で渡すパターンも考えられます。これはList
より明示的です。
そして先ほどはfind
を使ってO(N)な計算に対してMap
のアクセスはO(1)なため高速アクセス可能です。
fun create(
lineParams: List<CreateOrderLineParam>,
productMap: Map<ProductId, Product>,
): Order {
val orderLines = lineParams.map { lineParam ->
// 注文明細が持つ商品IDから商品を取得
val product = productMap[lineParam.productId] ?: throw NotFoundExpection("...") }
val price = product.unitPrice * lineParam.quantity
OrderLine(...)
}
...
}
しかしこの選択肢もまだ課題が残っています。
一つ目の課題は商品をとってくる時にドメイン上はありえないとされている商品が存在しないケースをハンドリングしないといけないことです。
MapのIDアクセスは確実に取得できるということを型上表現できないためこのメソッド内でのハンドリングが余儀なくされます。
二つ目の課題はList
同様、Map
もMap.entries
などのループ処理ができてしまう点です。
依存関係をList
やMap
などの値として渡してしまう最大の問題点は集約外のデータを取得する以上のことができてしまう点です。
一般的にAPIとしては渡される引数のパターンが使われ得るケースにおいて少なければ少ない方が良いとされます。
今回は「単純に注文明細に紐づく商品が欲しい」というニーズだけで、その点を最小限に満たす型は(ProductId) -> Product
であり、Map<ProductId, Product>
は振る舞いとして大きすぎてしまいます。
もちろんgetProduct
も引数としての関数の渡し方でミスがある可能性もあります。
Order.create(
lineParams = lineParams,
// 商品IDを渡して商品を返すという責務を果たさず適当な商品を返してしまうミス
getProduct = { productId -> somethingProduct }
)
どこまで行っても引数というものがある以上はミスが発生し得ますがgetProduct: (ProductId) -> Product
という型は達成したい目的に対しては最小限の型で、ハックがしづらい引数であることは言えるでしょう。
別選択肢②: ドメインサービスを導入する
二つ目の選択肢はドメインサービスを導入することです。
OrderRepository
とProductRepository
をそれぞれDIし、注文の保存処理と商品の取得処理を内部で行います。
class CreateOrderService(
private val orderRepository: OrderRepository,
private val productRepository: ProductRepository,
) {
fun create(
lineParams: List<CreateOrderLineParam>,
): Order {
val products = productRepository.listBy(lineParams.map { it.productId })
val orderLines = lineParams.map { lineParam ->
val product = products.find { it.id == lineParam.productId }
?: throw NotFoundException("商品ID${lineParam.productId.value}は存在しません。")
val price = product.unitPrice * lineParam.quantity
OrderLine(
id = OrderLineId.generate(),
productId = lineParam.productId,
price = price,
quantity = lineParam.quantity,
)
}
val order = Order(
id = OrderId.generate(),
orderLines = orderLines,
)
orderRepository.insert(order)
return order
}
}
class NotFoundException(override val message: String?) : Exception(message)
この実装の最大の問題点はDBアクセスと注文のドメインロジックが密結合になる点です。
DDDのようなドメイン層という独立した層を設け、DBアクセスや外部システムとの通信とは依存しないようにビジネスロジックを管理することで保守性を高めるということがしたかったはずなのに気づけばドメイン層からDBアクセスのことを気にしないといけないというのは難儀なことです。
さらに、ユニットテストもモックを使わないといけなくなり保守性の低いテストコードになってしまいます。
「実践ドメイン駆動設計」の書籍にもドメインサービスの危険性については議論されていて注意が必要です。
あまりにサービスを使いすぎると、ドメインモデル貧血症[Fowler, Anemic] に陥ってしまうという悪影響がある。
ヴォーン・ヴァーノン. 実践ドメイン駆動設計 (p.257). 翔泳社.
また、ドメインサービスの実装パターンの課題も結局は 「集約外のデータを取得したい」以上のことができてしまう点が問題です。
注文の作成処理がProductRepository
全体に依存してしまうことでCreateOrderService
ではProductRepository
が持つ全ての振る舞いにアクセスできてしまいます。
さらに今回のケースでは「集約外のデータを取得したい、取得方法は一切気にしない」としたいのにCreateOrderService
は必ずDBから商品を取得するという取得方法まで制限されてしまいます。
そういった意味でもgetProduct: (ProductId) -> Product
は実現したいことにおいては一番ミニマムな実現方法であることがわかります。
高階関数の使い方の注意点
関数を引数で渡せるようにすると関数が実現できる範囲が広がります。これはむしろデメリットでもあります。特にドメイン層の実装ではそのドメインの責務であるべきロジックを外から渡さないようにしてください。
今回の注文明細の料金を計算する例で言うと料金計算自体を関数で注入してしまうことです。
明らかに注文明細毎の料金計算は注文もしくは注文明細の責務であるはずなのに、その責務を外に押し付けてしまっています。
fun create(
lineParams: List<CreateOrderLineParam>,
calculatePrice: (CreateOrderLineParam) -> Int,
): Order {
val orderLines = lineParams.map { lineParam ->
// 明細毎の料金を計算
val price = calculatePrice(lineParam)
OrderLine(...)
}
...
}
集約外のデータを使って何か計算もしくはバリデーションをしたい時に注入すべき依存はシンプルにデータを取得する関数だけにとどめましょう。
9割のケースでは集約が持っているIDをベースに集約外からデータをとってくるケースでこのタイトルのとおりgetSomething: (SomethingId) -> Something
という関数を渡せば解決します。
慣れるまでは引数で渡せる関数はIDをキーにした関数のみにするという縛りをつけることをお勧めします。
高階関数の使い方を応用するとたとえば「名前が重複している商品をつくることはできない」みたいなことも実装可能です。
class Product(...) {
companion object {
fun create(
name: String,
unitPrice: Int,
getDuplicatedNameProduct: (String) -> Product?,
): Product {
val duplicatedNameProduct = getDuplicatedNameProduct(name)
if (duplicatedNameProduct != null) {
throw DuplicatedNameProductExistsException("「${name}」という商品はすでに存在します。重複している商品ID: ${duplicatedNameProduct.id.value}")
}
return Product(
id = ProductId.generate(),
name = name,
unitPrice = unitPrice,
)
}
}
}
名前が重複している商品は存在するか?は集約の外の情報であり、これは外部依存として外から注入するのが適切な例であると言えます。
getter高階関数パターンのユニットテスト
最後にユニットテストをどう書くかを説明していきます。
ドメイン層のテストは基本DBなどの外部システムへの依存がないため楽にユニットテストを書くことができます。
class OrderTest {
@Test
fun `商品の単価を用いて明細毎の料金の計算をした注文を作れる`() {
// given:
val productMap = listOf(
Product(
id = ProductId("1"),
name = "化粧品",
unitPrice = 200,
),
Product(
id = ProductId("2"),
name = "洗顔料",
unitPrice = 100,
),
).associateBy { it.id }
// when:
val actual = Order.create(
lineParams = listOf(
CreateOrderLineParam(
productId = ProductId("1"),
quantity = 2,
),
CreateOrderLineParam(
productId = ProductId("2"),
quantity = 1,
),
),
getProduct = { productId -> productMap[productId]!! }
)
// then:
Assertions.assertEquals(400, actual.orderLines.find { it.productId == ProductId("1") }?.price)
Assertions.assertEquals(100, actual.orderLines.find { it.productId == ProductId("2") }?.price)
}
}
しかしgetProduct
を実装するために商品のリストは作る必要があります。これはそもそも注文を作成するには商品の情報が必要なので払うべきコストではあると思います。
このとき、getProduct
でIDを指定したときに該当の商品が存在しなかったときのケースを書く必要はありません。getProduct: (ProductId) -> Product
という型の時点でドメイン層では商品は必ず存在すると仮定しているためです。もしドメインの責務上、商品がないケースがありえるならnullを返すような関数にしましょう。
それでは「この商品がないケース」はアプリケーション層のテストで実装するのか?という疑問が出てくるかと思います。この疑問に対しては「全ての層で商品がないケースはテストしない」が答えになります。
商品はビジネスロジック上物理削除が存在しないとされているので、商品がないケースというのはあり得ないケースです。あり得ないケースにおいて復帰不可能なエラーを出しているかを検証するテストはコストの割にリターンが少ないです。
まとめ
今回はgetter高階関数パターンというものを紹介して集約外にあるデータを取得する関数を引数として注入することで、DBに疎結合でありながらハックしづらいミニマムな型で依存を注入するやり方を紹介しました。
複数の集約を跨いで新しいデータを作る、もしくはバリデーションなどをするというケースは日常の開発ではかなり多いと思います。
今回のパターンがみなさんの快適な開発の手助けになればと思います!
あとgetterといわれるとJavaのフィールドにアクセスするgetterメソッドみたいなものをイメージする人が多いと思うので良い名前を募集してます。
Discussion
自分だと
orderLines
を作ってOrder.create
に渡してしまいそう。動かしてなくてすみませんがこんな感じです。
これだと
OrderLine
が外に露出しているので DDD 的にあんまり良く無いんですかね。質問ありがとうございます。
DDD的というよりかは自然言語としてどのような文章が成立するかで考えると責務をどこに置くべきかわかりやすいかなと思いました。
「化粧水を2個、洗顔料を1個で注文を作る」という文章通りにロジックを書くならば入力は化粧水2個と洗顔料1個であり、出力は注文です。このとき文章を見ると注文金額の計算は外から隠蔽されているので注文作成が注文明細の小計計算の責務を持つべきなのではと思っています。
ドメインサービスをむやみに利用させないアイデアとして非常に参考になりました。サービスは意味が広く慎重に管理しないとすぐ肥大化していくので。。。
ちょっと気になったのが、本来サービスに隔離すべき Entity を越境した依存関係が不用意に Entity へ集約されてしまった神 Entity ができてしまうのではないか、と感じました。(このあたりはトレードオフだとは思いますが)
「getter高階関数」と定義している部分は、リポジトリの抽象に依存するという内容を言い換えたものと理解したので、私だったら Read だけができる
IReadProductRepository
に依存したドメインサービスの実装を検討します。普段 Kotlin を使わないので、このような実装はできないという前提があったのなら申し訳ありません。
コメントありがとうございます。CreateOrderSeviceというのはドメインサービスでProductRepossiotry全体の依存ではなくRead処理だけの依存に限定したものですね。たしかにコード量が増えるデメリット以外では依存を限定できるのでこちらのほうが単純なドメインサービスより良いですね。
もちろんこのような実装はKotlinでも可能です。