永続化、集約、イベント
状態変更とドメインイベントを別操作で保存すると、リトライや障害のたびに不整合が残る。Kamaeでは集約境界・楽観的ロック・アウトボックスをセットで設計し、1コマンドの作業単位をユースケースが所有する。
状態型と遷移は 状態遷移 と ドメインモデリング が前提。配線はスキルリポジトリの references/application-wiring.md を参照する。
集約とトランザクション境界
Section titled “集約とトランザクション境界”1つの集約ルートが、まとめて変わる必要のある不変条件を所有する。ユースケースはその集約をロードし、純粋遷移するし、ストレージモデルが許す範囲で1トランザクション境界内に結果を永続化する。
def saveAssigned(state: EnRouteRequest, events: List[TaxiRequestEvent]): F[Unit]アダプタは状態の書き込みとイベントの追記を原子的に行う。
集約横断ルールはID、スナップショット、ドメインイベント、または後続ユースケースを使う。2つの集約ルートをメモリ上で変更し、呼び出し側が両方saveしてくれることを期待しない。
1 集約、1 トランザクション
Section titled “1 集約、1 トランザクション”ユースケースが1つの集約ルートを変更するとき、新しい状態と発行されたイベントは単一トランザクションで永続化する。
begin/load -> authorize -> transition (pure) -> save state + events -> commitドメインコードはトランザクションをbegin / commitしない。portはadapterが原子的に実装する操作を公開する。
stateとoutbox / event行の一貫性が必要なら、save_* portメソッドは1 DBトランザクションで両方を書く。呼び出し側がstateとeventを別メソッドで保存できるAPIは避ける。
楽観的並行性がデフォルト
Section titled “楽観的並行性がデフォルト”競合する集約には、集約ルートに単調増加 version または updated_at チェックを付ける。load portは現行versionを返し、save portは古い書き込みを拒否する。
集約がversionフィールドを使うとき、遷移は期待versionを検証し、黙って上書きするのではなく型付きのリトライ可能エラーを返す。
典型的フロー:
- 現行version付きで集約をロード
- 値上で純粋遷移
expected_versionでsave- 0行更新またはversion不一致を
ConcurrentModificationにマップ
競合は型付きユースケースエラーとして公開し、呼び出し側がリトライまたは409を返せるようにする。
悲観的ロック(SELECT ... FOR UPDATE など)は、在庫予約、座席ホールド、台帳記帳のように短く境界の明確なクリティカルセクション向け。ロックはadapterトランザクション内で取得し、ドメインコードではない。
集約横断の調整
Section titled “集約横断の調整”1コマンドが複数ルートに触れるとき:
| 状況 | 推奨アプローチ |
|---|---|
| 1 ルートが決定を所有し、他は事実だけ必要 | ID でスナップショットまたは read model をクエリ |
| 両ルート変更が必要で、一方失敗時に他方をロールバック | 単一ユースケース、明示順序、saga / outbox、または datastore が許す 1 トランザクション境界 |
| 結果整合性(eventual consistency)で足りる | ドメインイベント + 下流 consumer |
集約横断オーケストレーションをrepository adapter内に隠さない。ユースケースがビジネスステップを名指しする。
責務でリポジトリを分離する
Section titled “責務でリポジトリを分離する”repository traitはORMの都合ではなくドメインのニーズを表現する。read / writeインターフェースは小さく保つ。
trait TaxiRequestRepository[F[_]]: def findWaiting(id: RequestId): F[Option[WaitingRequest]] def saveAssigned(state: EnRouteRequest, events: List[TaxiRequestEvent]): F[Unit]doobie、slickなどのアダプタがこれらを実装する。ドメインコードはドライバ固有の型をimportしない。
アダプタはイベントを発明しない
Section titled “アダプタはイベントを発明しない”ビジネスイベントを作るのはドメイン遷移だけである。リポジトリは Transition(state, events) でドメインが返したものを永続化する。adapterがeventを「補完」すると監査とリプレイの信頼性が失われ、テストでも本番と異なる経路が生まれる。
eventレコードは明示的なcase classまたはenumでモデル化し、identifier、timestamp、aggregate id、event name / type、payloadを含める。event payloadでは型付きtimestamp、money、単位を使う。裸の String、Long、Double より OccurredAt、Money、DistanceMeters、CurrencyCode など。
データベースに不変条件をミラーする
Section titled “データベースに不変条件をミラーする”制約、check制約、ドメイン状態を反映するenum列を、実用的な範囲で使う。ドメインがすでに拒否した内容をDBが再検証する必要はないが、破損行の黙った挿入は防ぐ。
一意性、テナント所有権、非負残高、有効なライフサイクル状態、外部キー存在をDBが強制できるのに、アプリケーション検査だけに頼る永続化を避ける。
冪等なリトライ処理
Section titled “冪等なリトライ処理”リトライされうるコマンド(HTTPクライアント、queue consumer、outbox processor)は CommandId またはidempotency keyを持つ。state変更と一緒、またはdedupeテーブルに永続化し、重複配送が遷移を二重適用しないようにする。
outboxとevent consumerは、idempotency keyまたはドメインIDから導出した自然キーで重複を許容する設計にする。idempotencyはhandlerの後付けではなく、トランザクションストーリーの一部として扱う。
イベントのバージョニング
Section titled “イベントのバージョニング”イベントスキーマが進化するときは、payloadにversionを付け、境界で後方互換の読み取りをサポートする。
- 新variantまたは新
event_versionを追加する。古いevent_type文字列を別payload形状で再利用しない - リーフは往復可能なvalue objectまたはDTOとする
- 非同期に保存または消費されるイベントには、明示的な型 / バージョンとスキーマ進化戦略を文書化する
行マッピングと境界防御
Section titled “行マッピングと境界防御”persistence adapterもHTTPやキューと同様に、DTO → ドメイン変換のルールに従う(境界防御 参照)。たとえば en_route 行に driver_id がNULLのまま読み込まれた場合、無効な EnRouteRequest を組み立てて遷移に渡すのではなく、adapterで RepositoryError.CorruptRow として返す。
レビュー観点
Section titled “レビュー観点”1 ユースケースがトランザクション境界を所有しているか — High
Section titled “1 ユースケースがトランザクション境界を所有しているか — High”単一ユースケースがアトミックな作業単位を調整せず、無関係な複数の呼び出し元から状態保存・イベント発行・メッセージ公開を担うワークフローを指摘する。
リトライと重複コマンドは境界で冪等か — High
Section titled “リトライと重複コマンドは境界で冪等か — High”冪等キーや重複排除レコードなしに同一遷移を二重適用しうるコマンドハンドラやコンシューマを指摘する。
リトライと重複配信は冪等か — High
Section titled “リトライと重複配信は冪等か — High”冪等キーや重複排除レコードなしに、金額、在庫、ライフサイクル遷移、通知を二重適用しうるコマンド、イベントハンドラ、アウトボックスプロセッサ、外部呼び出しを指摘する。
状態とドメインイベントは原子的に永続化されているか — High
Section titled “状態とドメインイベントは原子的に永続化されているか — High”トランザクションやアウトボックスパターンなしに、集約状態の保存とイベントの公開 / 挿入を別操作で行うユースケースを指摘する。
競合書き込みには楽観的並行性が扱われているか — High
Section titled “競合書き込みには楽観的並行性が扱われているか — High”残高、ライフサイクル状態、在庫、その他高競合集約のload / modify / saveに、バージョンチェック、compare-and-swap、または同等のDB制約がない場合は指摘する。
ゼロ行更新とバージョン不一致は、黙った成功ではなく ConcurrentModification のような型付きエラーへマップする。
集約不変条件はルート経由でのみ変更されるか — High
Section titled “集約不変条件はルート経由でのみ変更されるか — High”集約ルートの遷移メソッドや型付き状態構造体を迂回して、子エンティティやライフサイクル状態を変更するコードを指摘する。
DB 制約は重要な不変条件を反映しているか — Medium
Section titled “DB 制約は重要な不変条件を反映しているか — Medium”一意性、テナント所有権、非負残高、有効なライフサイクル状態、外部キー存在をDBが強制できるのに、アプリケーション検査だけに頼る永続化を指摘する。
イベントは永続化アダプタ外で生成されているか — Medium
Section titled “イベントは永続化アダプタ外で生成されているか — Medium”ユースケース / ドメイン層から供給されたイベントを永続化するのではなく、リポジトリ内部でビジネスイベントを発明する箇所を指摘する。
リポジトリ trait はドメインのニーズを表現しているか — Medium
Section titled “リポジトリ trait はドメインのニーズを表現しているか — Medium”ユースケースが実際に必要とする小さなインターフェースではなく、ORM CRUDを写した大きなリポジトリtraitを指摘する。
悲観的ロックはスコープが限定され正当化されているか — Medium
Section titled “悲観的ロックはスコープが限定され正当化されているか — Medium”楽観的並行性やDB制約で足りるのに、特にエフェクトbindをまたぐ広いまたは長時間のロックを指摘する。
永続化イベントはバージョン付けされているか — Medium
Section titled “永続化イベントはバージョン付けされているか — Medium”イベントを非同期に保存または消費するのに、明示的なイベント型 / バージョン、スキーマ進化戦略、後方互換デシリアライズがないイベントペイロードを指摘する。
集約横断の調整は明示的か — Medium
Section titled “集約横断の調整は明示的か — Medium”メモリ上で2つの集約ルートを変更し、呼び出し元の両方永続化に頼るユースケースやリポジトリを指摘する。イベント、saga、スナップショット、文書化された単一トランザクション戦略を提案する。