注文設計で比較 — 非 DDD と DDD
EC の注文・注文明細・配送先・支払を、正規化中心と複数集約の分離でそれぞれ設計して比較する。

題材 — 注文の要件
カート確定後の「注文」周りは、カートよりも整合性と業務ルールが厚くなります。比較する要件:
- 注文: 会員と紐づく。ステータス(作成中/確定/発送中/完了/キャンセル)を持つ
- 注文明細: 商品 × 数量 × 単価。単価は必ず注文時点で固定(会計・返品の根拠になる)
- 配送先: 1 注文に 1 配送先(分割配送は今回は扱わない)。会員の住所帳から選ぶが、注文時点の内容を保持
- 支払: 注文 1 件に対して支払は 1 件以上(分割・再決済あり)。外部決済基盤を経由するので結果が非同期で返ってくる
- アクセス頻度: 読み取りは注文一覧・明細表示・レポートで頻繁。書き込みは注文確定時と支払イベント時
非 DDD 寄りの設計 — 4 テーブルを FK で網目に
伝統的な正規化設計では、orders・order_items・shipping_addresses・payments の 4 テーブルをすべて FK でつなぎ、注文確定時にひとつのトランザクションで全部 INSERT します。
特徴:
- FK 制約が広く張られているので、どのテーブルを単独で触っても整合性が崩れにくい
- 注文詳細画面は
orders JOIN order_items JOIN shipping_addresses JOIN paymentsの 4 テーブル JOIN で一発 - 注文確定のトランザクションは広く・長くなりがち(支払の外部通信を含めると、DB トランザクションとの境界設計が難しくなる)
- 支払の非同期更新が来るたびに
paymentsを UPDATE する。orders.statusとの整合はアプリ側で維持する
-- 非 DDD: 4 テーブルを FK で網目に
CREATE TABLE orders (
order_id BIGSERIAL PRIMARY KEY,
member_id BIGINT NOT NULL REFERENCES members(member_id),
status VARCHAR(20) NOT NULL, -- draft / placed / shipping / done / canceled
total NUMERIC(12, 2) NOT NULL,
placed_at TIMESTAMPTZ
);
CREATE TABLE order_items (
order_item_id BIGSERIAL PRIMARY KEY,
order_id BIGINT NOT NULL REFERENCES orders(order_id) ON DELETE CASCADE,
product_id BIGINT NOT NULL REFERENCES products(product_id),
quantity INT NOT NULL CHECK (quantity > 0),
unit_price NUMERIC(12, 2) NOT NULL
);
CREATE TABLE shipping_addresses (
shipping_address_id BIGSERIAL PRIMARY KEY,
order_id BIGINT NOT NULL UNIQUE REFERENCES orders(order_id) ON DELETE CASCADE,
zip VARCHAR(10) NOT NULL,
prefecture VARCHAR(20) NOT NULL,
city VARCHAR(80) NOT NULL,
street VARCHAR(200) NOT NULL,
recipient VARCHAR(100) NOT NULL
);
CREATE TABLE payments (
payment_id BIGSERIAL PRIMARY KEY,
order_id BIGINT NOT NULL REFERENCES orders(order_id) ON DELETE CASCADE,
provider VARCHAR(30) NOT NULL, -- stripe / paypal / konbini / ...
amount NUMERIC(12, 2) NOT NULL,
status VARCHAR(20) NOT NULL, -- pending / captured / refunded / failed
captured_at TIMESTAMPTZ
);DDD 寄りの設計 — 注文集約と支払集約を分ける
DDD では、「注文」と「支払」は別の整合性単位という捉え方が一般的です。理由は次のとおりです。
- 支払は外部決済基盤との非同期な相互作用で状態が変わる。注文のトランザクションに巻き込むと、DB トランザクションが外部通信待ちで長時間開きっぱなしになる
- 支払は「再決済」「返金」「分割」などライフサイクルが独立している
- 注文の業務不変条件(明細合計 = 注文合計)と、支払の業務不変条件(キャプチャ済み合計 ≤ 注文合計)は、別々に検証できるほうが筋が良い
そこで、DDD 側では以下のような集約分割になります。
- 注文集約:
orders+order_items+ 配送先情報(後述のとおり、値オブジェクトとしてordersに埋め込むことが多い) - 支払集約:
payments単独。order_idは ID 参照(FK は運用次第) - 商品集約:
products(明細はunit_priceとproduct_nameをスナップショットで持つ)
配送先を値オブジェクトとして orders に埋め込むのは、「この注文の配送先は、それ単体で更新されることがない」という判断です。住所帳(会員マスタ側)が変わっても、過去の注文の配送先は履歴として動かしてはならないので、注文確定時点でコピーして閉じ込めます。これは非 DDD 側も同じ発想で shipping_addresses テーブルを別立てしていましたが、DDD では独立テーブルにする動機がないと判断し、列として orders に並べるのが自然です。
-- DDD: 注文集約(配送先は値オブジェクトとして埋め込み)と支払集約を分離
CREATE TABLE orders (
order_id BIGSERIAL PRIMARY KEY,
member_id BIGINT NOT NULL, -- members(member_id) への ID 参照
status VARCHAR(20) NOT NULL,
total NUMERIC(12, 2) NOT NULL,
-- 配送先(値オブジェクト: orders の 1 行で閉じる)
ship_zip VARCHAR(10) NOT NULL,
ship_prefecture VARCHAR(20) NOT NULL,
ship_city VARCHAR(80) NOT NULL,
ship_street VARCHAR(200) NOT NULL,
ship_recipient VARCHAR(100) NOT NULL,
placed_at TIMESTAMPTZ
);
CREATE TABLE order_items (
order_id BIGINT NOT NULL REFERENCES orders(order_id) ON DELETE CASCADE,
line_no INT NOT NULL, -- 集約内ローカルな連番
product_id BIGINT NOT NULL, -- 商品集約への ID 参照
product_name VARCHAR(200) NOT NULL, -- スナップショット
quantity INT NOT NULL CHECK (quantity > 0),
unit_price NUMERIC(12, 2) NOT NULL, -- スナップショット
PRIMARY KEY (order_id, line_no)
);
-- 別集約: 支払
CREATE TABLE payments (
payment_id BIGSERIAL PRIMARY KEY,
order_id BIGINT NOT NULL, -- 注文集約への ID 参照(FK は運用判断)
provider VARCHAR(30) NOT NULL,
amount NUMERIC(12, 2) NOT NULL,
status VARCHAR(20) NOT NULL,
captured_at TIMESTAMPTZ
);トランザクション境界の差 — 注文確定と支払コールバック
両設計でトランザクションの切り方がどう違うかが、実運用で最も体感される差です。
| イベント | 非 DDD | DDD |
|---|---|---|
| 注文確定 | orders + order_items + shipping_addresses + payments を1 トランザクションで INSERT。支払の外部呼び出しはコミット後 or 事前に分けて整合を取る | 注文集約(orders + order_items)を 1 トランザクションで INSERT。支払集約は別トランザクションで pending 作成 + 外部呼び出し |
| 支払コールバック | payments を UPDATE。orders.status を同じトランザクションで更新する(FK で紐づいている) | payments を UPDATE(支払集約内)。orders.status は別トランザクションで更新(整合性はアプリ or イベント駆動) |
| 返金・再決済 | 既存の payments 行を更新/新規行を INSERT。orders 側にも影響するため注意 | 支払集約内で完結。注文側にはイベントで通知 |
| 障害時の状態 | orders.status と payments.status が DB レベルで揃いやすい(同一トランザクション) | 一時的に両者が食い違う時間が存在する(結果整合性) |
非 DDD は短期的な整合性の取りやすさが強み、DDD は長期的なドメイン変化への耐性(支払プロバイダの追加、外部連携の非同期化)が強みです。
読み取り — JOIN しにくさと CQRS
DDD で集約を分けると、集約をまたぐ読み取りは書き込みほど素直にいきません。注文詳細画面で「注文 + 明細 + 支払の最新状態」を一度に出したい場合、DDD でも結局 orders JOIN payments のようなクエリを書くことになります。
選択肢:
- 割り切って JOIN する — DDD の「集約は書き込み側の境界」と割り切り、読み取り用 SQL では JOIN を許可する。最もシンプル
- 読み取り専用ビュー —
CREATE VIEWや マテリアライズドビュー で集約を跨ぐ表現をまとめる - CQRS(読み取りモデルの分離) — 書き込み用のテーブル群と別に、画面表示用の非正規化済みテーブルを持つ。イベントで更新する。スケールするが運用負荷は高い
非 DDD はこの悩みが原理的に発生しません(すべてが FK で繋がっているので常に JOIN できる)。これは非 DDD の明確な強みです。業務が読み取り主体なら、ここは真剣に比較する価値があります。
マイクロサービス分割との関係
将来的にサービス分割を想定しているなら、集約境界は有力な分割線です。
- 非 DDD 設計のまま分割すると、FK がサービス境界を跨ぐことになり、参照整合性が担保できなくなる。分割のたびに「どの FK を切るか」「何を API 経由の参照に変えるか」を剥がし直す必要がある
- DDD 設計なら、集約間は最初から ID 参照で設計されているので、集約をサービスに切り出す作業は境界の物理化に近い(DB 分割・API 経由の参照に切り替えるだけ)
逆に、サービス分割の予定がまったくないモノリスなら、DDD の境界設計コストはリターンに見合わないこともあります。「マイクロサービスにするから DDD」は動機として妥当ですが、「モノリスのままだから非 DDD」も同じくらい妥当です。
