データモデル設計ガイド

カート設計で比較 — 非 DDD と DDD

EC のショッピングカートを題材に、正規化中心とカート集約中心の設計を並べて比較する。

カート設計で比較 — 非 DDD と DDD eyecatch

題材 — カートの要件

比較をフェアにするため、両設計で満たす要件をそろえます。

  • カートは会員ごとに 1 つ(未ログインの一時カートは別テーブル扱いとし、本比較では会員カートのみ)
  • カート明細は「商品 × 数量」の行。同一商品を複数回追加したら数量が加算される
  • 商品マスタは別に存在。価格・名前・在庫などを保持
  • 小計 / 合計は表示時に計算。ただし「価格改定に追随するか/カート投入時点の価格で固定するか」は設計判断
  • アクセス頻度: 読み取り(カート表示)が圧倒的に多く、書き込み(商品追加・数量変更)は散発的

非 DDD 寄りの設計 — カートと明細を対等に正規化

伝統的な正規化中心の発想では、cartscart_itemsproducts対等な 3 つのエンティティとして扱い、それぞれを FK でつなぎます。

特徴:

  • cart_items.product_idproducts.product_id へ FK。商品が削除されないかぎり、カートに壊れたリンクは生まれない
  • カート表示は cart_items JOIN products で価格と名前を取得する。常に最新の商品情報が反映される(価格改定が即時に反映)
  • cart_items に直接 SELECT / UPDATE することを禁じていない。集計バッチや運用ツールから触りやすい
sql
-- 非 DDD: 対等な 3 テーブルを FK で接続
CREATE TABLE products (
  product_id BIGSERIAL PRIMARY KEY,
  name       VARCHAR(200) NOT NULL,
  price      NUMERIC(12, 2) NOT NULL,
  stock      INT            NOT NULL,
  created_at TIMESTAMPTZ    NOT NULL DEFAULT NOW()
);

CREATE TABLE carts (
  cart_id    BIGSERIAL PRIMARY KEY,
  member_id  BIGINT      NOT NULL UNIQUE REFERENCES members(member_id),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE cart_items (
  cart_item_id BIGSERIAL PRIMARY KEY,
  cart_id      BIGINT    NOT NULL REFERENCES carts(cart_id)       ON DELETE CASCADE,
  product_id   BIGINT    NOT NULL REFERENCES products(product_id),
  quantity     INT       NOT NULL CHECK (quantity > 0),
  UNIQUE (cart_id, product_id)
);

-- カート表示: JOIN で最新の商品情報を引く
SELECT ci.quantity, p.name, p.price, ci.quantity * p.price AS line_total
FROM   cart_items ci
JOIN   products   p ON p.product_id = ci.product_id
WHERE  ci.cart_id = $1;

DDD 寄りの設計 — カート集約と商品集約を分ける

DDD では「カートに商品を入れる」という業務行為がカート集約の内部処理だと捉え、カート集約の中に cartcart_items を含めます。商品は別集約(商品マスタ集約)で、カート集約からは商品 ID で参照するだけです。

このとき cart_items商品名・単価をスナップショットとして持たせるかが論点になります。

  • 価格固定派: カートに入れた時点の価格で計算する、という業務要件なら、cart_itemsunit_price を持つ。以降 products を参照せずにカートを表示できる
  • 常に最新派: 業務的に「表示時点の最新価格で計算」の場合、unit_price は持たず、集約内の処理の中で商品マスタを参照する(リポジトリ側で別途読む)

いずれにせよ、集約をまたぐ関係をDB の FK で縛らないのが DDD 側の典型です。これは「商品が削除されてもカートは壊れない」「商品集約のマイグレーションとカート集約のマイグレーションを別リリースにできる」ための余地を残す意図です(ただし前節で触れたとおり、物理 FK を張るかは運用判断)。

sql
-- DDD: Cart 集約(cart + cart_items)と Product 集約を分離
CREATE TABLE products (
  product_id BIGSERIAL PRIMARY KEY,
  name       VARCHAR(200) NOT NULL,
  price      NUMERIC(12, 2) NOT NULL,
  stock      INT            NOT NULL,
  created_at TIMESTAMPTZ    NOT NULL DEFAULT NOW()
);

CREATE TABLE carts (
  cart_id    BIGSERIAL PRIMARY KEY,
  member_id  BIGINT      NOT NULL UNIQUE,       -- members(member_id) への ID 参照(FK は張らない運用も多い)
  total      NUMERIC(12, 2) NOT NULL DEFAULT 0, -- 集約内不変条件: sum(line_total) と一致
  updated_at TIMESTAMPTZ    NOT NULL DEFAULT NOW()
);

CREATE TABLE cart_items (
  cart_id        BIGINT NOT NULL REFERENCES carts(cart_id) ON DELETE CASCADE,
  product_id     BIGINT NOT NULL,                   -- products(product_id) への ID 参照
  product_name   VARCHAR(200)   NOT NULL,           -- スナップショット(価格固定派)
  unit_price     NUMERIC(12, 2) NOT NULL,           -- スナップショット
  quantity       INT            NOT NULL CHECK (quantity > 0),
  PRIMARY KEY (cart_id, product_id)
);

-- カート表示: カート集約だけで完結(products への JOIN 不要)
SELECT quantity, product_name, unit_price, quantity * unit_price AS line_total
FROM   cart_items
WHERE  cart_id = $1;

何が変わるか — 挙動の差を表で

場面非 DDDDDD(価格固定派)
価格改定後にカートを見る新価格で表示(意図通りのことも、驚きのことも)カート投入時の価格のまま。決済ページで確定
商品が削除されたFK で ON DELETE の挙動次第。参照不能の行を弾く制御が必要カート側は商品名も価格も保持しているので表示は壊れない。購入時にアプリが在庫チェック
カート表示の JOINcart_items JOIN products が必要カート内で完結。JOIN 不要
商品名の変更即時に全カートへ反映既存カートには波及しない(次の追加時から新名前)
ストレージ最小(商品情報は products にしかない)重複あり(スナップショット分)
バッチ/分析の書きやすさ素直に JOIN で集計できるスナップショットの意味を意識して集計する必要

太字でまとめると、非 DDD は「最新を見せる」のが素直DDD(価格固定派)は「入れた時点の状態を守る」のが素直です。業務要件がどちらに寄っているかで、使うべき形は変わります。

中間形 — よくある折衷案

実務では純粋な非 DDD でも純粋な DDD でもなく、折衷が多いです。たとえば:

  • FK は張る(データクレンジングと運用のため)が、アプリ側では「カート集約の境界越しに書き込まない」「集約外の更新を同一トランザクションに入れない」という規律は守る
  • スナップショットは持つが、表示時は products も JOIN で引き、両方を併記する(「現在価格 ¥2,800 / カート投入時 ¥3,000」)
  • カート単位の total は持たない(常に集計する)が、cart_items にはスナップショットを持つ

「DDD か非 DDD か」の二者択一で考えるより、「この情報を集約内で固定するか、外部から常に引くか」を列ごとに判断する、と捉えるほうが実務的です。