DDD 適切なモデリング
本ページの内容
DDDを導入する際にぶつかった問題と、その解決手段について振り返ります。特に、適切なモデリングの重要性に焦点を当てます。
経緯
DDD(ドメイン駆動設計)に出会うまで、オブジェクト指向やデザインパターンを使って設計を行ってきましたが、永続化の配置や、クラスの肥大化など、様々な課題に直面していました。DDDと出会い、業務ドメインをロジックに落とし込むことで、業務側とロジック側の不整合を減らし、要件に素早く対応できるという利点に感銘を受け、実際の設計に取り入れることを決めました。
モデリング
当初、ドメインモデルをOOPでよくある「モノ」として設計していたため、扱いづらいものができあがってしまいました。ECサイトの商品(Product)を例に説明します。
モノ視点のモデリング(問題のある例)
// 問題のある例:すべての情報を一つのクラスに詰め込んでいる
data class Product(
val id: String,
val name: String,
val description: String,
val price: BigDecimal,
val cost: BigDecimal,
val stockQuantity: Int,
val categories: List<Category>,
val images: List<Image>,
val ratings: List<Rating>,
val discounts: List<Discount>,
val taxRate: BigDecimal){
// カタログ表示のメソッド
fun displayInfo() { /* ... */ }
// 在庫管理のメソッド
fun updateStock(quantity: Int) { /* ... */ }
fun isInStock(): Boolean { /* ... */ }
// 価格計算のメソッド
fun calculateFinalPrice(): BigDecimal { /* ... */ }
fun applyDiscount(discount: Discount) { /* ... */ }
// 会計のメソッド
fun calculateProfit(): BigDecimal { /* ... */ }
}
一件問題なさそうに見えますが、このモデルは「モノ」(商品という物理的な概念)に焦点を当てており、異なるビジネスプロセス(「コト」)を混在させています。
改善例:コンテキスト別のモデリングと「コト」への注目
各コンテキストで「商品」という概念を別々にモデリングし、それぞれのビジネスプロセス(「コト」)に焦点を当てます:
// カタログコンテキスト - 「商品を閲覧する」というコトに注目
data class CatalogProduct(
val id: String,
val name: String,
val description: String,
val displayPrice: Money,
val images: List<String>,
val categories: List<String>
) {
// 商品検索に関するメソッド
fun matchesSearchCriteria(criteria: SearchCriteria): Boolean { /* ... */ }
// 商品表示に関するメソッド
fun getPrimaryImage(): String { /* ... */ }
}
// 在庫管理コンテキスト - 「在庫を管理する」というコトに注目
data class InventoryProduct(
val productId: String,
val sku: String,
val stockLevel: Int,
val reorderThreshold: Int
) {
// 在庫確認のメソッド
fun isInStock(quantity: Int): Boolean { /* ... */ }
// 在庫引当のメソッド
fun allocateStock(quantity: Int): InventoryProduct { /* ... */ }
// 入荷処理のメソッド
fun receiveStock(quantity: Int): InventoryProduct { /* ... */ }
}
// 価格設定コンテキスト - 「価格を計算する」というコトに注目
data class PricingProduct(
val productId: String,
val basePrice: Money,
val discountRules: List<DiscountRule>,
val taxCategory: String
) {
// 最終価格計算のメソッド
fun calculateFinalPrice(customerId: String): Money { /* ... */ }
// 特別オファーの適用可否判定
fun isEligibleForPromotion(promotionId: String): Boolean { /* ... */ }
}
このように、各コンテキストでは「商品」という同じ概念でも、そのコンテキスト固有の「コト」(ビジネスプロセス)に焦点を当ててモデリングしています。
コンテキスト間のマッピング
各コンテキストで商品を別々にモデリングしましたが、実際の商品は一つなので、コンテキスト間のマッピングが必要になります。
// マッピングクラス例
@Component
class ProductContextMapper(
private val inventoryRepository: InventoryRepository,
private val discountRuleRepository: DiscountRuleRepository
) {
fun mapToPricingProduct(catalogProduct: CatalogProduct): PricingProduct {
return PricingProduct(
productId = catalogProduct.id,
basePrice = catalogProduct.displayPrice,
discountRules = discountRuleRepository.findByProductId(catalogProduct.id),
taxCategory = determineTaxCategory(catalogProduct.categories)
)
}
fun mapToInventoryProduct(catalogProduct: CatalogProduct): InventoryProduct {
val inventoryData = inventoryRepository.findByProductId(catalogProduct.id)
return InventoryProduct(
productId = catalogProduct.id,
sku = inventoryData.sku,
stockLevel = inventoryData.stockLevel,
reorderThreshold = inventoryData.threshold
)
}
private fun determineTaxCategory(categories: List<String>): String { /* ... */ }
}
このマッピングクラスは、異なるコンテキスト間での商品モデルの変換を担当しています。これにより:
- 各コンテキストは自身の関心事に集中したモデルを持つことができる
- コンテキスト間の依存関係が明示的になり、変更の影響範囲が限定される
- 各コンテキスト固有のビジネスルールが他のコンテキストに漏れることなく、適切に封じ込められる
マッピングを通じて、異なるコンテキスト間でも必要な情報を共有しながら、各コンテキストの独立性を保つことができます。これはDDDにおける「境界づけられたコンテキスト」の重要な実践方法の一つです。
その他のマッピングアプローチ
-
外部システムとの連携:
外部システム(外部のAPIや内部でもレガシーシステムとか)と連携する場合は、腐敗防止層を使用して外部モデルの変更から内部モデルを保護します。 -
共有カーネル:
密接に関連する2つのコンテキスト間で、限定的な共有モデルを使用することもあります。ただし、共有部分は最小限に抑え、変更の影響範囲を常に考慮する必要があります。
重要なのは、各コンテキストの独立性を保ちつつ、全体としての整合性を維持することです。コンテキストを適切な形で分割し、適切なマッピングを選択することで、柔軟で保守性の高いシステム設計が可能になります。
適切な集約とドメインモデルのサイズ
コンテキストを分けることで、各ドメインモデルは自然と適切なサイズになります。例えば、カタログコンテキストのCatalogProduct
集約は以下のようになります:
// カタログコンテキストの適切なサイズの集約
class CatalogProduct private constructor(
val id: ProductId,
val name: String,
val description: String,
val price: Money,
private val categories: List<Category>,
private val images: List<ProductImage>
) {
companion object {
fun create(name: String, description: String, price: Money): CatalogProduct {
return CatalogProduct(
ProductId.generate(),
name,
description,
price,
emptyList(),
emptyList()
)
}
}
fun addCategory(category: Category): CatalogProduct {
return CatalogProduct(id, name, description, price, categories + category, images)
}
fun addImage(image: ProductImage): CatalogProduct {
return CatalogProduct(id, name, description, price, categories, images + image)
}
fun getPrimaryImage(): ProductImage? {
return images.firstOrNull()
}
fun isInCategory(categoryId: CategoryId): Boolean {
return categories.any { it.id == categoryId }
}
}
data class Category(val id: CategoryId, val name: String)
data class ProductImage(val url: String, val altText: String)
この改善されたアプローチでは:
- カタログコンテキスト固有の「コト」(商品の表示や検索)に焦点を当てたモデルを定義しています
- 集約のサイズが適切になり、商品に関する表示や分類の責務が明確になっています
- 価格設定や在庫管理など、他のコンテキストに関する情報は含まれていません
このように、各コンテキストで集約を適切にモデリングすることで、ビジネスルールを明確に表現し、変更に強い設計が可能になります。異なるコンテキスト間の連携は、ドメインイベントや参照IDを通じて行うことで、コンテキスト間の結合度を低く保つことができます。
まとめ
DDDを導入しても、モデリングに失敗すると扱いにくいモデルになったり、複数のコンテキストの事情を含んだ保守が困難なシステムになってしまいます。適切なモデリングを行うことで、変更容易性が高く、ドメインの本質を捉えたシステムを構築できます。
DDDにおける適切なモデリングは、以下の点に注意することで実現できます:
-
コンテキストを明確に分離する:
- 異なるビジネスの文脈ごとにモデルを分割する
- 各コンテキスト内では一貫した用語と概念を使用する
- コンテキスト間の関係を明示的にマッピングする
-
「モノ」ではなく「コト」(ビジネスプロセス)に注目する:
- 単なるデータの集まりではなく、ビジネス上の振る舞いを中心にモデリングする
- ドメインエキスパートが語る業務プロセスをそのままコードに反映させる
- 「何を持っているか」より「何ができるか」に焦点を当てる
-
適切な粒度の集約を定義する:
- 集約は小さく保ち、必要最小限の要素だけを含める
- 集約のルートを通じてのみ内部要素にアクセスするよう設計する
- トランザクションの一貫性を保つ単位として集約を考える
これらのポイントは互いに関連しています。コンテキストをうまく分けると、それぞれのコンテキストで「コト」に集中しやすくなります。そして「コト」に注目することで、自然と適切なサイズの集約が見えてきます。
コンテキスト間のつなぎ方をきちんと考えて、それぞれのコンテキストの独立性を保ちながらシステム全体がうまく動くようにすれば、ビジネスの変化に柔軟に対応できるシステムが作れます。
DDDの本当の価値は、技術的なパターンだけでなく、ドメインの本質を理解してコードに反映させることにあります。適切なモデリングを通じて、ビジネスとプログラムの間の溝を埋めることが、DDDの一番大切な目標と感じています。
Discussion