永続化と切り離した"純粋"ドメインモデリング入門 - ステート、イベント、Deciderで始めるイミュータブルモデルの実装
#ドメイン駆動設計(DDD) #アプリケーションアーキテクチャ #AI時代のエンジニアリング #設計原則 #品質特性 #プラクティス
#共有する
ジェイテックジャパン高丘さんの記事。
https://zenn.dev/jtechjapan_pub/articles/fc9878ec69b6a1
一言で言えば、「データベースの都合でロジックを汚さず、全ての履歴を残し、AIを活用しつつもコントロール権を人間が握る設計」 が重要であると説かれている。
/icons/hr.icon
1. 設計の核となる「4つの柱」
将来的な手戻りや負債(=後悔)を最小限にするための中心的な指針。
永続化と切り離した純粋なドメインモデル (In-memory Always Valid)
データベース(RDBなど)の都合やスキーマに依存させず、ドメインロジックを設計する。
「データが存在する限り、常に整合性が取れている(Validである)」状態を保証する。
これにより、テストが高速化し、ビジネスロジックが技術的制約から解放される。
データを消さない (Event Sourcing)
現在の状態(State)だけでなく、発生した事実(Event)を全て記録する。
イベントは「貯蓄(資産)」であり、過去の履歴が完全に残ることで、監査やデバッグ、将来の分析に役立つ。
最初からスケールアウト設計
小さく始めても、将来的にリニアに拡張できる構造(アクターモデルなど)を採用しておく。
読み書きの分離 (CQRS)
更新(Write)と参照(Read)のモデルを分け、それぞれ独立して進化できるようにする。
2. 具体的な実装アプローチ
上記の思想を具現化するための技術的な要点。
イミュータブル(不変)モデルの徹底
状態(State)やイベント(Event)はイミュータブルなデータとして定義する。
ロジックは「データ」と「変換関数(Decider)」に分離し、副作用を排除する。
偶有的複雑性(永続化など外界との入出力)の排除
ビジネス本来の複雑さ(本質的複雑性)と、技術的な制約による複雑さ(偶有的複雑性)を区別する。
永続化(DB保存)の処理をドメインロジックから追い出すことで、コードが技術的負債に侵食されるのを防ぐ。
3. AI時代における設計者の役割
AIは「増幅器」、アーキテクチャは「羅針盤」
AIはコード生成などの能力を増幅させるが、方向性を決めることはできない。
設計者がアーキテクチャという「羅針盤」と「ガードレール(制約)」を提供することで、AIが生成するコードの品質と整合性を保つ。
/icons/hr.icon
Deciderパターン
Deciderパターンは、イベントソーシング(Event Sourcing)やドメイン駆動設計(DDD)において、「意思決定(ビジネスロジック)」と「状態の計算(状態遷移)」を明確に分離し、純粋関数として実装するデザインパターン。
つまり、ドメインモデルを「判断する頭脳(Decide)」と「記憶する身体(Evolve/State)」に分け、それらを純粋関数として実装するアーキテクチャスタイル。
これにより、「ビジネスロジックの複雑さ」と「技術的な複雑さ(DB保存など)」を完全に切り離すことが可能になる。
1. Deciderパターンの核となる「3つの関数」
Deciderパターンでは、ドメインロジックをクラスのメソッドに散在させず、以下の3つの要素を持つ1つの構造(Decider)として定義する。
これらはすべて純粋関数(副作用を持たず、入力のみで出力が決まる関数)であることが最大の特徴。
① Decide(意思決定)
役割: 「やりたいこと(Command)」と「現在の状態(State)」を受け取り、ビジネスルールに照らし合わせて「結果(Event)」を返す。
シグネチャ: (Command, State) -> Event[]
ポイント: ここでバリデーションを行います。「在庫が足りないからエラー」や「注文を受け付けた」と判断するが、状態の変更は行わない。ただ「何が起きたか(イベント)」を返すだけ。
② Evolve(状態の進化 / Apply)
役割: 「現在の状態(State)」と「起きたこと(Event)」を受け取り、「新しい状態(New State)」を返す。
シグネチャ: (State, Event) -> State
ポイント: ここにはビジネスロジック(判断)を含まない。単に「イベントの内容を状態に反映する(畳み込み)」だけのロジックを書く。これにより、過去のイベントを再生(Replay)して現在地を復元することが可能になる。
③ InitialState(初期状態)
役割: 何も起きていない初期の状態を定義する。
シグネチャ: State
2. 処理の流れ(フロー)
通常のOOP(ミュータブルなオブジェクト指向)では「メソッドを呼ぶと、その中で判断して、その場でメンバ変数を書き換える」ことが多いですが、Deciderではこれらが明確に分かれる。
1. Command(ユーザーの要求:例「本を借りたい」)が来る。
2. Decide関数が、現在のState(例:貸出可)を見て判断する。
OKなら → BookCheckedOut イベントを生成して返す。
NGなら → BookNotAvailable エラーイベント等を返す。
3. (ここでイベントをDBに保存するなどの副作用が発生する場合がある)
4. Evolve関数が、現在のStateと、生成されたEventを受け取り、次のState(例:貸出中)を作って返す。
3. 具体的なコードイメージ(C#風)
先ほどの記事の文脈に合わせて、C#のレコード(Record)を使った擬似コードで書くと非常にスッキリ。
code:csharp
// 1. データ構造(イミュータブル)
public record State(int Count);
public record Command(string Type);
public record Event(string Type);
// 2. Deciderの実装(純粋関数のみ)
public static class CounterDecider
{
// 初期状態
public static State InitialState => new State(0);
// Decide: ロジック・判断 (Command + State -> Events)
public static Event[] Decide(Command command, State state)
{
return command.Type switch
{
"Increment" => new[] { new Event("Incremented") },
"Decrement" when state.Count > 0 => new[] { new Event("Decremented") },
"Decrement" => throw new InvalidOperationException("これ以上減らせません"), // またはエラーイベント
_ => Array.Empty<Event>()
};
}
// Evolve: 状態遷移 (State + Event -> New State)
public static State Evolve(State state, Event @event)
{
return @event.Type switch
{
"Incremented" => state with { Count = state.Count + 1 },
"Decremented" => state with { Count = state.Count - 1 },
_ => state
};
}
}
4. なぜこれが「後悔しない設計」につながるのか?
テストが圧倒的に楽(モック不要)
DBもHTTPも不要。「このStateでこのCommandを投げたら、このEventが返るか?」という入出力のテストだけで、ビジネスロジックを100%検証できる。
Always Valid(常に正しい状態)
Evolve関数(状態遷移)を正しく定義すれば、不正なイベントが適用されない限り、状態が壊れることない。
インフラとの完全な分離
Decider自体は「DBにどう保存するか」を一切知る必要がない。メモリ上で動かしても、RDBに使っても、ファイルシステムに使っても、ロジックコードは1行も書き換える必要がない。
AIとの相性が良い
構造が決まっており、副作用がないため、AIに「この仕様を満たすDecide関数を書いて」と指示すると、非常に精度の高いコードが生成されやすい?