ドメイン駆動設計の実践
この部の 2 / 6 章 ・ 全体で 57 / 76 章 ・ 読了目安 50 分
- 集約の境界を「同時に整合が必要か」で決められる
- 1トランザクション1集約の意味を説明できる
- DDD を適用すべきかを判断できる
前章で「ドメイン = 対象にしている業務そのもの」を見ました。
ドメイン駆動設計(DDD) は、その業務の複雑さに正面から取り組むための設計手法です。 Eric Evans が 2003 年にまとめたもので、20年以上使われ続けています。
ただし、DDD は誤解されやすい手法でもあります。 「エンティティと値オブジェクトとリポジトリを作ればいい」と思われがちですが、 それは DDD の半分ですらありません。
DDD は2階建てになっている
戦略的設計 どこで区切るか、どう対応づけるか
(境界づけられたコンテキスト、コンテキストマップ、ユビキタス言語)
↑ こちらが本体
↓ こちらは実装の道具
戦術的設計 どう実装するか
(エンティティ、値オブジェクト、集約、リポジトリ、ドメインイベント)
戦術的設計だけを取り入れることを、軽量 DDD(DDD-lite)と呼びます。
EntityValueObjectRepositoryというクラスは作った- しかしチームの言葉は揃っておらず、境界も引かれていない
- 結果、ただ層が増えて記述量が3倍になっただけになる
これは実際によく起きます。DDD を導入して失敗した、という話の大半がこれです。
先に効くのは戦略のほうです。 言葉を揃え、境界を引く。 コードの形はその後で決まります。
集約 — DDD で最も重要な概念
エンティティと値オブジェクトは前章で扱いました。 戦術的設計で最も重要で、最も理解されていないのが集約(Aggregate)です。
集約とは、必ず一緒に整合していなければならないオブジェクトのまとまりであり、 1トランザクションで更新する単位です。
実例: 注文と注文明細
注文(Order) ← 集約ルート
├── 注文明細(OrderLine)×N ← 集約の内部
└── 配送先(ShippingAddress) ← 値オブジェクト
ここには、守らなければならないルール(不変条件)があります。
・注文の合計金額は、明細の合計と必ず一致する
・明細が0件の注文は存在してはいけない
・確定済みの注文の明細は変更できない
これらを常に成立させる責任を持つのが集約です。 そのために、外部からは集約ルート(Order)経由でしか触れないようにします。
type Order struct {
id OrderID
status Status
lines []OrderLine // 外部から直接触らせない(小文字始まり)
}
// 明細の追加は、必ず注文を通す
func (o *Order) AddLine(productID ProductID, qty int, unitPrice Money) error {
if o.status != StatusDraft {
return ErrOrderAlreadyConfirmed // 不変条件をここで守る
}
if qty <= 0 {
return ErrInvalidQuantity
}
o.lines = append(o.lines, OrderLine{productID, qty, unitPrice})
return nil
}
func (o *Order) Total() (Money, error) { /* 明細から常に計算する */ }// これができてしまうと、集約が壊れる
order.Lines = append(order.Lines, line) // ← 確定済みでも追加できてしまうルールを守る場所が1箇所に決まるからです。
明細を直接触れる設計だと、「確定済みなら変更不可」という判定を API・バッチ・管理画面のそれぞれに書くことになります。 そして必ずどこか1箇所が抜けます(前章のドメイン貧血症)。
集約は小さく作る
新人が最初にやる失敗は、集約を大きくしすぎることです。
悪い: 顧客(Customer)集約の中に、その顧客の全注文と全レビューを入れる
→ 1件の注文を追加するために、10年分の注文を読み込むことになる
→ 同時に別の注文が追加されると、更新が衝突する
判断の基準は「本当に同時に整合していなければならないか」です。
注文と注文明細 → 同時に整合が必要(合計金額が一致していないと困る)→ 同じ集約
顧客と注文 → 同時でなくてよい(顧客名が変わっても注文は成立する)→ 別の集約
集約をまたぐときは ID で参照する
type Order struct {
id OrderID
customerID CustomerID // ← Customer オブジェクトそのものは持たない
lines []OrderLine
}オブジェクトごと持つと、注文を読むたびに顧客も読み込まれ、 どこまでがこの集約なのかが曖昧になります。
これは DDD の中でも特に実務的な指針です。
1トランザクション = 1集約
複数の集約を同時に更新する設計は、
- ロックの範囲が広がり、同時実行性能が落ちる
- サービスを分割する時に、分散トランザクションが必要になって詰む(マイクロサービスの実務)
「注文を確定したら、在庫を減らす」のように複数にまたがる処理は、 次のドメインイベントで結果整合にします。
リポジトリ
集約を丸ごと保存し、丸ごと取り出すためのものです。
// ドメイン側が必要な形を宣言する(設計の基礎の依存の反転)
type OrderRepository interface {
FindByID(ctx context.Context, id OrderID) (*Order, error)
Save(ctx context.Context, o *Order) error
}要点は3つです。
- 集約ごとに1つ(
OrderLineRepositoryは作らない。明細は注文から取る) - 永続化の詳細を隠す(SQL も Spanner も、呼ぶ側には見えない)
- 戻ってくるのはドメインのオブジェクト(DB の行そのままではない)
ハンズオン: サービスを1本作って動かすのハンズオンで作った Store interface は、まさにこれです。
ドメインサービス
どのエンティティにも自然に属さないルールの置き場所です。
// 「メールアドレスの重複禁止」は、特定の1人の Member には属さない
// (他の全員を知らないと判定できない)
type MemberRegistrationService struct{ repo MemberRepository }
func (s *MemberRegistrationService) Register(ctx context.Context, email Email) (*Member, error) {
exists, err := s.repo.ExistsByEmail(ctx, email)
if err != nil { return nil, err }
if exists { return nil, ErrEmailAlreadyUsed }
return NewMember(email)
}「エンティティに置きにくいからサービスへ」を繰り返すと、 結局すべてのロジックがサービスに集まり、貧血症に戻ります。
まず「このルールは誰が知っているべきか」を考え、 本当に誰にも属さないものだけをドメインサービスにしてください。
ドメインイベント
「業務上、意味のある出来事が起きた」ことを表すオブジェクトです。
type OrderConfirmed struct {
OrderID OrderID
CustomerID CustomerID
Total Money
OccurredAt time.Time
}集約は自分の中の整合性だけを守り、他への波及はイベントに任せます。
[注文集約] 注文を確定する
│ OrderConfirmed イベント
├──→ [在庫] 引当を確定する
├──→ [通知] 確認メールを送る
└──→ [分析] 売上に計上する
利点は2つです。
- 注文の処理が、メール送信の失敗に巻き込まれない
- 後から購読先を足しても、注文側のコードを変更しなくてよい
代償として、結果整合(一瞬ズレる)を受け入れる必要があります。 「注文したのに、まだメールが来ない」は数秒なら許容できるはずです。 何が即時で、何が数秒遅れてよいかは、業務の判断です。
アーキテクチャ — 依存を内側に向ける
DDD と一緒に語られる構成(ヘキサゴナル、オニオン、クリーンアーキテクチャ)は、 名前も図も違いますが、主張は1つです。
依存は、外側から内側へ。ドメインは何にも依存しない。
┌──────────────────────────┐
│ インフラ(DB, HTTP, 外部API)│ ← 最も変わりやすい
│ ┌────────────────────┐ │
│ │ ユースケース(アプリ層)│ │
│ │ ┌──────────────┐ │ │
│ │ │ ドメイン │ │ │ ← 最も変わりにくい
│ │ │ (業務ルール) │ │ │
│ │ └──────────────┘ │ │
│ └────────────────────┘ │
└──────────────────────────┘
矢印は常に内向き
内側は外側を知りません。ドメイン層に import "database/sql" が出てきたら、
その時点で崩れています。
実現の手段は設計の基礎と同じで、interface を内側に置き、実装を外側に置くだけです。
handler / service / domain / infra の4層は、
規模が小さいうちはやりすぎです。
ハンズオン: サービスを1本作って動かすのハンズオンのように handler / service / store の3層から始め、
業務ルールが増えてきた段階で domain を切り出すので十分間に合います。
層の数は目的ではありません。 依存の向きが守られていることだけが本質です。
コンテキストマップ — チーム間の関係を図にする
前章で「文脈が変われば同じ言葉でも別のモデル」と書きました。 では、その境界をまたぐときに何が起きるか。
境界の向こうは、たいてい別のチームが作っています。 その関係には、いくつかの型があります。
| 関係 | 意味 |
|---|---|
| 顧客 / 供給者 | 下流の要望を、上流がある程度聞いてくれる |
| 順応者(Conformist) | 上流のモデルにそのまま従う(交渉できない相手) |
| 共有カーネル | 一部のモデルを2チームで共有する(変更に合意が要る) |
| 腐敗防止層(ACL) | 相手のモデルを、自分たちの言葉に翻訳する層を挟む |
| 公開ホストサービス | 誰でも使える形で公開する(一般的な API) |
腐敗防止層(ACL)が実務で最も効く
外部サービスやレガシーシステムを使う時、 相手の概念をそのまま自分たちのコードに持ち込まないための層です。
[自分たちのドメイン] ←→ [ACL: 翻訳する] ←→ [外部の決済サービス]
Payment(自分たちの言葉) 変換 PaymentIntent, Charge...
// 自分たちの言葉で定義する
type PaymentGateway interface {
Charge(ctx context.Context, orderID OrderID, amount Money) (PaymentID, error)
}
// 実装の中だけで、外部 SDK の概念を扱う
type StripeGateway struct{ client *stripe.Client }外部サービスの用語(PaymentIntent Charge Customer)が
業務ロジックの中まで侵入します。
その状態で決済サービスを乗り換えると、 アプリケーション全体を書き換えることになります。
設計の基礎で「外部サービスは必ず interface で切る」と書いたのは、この理由です。 ACL は、その考え方に用語の翻訳を加えたものです。
ドメイン知識を引き出す: イベントストーミング
業務を洗い出すためのワークショップ手法です。 壁に付箋を貼りながら、業務で起きる出来事を時系列に並べます。
1. オレンジの付箋に「起きた出来事」を過去形で書く
(注文された / 決済が完了した / 出荷された / 返品された)
2. 時系列に並べる
3. 出来事を引き起こす「操作」と、判断する「ルール」を足す
4. 矛盾・空白・「これってどうなるんですか」が浮かび上がる ← ここが成果
エンジニアだけでなく、業務側の人と一緒にやるのが要点です。 仕様書を読むより速く、認識の食い違いがその場で見つかります。
新人であっても、「この場合はどうなりますか」と聞いて 付箋を1枚増やすだけで貢献になります。
DDD をやるべきか
すべてのシステムに必要なわけではありません。
| 向いている | 向いていない |
|---|---|
| 業務のルールが複雑 | ほぼ CRUD(登録して一覧するだけ) |
| 長く運用される | 使い捨て・短命 |
| 業務に詳しい人と話せる | 仕様が仕様書だけで完結する |
| チームで開発する | 1人で作り切る |
| ルールが頻繁に変わる | 技術的な性能が本体(画像変換など) |
- 管理画面の CRUD に集約とリポジトリを作る — 記述量が増えるだけ
- チーム内でだけ DDD 用語を使う — 業務側と話せなくなり、本末転倒
- 本を読んで、全部の型を一度に導入しようとする — まず値オブジェクトと集約だけでよい
- 「これは DDD 的に正しくない」という議論に時間を使う — 正しさの基準は業務がうまく表現できているかであって、本への忠実さではありません
「注文を確定したら、在庫を引き当て、確認メールを送る」という処理を実装します。DDD の観点で最も適切なのは?
実務の落とし穴まとめ
- 戦術だけ真似る(軽量 DDD) — 言葉と境界を先に揃える
- 集約が大きすぎる — 読み込みが重くなり、更新が衝突する
- 集約の内部を外から直接触る — ルールを守る場所が散る
- 1トランザクションで複数の集約を更新 — 後で分散トランザクションに詰む
- 集約をまたいでオブジェクトを直接持つ — 境界が曖昧になる。ID で参照する
- ドメインサービスが肥大化 — 結局ロジックが1箇所に集まる
- ドメイン層が DB やフレームワークに依存 — 依存は内側へ
- 外部サービスの用語がドメインに侵入 — 腐敗防止層で翻訳する
- CRUD に DDD を適用 — 記述量が増えるだけ
- DDD 用語で業務側と話す — ユビキタス言語の目的に反する
まとめ
- DDD は戦略(境界と言葉)と戦術(実装の型)の2階建て。効くのは戦略のほう
- 集約 = 常に整合していなければならない単位 = 1トランザクションの単位。 外からはルート経由でしか触らせない
- 集約は小さく作り、またぐときは ID で参照する
- リポジトリは集約ごとに1つ。永続化を隠し、ドメインのオブジェクトを返す
- 集約をまたぐ処理はドメインイベントで結果整合にする
- ヘキサゴナル / オニオン / クリーンの主張は同じ——依存は内側へ
- 境界をまたぐ相手とは腐敗防止層(ACL)で翻訳する。 これが無いと外部サービスを乗り換えられなくなる
- 業務が複雑で長く続くものにだけ使う。CRUD に持ち込まない
公式ドキュメント
迷ったら一次情報に戻ってください。
| 対象 | リンク |
|---|---|
| DDD Reference(Evans・無料 PDF) | https://www.domainlanguage.com/ddd/reference/ |
| Martin Fowler: DDD_Aggregate | https://martinfowler.com/bliki/DDD_Aggregate.html |
| microservices.io: Saga パターン | https://microservices.io/patterns/data/saga.html |
章末問題
外部の決済サービスの SDK が返す `PaymentIntent` という型を、業務ロジックの中でそのまま使っています。何を指摘しますか。
社内の管理画面(マスタデータを登録・一覧するだけ)に、集約・リポジトリ・ドメインサービスを一式導入しようとしています。どう判断しますか。
次は、この設計をコードのレベルに落とす話に戻ります。