コンテンツにスキップ

DDDで考えるフールプルーフ設計

「注文を確定する前に明細を確認する」と手順書に書いても、APIやバッチを追加した人が確認を忘れるかもしれません。DDDでは、どの呼び出し元からでも同じドメインルールを通るように、モデルの操作を設計することができます。

これがドメインモデルにフールプルーフの考え方を応用する一つの方法です。フールプルーフ自体の意味は基礎編で説明しています。

注文に次のルールがあるとします。

  • 数量は1以上99以下
  • 同じ商品を二重に追加できない
  • 明細がない注文は確定できない
  • 確定済みの注文は変更できない

アプリケーションサービスが毎回チェックしてから、公開されたプロパティを書き換える構造では、呼び出し元が増えるほど確認漏れの機会も増えます。

// 避けたい例:呼び出し側が手順を守らないと、不正な状態を作れる
if (order.items.isNotEmpty()) {
order.status = OrderStatus.CONFIRMED
}

別の呼び出し元が order.status = OrderStatus.CONFIRMED だけを実行できるなら、チェックは強制されていません。

ルール主な置き場所理由
数量は1以上99以下Quantity 値オブジェクト数量単体で判定でき、生成時に拒否できる
商品の重複禁止Order 集約ルート明細全体を見て判定する
明細なしで確定しないOrder 集約ルート注文の状態遷移と明細を同時に確認する
確定後の変更禁止Order 集約ルート変更操作の入口で現在の状態を確認する

値の局所的な条件は値オブジェクトに、複数の要素や状態遷移にまたがる条件は集約ルートに置くと、責務を分けやすくなります。

以下は設計の要点だけを示したKotlinの例です。下書き注文には明細が0件でもよく、確定時には1件以上必要というルールにしています。

@JvmInline
value class Quantity(val value: Int) {
init {
require(value in 1..99) { "数量は1以上99以下です" }
}
}
data class OrderItem(val productId: String, val quantity: Quantity)
enum class OrderStatus { DRAFT, CONFIRMED }
class Order {
private val items = mutableListOf<OrderItem>()
var status: OrderStatus = OrderStatus.DRAFT
private set
// 外部へ内部の可変リストを渡さない
fun items(): List<OrderItem> = items.toList()
fun addItem(productId: String, quantity: Quantity) {
check(status == OrderStatus.DRAFT) { "確定済みの注文は変更できません" }
check(items.none { it.productId == productId }) { "商品が重複しています" }
items.add(OrderItem(productId, quantity))
}
fun confirm() {
check(status == OrderStatus.DRAFT) { "すでに確定済みです" }
check(items.isNotEmpty()) { "明細がない注文は確定できません" }
status = OrderStatus.CONFIRMED
}
}

外部に公開するのは addItem() と confirm() です。状態を直接書き換えるsetterや、内部の可変リストを公開していません。items() はコピーを返すため、呼び出し側が返された一覧を操作しても集約内部は変わりません。Kotlinの List は読み取り専用のインターフェースであり、それだけで元の可変リストを保護できるわけではない点にも注意します。

OrderItem を外から直接更新できる形で公開しないことも重要です。実際に数量変更が必要なら、Order.changeItemQuantity() のような操作を追加し、その中で注文状態と新しい数量を確認します。

オブジェクトのメソッドを整えても、別の更新経路が子エンティティを直接保存できると、集約ルートの検証を通りません。アプリケーションサービスは OrderRepository から注文を取得し、集約ルートの操作を呼んでから保存します。OrderItemRepository を公開して明細だけ更新する設計は、この例では避けます。

flowchart LR
    A["API・バッチ"] --> B["アプリケーションサービス"]
    B --> C["OrderRepository"]
    C --> D["Order 集約ルート"]
    D --> E["OrderItem"]

ただし、ドメインモデルだけでシステム内のすべての経路を閉じられるわけではありません。SQLによる直接更新、データ移行、別サービスからの書き込み、競合する同時更新などは、権限・トランザクション・楽観ロック・データベース制約も含めて対策を考えます。たとえば (order_id, product_id) の一意制約は、商品の重複を保存時にも検出する補助になります。

  1. どの呼び出し元も、同じルールを通らないと状態を変えられないか。
  2. 値を作る時点で拒否できる不正値を、後の処理まで持ち込んでいないか。
  3. 内部の可変オブジェクトやコレクションを外へ渡していないか。
  4. 子エンティティだけを保存する経路や、直接SQL更新する経路はないか。
  5. 正常な操作に加えて、不正な生成・変更・確定を拒否することをテストしたか。

フールプルーフの目標は、呼び出し元に「忘れずに確認する」責任を増やすことではありません。ドメインルールを通る操作だけを使えるようにし、迂回しようとすると早く失敗する構造を作ることです。

AI開発で意図しない実装を減らすために、堅牢なドメインをどう先に設計するかはAI開発で意図しない実装を減らすには?で扱います。