永続化と切り離した"純粋"ドメインモデリング入門 - ステート、イベント、Deciderで始めるイミュータブルモデルの実装
株式会社ジェイテックジャパン CTOの高丘 @tomohisaです。
先週開催されたアーキテクチャカンファレンスで登壇し、「Always Valid Domain Model」について話しました。これは、ドメインモデルが常に整合性のある状態を保つことを保証する設計アプローチです。
この記事では、Always Valid Domain Modelを実現するための具体的なドメインモデリング手法について解説します。
はじめに:「永続化と切り離す」とはどういうことか
多くのアプリケーションでは、ドメインモデルがデータベースの構造と密結合しています。ORMのEntityクラスにビジネスロジックを書き、SaveChangesで永続化する。このアプローチには以下の問題があります:
- テストが困難 - DBモックが必要になる
- ロジックが散在 - 「どこで何が起きているか」が追いにくい
- 変更に弱い - DBスキーマ変更がドメインコードに波及する
この記事で紹介するアプローチは、ドメインモデルを純粋なデータと関数の組み合わせとして定義し、永続化の詳細を完全に外部に追い出します。
この記事では、以下の要素をイミュータブルモデルとして定義し、ビジネスロジックを凝集させる方法を紹介します:
- ステート(State): 現在の状態を表すイミュータブルなデータ
- イベント(Event): 状態変化を表すイミュータブルなデータ
- Decider: 変換関数と変換条件をグループ化したもの
コアコンセプト:永続化を知らないドメインモデル
DDDを実践しようとしても、注意しないとテーブル駆動的なコード設計になってしまい、永続化をRepositoryに切り出したとしても、Repositoryの処理自体が永続化手法に依存したコードとなってしまうことが起こりえます。これは、僕の登壇でもエリック・エヴァンスの言葉として引用した通りです。
本当にピュアなドメインモデルは永続化の存在すら知らないべきです。エヴァンスも、インメモリにドメインを置く形で実装することにより、テーブル駆動でRDBの挙動がドメインに現れてしまう問題から解決されることについて述べています。
このアプローチでは、イベントをPure C#のイミュータブルモデルとして定義し、それを呼び出すフローのコマンドはフレームワーク側に分離します。これにより、ドメインモデルは純粋なデータ構造と変換関数だけになります。
コマンド側がフレームワークと繋がっていて、条件が合っていたらイベントを返します。イベントが返されたら、フレームワークが永続化とステートの作成・管理を行います。ドメインモデルは永続化先がPostgreSQLでもCosmosDBでもインメモリでも、一切関知しません。
Deciderパターン:永続化を意識しない設計手法の一例
永続化と切り離したドメインモデリングを実現する方法は複数ありますが、この記事ではDeciderパターンを紹介します。
Deciderパターンは、Jérémie Chassaingが提唱した設計パターンです。このパターンの核心は、ドメインロジックから一切の副作用(永続化、外部API呼び出し、ログ出力など)を排除することにあります。
純粋関数だけでドメインを表現することで、以下のメリットが得られます:
- テストが容易 - DBもモックも不要、純粋な入出力のテスト
- 推論しやすい - 同じ入力には常に同じ出力
- 再利用可能 - インフラに依存しないため、別プロジェクトでも使える
Deciderは以下の7つの要素で構成されます:
| 要素 | 型シグネチャ | 説明 |
|---|---|---|
| Command | 型 | システムへの入力(意図を持った操作) |
| Event | 型 | 状態変化を表す過去形の出来事 |
| State | 型 | システムの現在の状態 |
| initialState | State |
何も起きる前の初期状態 |
| decide | Command -> State -> Event list |
コマンドを受けて判断を下す |
| evolve | State -> Event -> State |
イベントを適用して状態を遷移させる |
| isTerminal | State -> bool |
終端状態かどうかを判定する |
// F#での型定義(Jérémie Chassaingの記事より)
type Decider<'c,'e,'s> =
{ decide: 'c -> 's -> 'e list
evolve: 's -> 'e -> 's
initialState: 's
isTerminal: 's -> bool }
decide と evolve の分離
Deciderパターンの核心は、判断(decide)と状態遷移(evolve) を明確に分離することです。
decide関数
type Decide = Command -> State -> Event list
コマンドと現在の状態を受け取り、「何が起きたか」をイベントのリストとして返します。この関数は:
- 現在の状態を変更しない(イミュータブル)
- 純粋関数である(同じ入力に対して常に同じ出力)
- 副作用を持たない(ログ出力すら不要)
evolve関数
type Evolve = State -> Event -> State
現在の状態とイベントを受け取り、新しい状態を返します。この関数は:
- 例外をthrowしてはいけない(リプレイで使われるため)
- 非常にシンプルであるべき(フィールドの設定、リストへの追加など)
- 判断はすでにdecide側で行われている
なぜ分離するのか?
Evolve関数はイベントのリプレイで使われます。リプレイとは、保存されたイベントを順番に再生してステートを再構築する処理です。リプレイ時は、過去に一度成功したイベントを再適用するだけなので、例外をthrowしてはいけません。
一方、Sekibanの実装ではValidate関数でdecideの「判断」部分を担います。コマンド実行時にイベント発行前に呼ばれ、現在の状態でイベントを追加してもよいかを確認します。
Sekiban DCBでの実装:永続化を隠蔽したDecider
Sekiban DCB版を使うと、開発者は永続化を一切意識せずにDeciderパターンを実装できます。以下のマッピングでDeciderの7要素をC#で表現します:
| Deciderの要素 | Sekiban DCBでの実装 |
|---|---|
| Command | ICommandWithHandler<T> |
| Event | イミュータブルなrecord |
| State | イミュータブルなrecord |
| decide | コマンドハンドラー + Validate関数 |
| evolve | DeciderのEvolveメソッド + Projector |
| initialState | EmptyTagStatePayload |
| isTerminal | 状態の判定ロジック(必要に応じて) |
具体例:銀行口座の出金処理
// ステート(現在の状態)- イミュータブルなrecord
public record BankAccountState(
Guid AccountId,
string AccountName,
decimal Balance,
bool IsActive);
// イベント(状態変化)- イミュータブルなrecord
public record MoneyWithdrawn(
Guid AccountId,
decimal Amount,
DateTime WithdrawnAt);
// Decider - staticクラスで拡張メソッドとして定義
public static class MoneyWithdrawnDecider
{
/// <summary>
/// 検証(decideの一部):送金前に残高が足りていることを確認
/// </summary>
public static void Validate(this BankAccountState state, MoneyWithdrawn ev)
{
if (!state.IsActive)
throw new InvalidOperationException("口座が無効です");
if (state.Balance < ev.Amount)
throw new InvalidOperationException("残高が不足しています");
}
/// <summary>
/// 進化:イベントを適用して新しいステートを生成
/// </summary>
public static BankAccountState Evolve(this BankAccountState state, MoneyWithdrawn ev)
{
return state with { Balance = state.Balance - ev.Amount };
}
}
実際のプロダクションコードでは、Deciderをstaticクラスの拡張メソッドとして定義することで、以下のメリットがあります:
- ステートに対する操作として自然に読める(
state.Validate(ev)、state.Evolve(ev)) - イベントごとにファイルを分離でき、責務が明確になる
- XMLコメントでドキュメント化しやすい
この例では、送金処理前に残高が足りていることを確認するロジックがValidate関数に定義されています。重要なのは、この判断ロジックがコマンド側にあるのではなく、コアのイミュータブルモデル内にあることです。これにより、知識が凝集されます。
ドメインモデリングの進め方:永続化を忘れて設計する
永続化と切り離したドメインモデリングでは、最初から最後までDBのことを考えません。イミュータブルモデルのステート、イベント、Deciderを定義していくことで、データと関数の組み合わせで構築していきます。
ステップ1: イベントを洗い出す
まず、ドメインで発生する「出来事」を洗い出します。
// 銀行口座ドメインのイベント例
public record AccountOpened(Guid AccountId, string AccountName, DateTime OpenedAt);
public record MoneyDeposited(Guid AccountId, decimal Amount, DateTime DepositedAt);
public record MoneyWithdrawn(Guid AccountId, decimal Amount, DateTime WithdrawnAt);
public record AccountClosed(Guid AccountId, DateTime ClosedAt);
ステップ2: ステートを定義する
イベントによって変化する状態を定義します。
public record BankAccountState(
Guid AccountId,
string AccountName,
decimal Balance,
bool IsActive,
DateTime? ClosedAt);
ステップ3: Deciderを実装する
各イベントに対して、ValidateとEvolveを別々のstaticクラスとして実装します。これにより、イベントごとにファイルを分離でき、見通しが良くなります。
// AccountOpenedDecider.cs
public static class AccountOpenedDecider
{
public static void Validate(this BankAccountState state, AccountOpened ev)
{
// 新規作成なので特に検証なし
}
public static BankAccountState Evolve(this BankAccountState state, AccountOpened ev)
{
return new BankAccountState(
ev.AccountId,
ev.AccountName,
Balance: 0,
IsActive: true,
ClosedAt: null);
}
}
// MoneyDepositedDecider.cs
public static class MoneyDepositedDecider
{
public static void Validate(this BankAccountState state, MoneyDeposited ev)
{
if (!state.IsActive)
throw new InvalidOperationException("口座が無効です");
if (ev.Amount <= 0)
throw new ArgumentException("入金額は正の値である必要があります");
}
public static BankAccountState Evolve(this BankAccountState state, MoneyDeposited ev)
{
return state with { Balance = state.Balance + ev.Amount };
}
}
// AccountClosedDecider.cs
public static class AccountClosedDecider
{
/// <summary>
/// 検証:閉鎖条件を確認
/// - まだ閉鎖されていないこと
/// - 残高が0であること
/// </summary>
public static void Validate(this BankAccountState state, AccountClosed ev)
{
// 冪等性確保: 既に閉鎖済みの場合は検証をスキップ
if (!state.IsActive)
return;
if (state.Balance != 0)
throw new InvalidOperationException("残高が0でないと閉鎖できません");
}
public static BankAccountState Evolve(this BankAccountState state, AccountClosed ev)
{
// リプレイ時の安全性: 既に閉鎖済みの場合はそのまま返す
if (!state.IsActive)
return state;
return state with { IsActive = false, ClosedAt = ev.ClosedAt };
}
}
ステップ4: 計算ロジック・共通ロジックを定義する
特に複雑なドメインでは、計算処理や共通処理が多く存在します。すでにステートがAlways Valid Domain Modelとして定義されていれば、Nullチェックなどの防御的コードは不要になり、計算ロジックに集中できます。
これらの計算関数は、多くの場合コマンドから呼び出され、イベントを確定する際に使用されます。この手法では、計算ロジックも純粋関数として定義・モデリングできます。
// 銀行口座の計算ロジック - ステートの拡張メソッドとして定義
public static class BankAccountCalculations
{
/// <summary>
/// 月末利息を計算する
/// 口座タイプ、残高、契約オプションによって複雑な計算が必要
/// </summary>
public static decimal CalculateMonthlyInterest(
this BankAccountState state,
InterestRateOptions options,
DateTime calculationDate)
{
// ステートはAlways Validなので、IsActiveのチェックのみで十分
if (!state.IsActive) return 0m;
var baseRate = options.GetBaseRate(state.AccountType);
var bonusRate = state.Balance >= options.BonusThreshold
? options.BonusRate
: 0m;
// 複雑な日割り計算
var daysInMonth = DateTime.DaysInMonth(calculationDate.Year, calculationDate.Month);
var dailyRate = (baseRate + bonusRate) / 365;
return Math.Round(state.Balance * dailyRate * daysInMonth, 2);
}
/// <summary>
/// 出金可能額を計算する(残高 - 最低残高要件)
/// </summary>
public static decimal CalculateWithdrawableAmount(
this BankAccountState state,
decimal minimumBalanceRequirement)
{
if (!state.IsActive) return 0m;
return Math.Max(0, state.Balance - minimumBalanceRequirement);
}
/// <summary>
/// 手数料を計算する
/// </summary>
public static decimal CalculateTransactionFee(
this BankAccountState state,
decimal transactionAmount,
FeeSchedule feeSchedule)
{
// 残高に応じた手数料優遇
if (state.Balance >= feeSchedule.FeeWaiverThreshold)
return 0m;
return feeSchedule.CalculateFee(transactionAmount);
}
}
// 利息オプション(これもイミュータブル)
public record InterestRateOptions(
decimal StandardRate,
decimal PremiumRate,
decimal BonusRate,
decimal BonusThreshold)
{
public decimal GetBaseRate(AccountType accountType) => accountType switch
{
AccountType.Standard => StandardRate,
AccountType.Premium => PremiumRate,
_ => StandardRate
};
}
これらの計算ロジックは、コマンドハンドラーから以下のように使用されます:
// 利息付与コマンドでの使用例
public record ApplyMonthlyInterestCommand(Guid AccountId, DateTime CalculationDate)
: ICommandWithHandler<ApplyMonthlyInterestCommand>
{
public static async Task<EventOrNone> HandleAsync(
ApplyMonthlyInterestCommand command,
ICommandContext context)
{
var state = await context.GetStateAsync<BankAccountStatePayload, BankAccountProjector>(
new BankAccountTag(command.AccountId));
var options = await context.GetInterestRateOptions(); // 設定取得
// 純粋関数で利息を計算
var interest = state.Payload.State.CalculateMonthlyInterest(options, command.CalculationDate);
if (interest <= 0) return EventOrNone.None;
var ev = new InterestApplied(command.AccountId, interest, command.CalculationDate);
state.Payload.State.Validate(ev);
return EventOrNone.From(ev, new BankAccountTag(command.AccountId));
}
}
Sekiban DCBとの連携:薄いラッパーとしてのイベントソーシング実装
ここまで定義してきた純粋なデータ(State、Event)と関数(Validate、Evolve、計算ロジック)は、イベントソーシングフレームワークを一切知りません。この設計により、Sekiban DCBとの連携は非常に薄い実装で済みます。
Projector:Evolveを呼び出すだけのシンプルな実装
Sekiban DCB版では、Projectorがイベントからステートを構築する役割を担います。Projectorは各イベントに対応するDeciderのEvolveメソッドを呼び出すだけです。
/// <summary>
/// 銀行口座プロジェクター
/// イベントから銀行口座の状態を再構築
/// </summary>
public class BankAccountProjector : ITagProjector<BankAccountProjector>
{
public static ITagStatePayload Project(ITagStatePayload current, Event ev)
=> (current, ev.Payload) switch
{
// 口座開設で新規ステートを作成
(EmptyTagStatePayload, AccountOpened opened) =>
new BankAccountStatePayload(
new BankAccountState(Guid.Empty, "", 0, false, null).Evolve(opened)),
// 入金
(BankAccountStatePayload state, MoneyDeposited deposited) =>
state with { State = state.State.Evolve(deposited) },
// 出金
(BankAccountStatePayload state, MoneyWithdrawn withdrawn) =>
state with { State = state.State.Evolve(withdrawn) },
// 口座閉鎖
(BankAccountStatePayload state, AccountClosed closed) =>
state with { State = state.State.Evolve(closed) },
_ => current
};
public static string ProjectorVersion => "1.0.0";
public static string ProjectorName => nameof(BankAccountProjector);
}
public record BankAccountStatePayload(BankAccountState State) : ITagStatePayload;
Projectorの実装は非常にシンプルです:
- パターンマッチングで各イベントを振り分け
- すでに定義済みの
Evolveメソッドを呼び出すだけ - イベント追加時は新しいcaseを1行追加するだけ
コマンドハンドラー:Validateを呼び出すだけの薄いラッパー
コマンドハンドラーも同様に、すでに定義済みのValidateを呼び出すだけの薄い実装になります。
/// <summary>
/// 出金コマンド
/// </summary>
public record WithdrawMoneyCommand(
Guid AccountId,
decimal Amount,
DateTimeOffset WithdrawalTime
) : ICommandWithHandler<WithdrawMoneyCommand>
{
public static async Task<EventOrNone> HandleAsync(
WithdrawMoneyCommand command,
ICommandContext context)
{
// 1. 現在のステートを取得(Sekibanがイベントをリプレイして構築)
var accountTag = new BankAccountTag(command.AccountId);
var accountState = await context.GetStateAsync<BankAccountStatePayload, BankAccountProjector>(accountTag);
// 2. イベントを作成
var ev = new MoneyWithdrawn(
command.AccountId,
command.Amount,
command.WithdrawalTime.DateTime);
// 3. Deciderのバリデーションを実行
// 条件を満たしていない場合は例外がスローされる
accountState.Payload.State.Validate(ev);
// 4. イベントを返す(Sekibanが自動的に永続化)
return EventOrNone.From(ev, accountTag);
}
}
フレームワークとの責務分担
開発者が実装すること(純粋なドメインモデル):
- State、Event の定義
- Validate、Evolve 関数
- 計算ロジック
Sekiban DCBが担当すること(インフラ層):
- イベントの永続化(PostgreSQL、CosmosDBなど)
- ステートの再構築(イベントリプレイ)
- 並行制御
- スナップショット管理
まとめ:永続化と切り離すことで得られるもの
この記事で紹介したアプローチの本質は、ドメインモデルを永続化から完全に切り離すことです。
| 要素 | 役割 | 永続化との関係 |
|---|---|---|
| ステート | 現在の状態を表すイミュータブルなデータ | DBスキーマを知らない |
| イベント | 状態変化を表すイミュータブルなデータ | 保存方法を知らない |
| Validate | イベント追加の条件を検証する関数 | 純粋関数、副作用なし |
| Evolve | イベントを適用して新ステートを生成する関数 | 純粋関数、副作用なし |
| Projector | イベントからステートを再構築する関数 | フレームワークから呼ばれる |
永続化と切り離すメリット
-
テストが劇的に簡単になる
- DBセットアップ不要
- ステートを直接作って、イベントを適用して、結果を検証するだけ
-
インフラの変更に強い
- PostgreSQLからCosmosDBへの移行?ドメインコードは一切変更不要
-
ビジネスロジックが見つけやすい
- 「残高が足りているか?」→ Validate関数を見る
- 「残高はどう計算される?」→ Evolve関数を見る
-
AIとの相性が良い
- 純粋関数なので、AIがロジックを理解しやすく、コード生成もしやすい
Sekiban DCB版を使用することで、永続化やイベントストアの詳細を意識することなく、ドメインモデリングに集中できます。開発者はValidate、Evolve、Projectorを定義するだけで、残りはフレームワークが処理します。
Validate(またはThrowIfStateIsInconsistent)でも、ReturnResultIfInconsistentでも本質は変わりません。重要なのは、データと関数の組み合わせでドメインを表現し、永続化を外に追い出すというアプローチです。
イベントソーシングでなくても使えますか?
はい、使えます。 この手法の本質は、ステート間の差分Δをイベントとしてモデリングし、変換関数を純粋関数としてデザインすることにあります。
state2 = Evolve(state1, event)
この考え方はどの言語でも使えますし、イベントソーシングを採用しなくても適用可能です。LLMにとっても理解しやすい構造なので、AIとの協働でもうまく機能します。
イベントソーシングなしで使う場合
イベントソーシングを使わない場合、ステート(State)を直接データベースに保存することになります。この場合:
- Always Valid Domain Modelとして定義されたステートを、RDBのテーブルにマッピングする必要がある
- イミュータブルなrecordとRDBのミュータブルなレコードは相性があまり良くない
- 変換関数(マッパー)を作成して使用することは可能
// RDBに保存する場合の変換例
public static BankAccountEntity ToEntity(this BankAccountState state)
{
return new BankAccountEntity
{
AccountId = state.AccountId,
AccountName = state.AccountName,
Balance = state.Balance,
IsActive = state.IsActive,
ClosedAt = state.ClosedAt
};
}
public static BankAccountState ToDomain(this BankAccountEntity entity)
{
return new BankAccountState(
entity.AccountId,
entity.AccountName,
entity.Balance,
entity.IsActive,
entity.ClosedAt);
}
イベントソーシングを使う場合のメリット
イベントソーシングを採用すると、モデリングしたイベントをそのまま保存できます。ステートとDBの間の変換が不要になり、よりスムーズです。
| 観点 | イベントソーシングなし | イベントソーシングあり |
|---|---|---|
| 保存対象 | ステート(現在の状態) | イベント(状態変化の履歴) |
| DB変換 | 必要(マッパー作成) | 不要(イベントをそのまま保存) |
| 履歴 | 追加実装が必要 | 自動的に残る |
| リプレイ | 不可 | 可能 |
参考
- Functional Event Sourcing Decider - thinkbeforecoding - Jérémie ChassaingによるDeciderパターンの解説
- Sekiban - Event Sourcing Framework for .NET
関連記事
Findyさんから登壇の録画が公開されています。よろしかったらFindy登録は必要ですが以下のリンクからご覧ください。
Discussion