ドメインモデルは書き込みの道にだけ置く
handler・usecase・domain・repository・readmodel の5層を図で示す。要点は、リクエストの道が書きと読みで分かれていて、ドメインモデル(不変条件と状態遷移)が居るのは書き込みの道だけということ。読みは Entity を経由せず SQL が直接応答の形を作る。
サービス1個分のコード構造を図にしたら思ったより伝わったので、記事にしておく。 CQS とオンラインマイグレーションの相性の話の 続きで、今回はドメインモデルがその構造のどこに居るかという話。
例は前回と同じ写真投稿サービス。photos テーブルが1つあり、
アップロード完了前の写真は表示してはいけない、という業務ルールがあるとする。
図1: リクエストの流れ
- 読み取りの道は tx なし(持つ口が実装に無い)
- readmodel/ は未完了の行は返さない
見てほしいのは1点だけ。ドメインモデルが居るのは書き込みの道だけだ。 読みの道は domain を通らず、SQL が直接応答の形を作る。
ドメインモデル側に何を置くか
書き込みの道の中心にいる Entity はこういう形をしている。
// フィールドは全部非公開。状態を変える手段は業務の語彙を持ったメソッドだけ
func (p *Photo) Publish() error {
if p.status != Ready {
return ErrUploadNotFinished // 未完了の写真は公開できない
}
...
}
- 不変条件: タイトルの長さ、許可された content-type。Value Object の
Parse コンストラクタ(
ParseTitle等)を通らないと値が作れない - 状態遷移:
pending_upload → ready → 公開。順序を飛ばす遷移は Entity がエラーを返す - 置かないもの: IO・永続化・他集約の中身・表示の都合
「未完了の写真は公開できない」のようなルールを usecase の if 文ではなく Entity のメソッドに置くと、そのルールを迂回する書き込み経路が作れなくなる。 どの usecase から呼んでも同じメソッドを通るからだ。
なぜ読みには課さないのか
読みが欲しいのは「画面に出す形」で、不変条件の検証ではない。 すでに DB に入っている行は(書き込みの道が守られている限り)不変条件を 満たしているので、読むたびに Entity へ復元して検証し直すのは仕事の重複になる。
かわりに読みの道には読みの道のルールを置く。「未完了の写真は表示しない」は
表示の都合なので、readmodel の全クエリに AND status = 'ready' として置く。
書き込み側は逆に status を問わない(削除や回収の対象には未完了の行も含まれる)。
同じテーブルでも、書きと読みで「見えるべき行」が違う——これを1つのモデルで
賄おうとすると、条件が呼び出し側の if 文に漏れていく。
図2: 型のレベルでも道が分かれている
クエリは手書きの SQL で、sqlc(SQL から型付き Go コードを生成するツール)に 食わせる。生成先が書きと読みで分かれている。
生成型は Go の internal パッケージに置く。言語規則により、
repository/internal/sqlcgen は repository/ からしか import できない。
つまり DB の行の形が usecase や handler に漏れられないことが、
規約ではなくコンパイラで保証される。
各層の責務
| 層 | 責務 | 置いてはいけないもの |
|---|---|---|
| handler | HTTP ⇄ usecase の型変換 | 業務判断・SQL |
| usecase | 手順の編成(認可 → tx → domain → 保存) | HTTP の知識・SQL |
| domain | 不変条件と状態遷移のみ | IO・永続化・他集約の中身・表示の都合 |
| repository | Entity の復元と保存 | 表示用のクエリ |
| readmodel | 表示の形を直接組む | domain の使用・トランザクション |
この表は「何をするか」より**「何をしてはいけないか」の列**のほうが重要だ。 層の境界は、責務の説明ではなく禁止事項で決まる。
補足: 他集約は ID でだけ持つ
Entity が別サービスの持ち物(例: 撮影機材)を参照したいとき、相手の Entity
ではなく ID を値として持つ(gearItemID *int64 のような形)。
集約とトランザクション境界の設計で
書いた「他集約の中身は持たない」のコード上の対応物で、DB 側の対応物は
database を跨ぐ外部キーを張らないになる。
同じ境界線が、Entity のフィールド・SQL の外部キー・トランザクションの範囲という
3つの場所に同じ形で現れる。
まとめ
- リクエストの道は書きと読みで分かれ、ドメインモデルは書きの道にだけ居る
- 書きの道のルール(不変条件・状態遷移)は Entity のメソッドに置き、 迂回できない形にする
- 読みの道のルール(表示条件)は readmodel のクエリに置き、 domain に持ち込まない
- 層の境界は責務の説明ではなく禁止事項で決まり、可能なら コンパイラ(internal パッケージ)や生成器(sqlc)に守らせる