データモデル設計ガイド

注文設計で比較 — 非 DDD と DDD

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

注文設計で比較 — 非 DDD と DDD eyecatch

題材 — 注文の要件

カート確定後の「注文」周りは、カートよりも整合性と業務ルールが厚くなります。比較する要件:

  • 注文: 会員と紐づく。ステータス(作成中/確定/発送中/完了/キャンセル)を持つ
  • 注文明細: 商品 × 数量 × 単価。単価は必ず注文時点で固定(会計・返品の根拠になる)
  • 配送先: 1 注文に 1 配送先(分割配送は今回は扱わない)。会員の住所帳から選ぶが、注文時点の内容を保持
  • 支払: 注文 1 件に対して支払は 1 件以上(分割・再決済あり)。外部決済基盤を経由するので結果が非同期で返ってくる
  • アクセス頻度: 読み取りは注文一覧・明細表示・レポートで頻繁。書き込みは注文確定時と支払イベント時

非 DDD 寄りの設計 — 4 テーブルを FK で網目に

伝統的な正規化設計では、ordersorder_itemsshipping_addressespayments の 4 テーブルをすべて FK でつなぎ、注文確定時にひとつのトランザクションで全部 INSERT します。

特徴:

  • FK 制約が広く張られているので、どのテーブルを単独で触っても整合性が崩れにくい
  • 注文詳細画面は orders JOIN order_items JOIN shipping_addresses JOIN payments の 4 テーブル JOIN で一発
  • 注文確定のトランザクションは広く・長くなりがち(支払の外部通信を含めると、DB トランザクションとの境界設計が難しくなる)
  • 支払の非同期更新が来るたびに payments を UPDATE する。orders.status との整合はアプリ側で維持する
sql
-- 非 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_priceproduct_name をスナップショットで持つ)

配送先を値オブジェクトとして orders に埋め込むのは、「この注文の配送先は、それ単体で更新されることがない」という判断です。住所帳(会員マスタ側)が変わっても、過去の注文の配送先は履歴として動かしてはならないので、注文確定時点でコピーして閉じ込めます。これは非 DDD 側も同じ発想で shipping_addresses テーブルを別立てしていましたが、DDD では独立テーブルにする動機がないと判断し、列として orders に並べるのが自然です。

sql
-- 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
);

トランザクション境界の差 — 注文確定と支払コールバック

両設計でトランザクションの切り方がどう違うかが、実運用で最も体感される差です。

イベント非 DDDDDD
注文確定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」も同じくらい妥当です。