ドメインモデルは書き込みの道にだけ置く

handler・usecase・domain・repository・readmodel の5層を図で示す。要点は、リクエストの道が書きと読みで分かれていて、ドメインモデル(不変条件と状態遷移)が居るのは書き込みの道だけということ。読みは Entity を経由せず SQL が直接応答の形を作る。

サービス1個分のコード構造を図にしたら思ったより伝わったので、記事にしておく。 CQS とオンラインマイグレーションの相性の話の 続きで、今回はドメインモデルがその構造のどこに居るかという話。

例は前回と同じ写真投稿サービス。photos テーブルが1つあり、 アップロード完了前の写真は表示してはいけない、という業務ルールがあるとする。

図1: リクエストの流れ

トランザクション(txはここだけ)

書き込み

読み取り

認可: この操作をしてよいか

認可: 見えるIDの一覧を取得

HTTPリクエスト

handler/(HTTPの形 ⇄ usecaseの型の変換だけ)

usecase.Commands

usecase.Queries

domain.Photo ← ドメインモデル

不変条件(タイトル長の上限等)

状態遷移(未完了の写真は公開できない、等)

repository/(Entityの復元と保存)

readmodel/(Entityを経由せず表示の形を直接SELECT)

MySQL photos テーブル(同一)

見てほしいのは1点だけ。ドメインモデルが居るのは書き込みの道だけだ。 読みの道は domain を通らず、SQL が直接応答の形を作る。

ドメインモデル側に何を置くか

書き込みの道の中心にいる Entity はこういう形をしている。

// フィールドは全部非公開。状態を変える手段は業務の語彙を持ったメソッドだけ
func (p *Photo) Publish() error {
    if p.status != Ready {
        return ErrUploadNotFinished // 未完了の写真は公開できない
    }
    ...
}

「未完了の写真は公開できない」のようなルールを usecase の if 文ではなく Entity のメソッドに置くと、そのルールを迂回する書き込み経路が作れなくなる。 どの usecase から呼んでも同じメソッドを通るからだ。

なぜ読みには課さないのか

読みが欲しいのは「画面に出す形」で、不変条件の検証ではない。 すでに DB に入っている行は(書き込みの道が守られている限り)不変条件を 満たしているので、読むたびに Entity へ復元して検証し直すのは仕事の重複になる。

かわりに読みの道には読みの道のルールを置く。「未完了の写真は表示しない」は 表示の都合なので、readmodel の全クエリに AND status = 'ready' として置く。 書き込み側は逆に status を問わない(削除や回収の対象には未完了の行も含まれる)。 同じテーブルでも、書きと読みで「見えるべき行」が違う——これを1つのモデルで 賄おうとすると、条件が呼び出し側の if 文に漏れていく。

図2: 型のレベルでも道が分かれている

クエリは手書きの SQL で、sqlc(SQL から型付き Go コードを生成するツール)に 食わせる。生成先が書きと読みで分かれている。

入力

入力

生成

生成

db/schema.sql(マイグレーション全適用後のスキーマ)

db/queries/repository/*.sql(INSERT / UPDATE / FOR UPDATE)

db/queries/readmodel/*.sql(表示用SELECT)

repository/internal/sqlcgen

readmodel/internal/sqlcgen

生成型は Go の internal パッケージに置く。言語規則により、 repository/internal/sqlcgen は repository/ からしか import できない。 つまり DB の行の形が usecase や handler に漏れられないことが、 規約ではなくコンパイラで保証される。

各層の責務

層責務置いてはいけないもの
handlerHTTP ⇄ usecase の型変換業務判断・SQL
usecase手順の編成(認可 → tx → domain → 保存)HTTP の知識・SQL
domain不変条件と状態遷移のみIO・永続化・他集約の中身・表示の都合
repositoryEntity の復元と保存表示用のクエリ
readmodel表示の形を直接組むdomain の使用・トランザクション

この表は「何をするか」より**「何をしてはいけないか」の列**のほうが重要だ。 層の境界は、責務の説明ではなく禁止事項で決まる。

補足: 他集約は ID でだけ持つ

Entity が別サービスの持ち物(例: 撮影機材)を参照したいとき、相手の Entity ではなく ID を値として持つ(gearItemID *int64 のような形)。 集約とトランザクション境界の設計で 書いた「他集約の中身は持たない」のコード上の対応物で、DB 側の対応物は database を跨ぐ外部キーを張らないになる。 同じ境界線が、Entity のフィールド・SQL の外部キー・トランザクションの範囲という 3つの場所に同じ形で現れる。

まとめ

← Back to all posts