データモデル設計ガイド
カート設計で比較 — 非 DDD と DDD
EC のショッピングカートを題材に、正規化中心とカート集約中心の設計を並べて比較する。

題材 — カートの要件
比較をフェアにするため、両設計で満たす要件をそろえます。
- カートは会員ごとに 1 つ(未ログインの一時カートは別テーブル扱いとし、本比較では会員カートのみ)
- カート明細は「商品 × 数量」の行。同一商品を複数回追加したら数量が加算される
- 商品マスタは別に存在。価格・名前・在庫などを保持
- 小計 / 合計は表示時に計算。ただし「価格改定に追随するか/カート投入時点の価格で固定するか」は設計判断
- アクセス頻度: 読み取り(カート表示)が圧倒的に多く、書き込み(商品追加・数量変更)は散発的
非 DDD 寄りの設計 — カートと明細を対等に正規化
伝統的な正規化中心の発想では、carts・cart_items・products を対等な 3 つのエンティティとして扱い、それぞれを FK でつなぎます。
特徴:
cart_items.product_idはproducts.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 では「カートに商品を入れる」という業務行為がカート集約の内部処理だと捉え、カート集約の中に cart と cart_items を含めます。商品は別集約(商品マスタ集約)で、カート集約からは商品 ID で参照するだけです。
このとき cart_items に商品名・単価をスナップショットとして持たせるかが論点になります。
- 価格固定派: カートに入れた時点の価格で計算する、という業務要件なら、
cart_itemsにunit_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;何が変わるか — 挙動の差を表で
| 場面 | 非 DDD | DDD(価格固定派) |
|---|---|---|
| 価格改定後にカートを見る | 新価格で表示(意図通りのことも、驚きのことも) | カート投入時の価格のまま。決済ページで確定 |
| 商品が削除された | FK で ON DELETE の挙動次第。参照不能の行を弾く制御が必要 | カート側は商品名も価格も保持しているので表示は壊れない。購入時にアプリが在庫チェック |
| カート表示の JOIN | cart_items JOIN products が必要 | カート内で完結。JOIN 不要 |
| 商品名の変更 | 即時に全カートへ反映 | 既存カートには波及しない(次の追加時から新名前) |
| ストレージ | 最小(商品情報は products にしかない) | 重複あり(スナップショット分) |
| バッチ/分析の書きやすさ | 素直に JOIN で集計できる | スナップショットの意味を意識して集計する必要 |
太字でまとめると、非 DDD は「最新を見せる」のが素直、DDD(価格固定派)は「入れた時点の状態を守る」のが素直です。業務要件がどちらに寄っているかで、使うべき形は変わります。
中間形 — よくある折衷案
実務では純粋な非 DDD でも純粋な DDD でもなく、折衷が多いです。たとえば:
- FK は張る(データクレンジングと運用のため)が、アプリ側では「カート集約の境界越しに書き込まない」「集約外の更新を同一トランザクションに入れない」という規律は守る
- スナップショットは持つが、表示時は
productsも JOIN で引き、両方を併記する(「現在価格 ¥2,800 / カート投入時 ¥3,000」) - カート単位の
totalは持たない(常に集計する)が、cart_itemsにはスナップショットを持つ
「DDD か非 DDD か」の二者択一で考えるより、「この情報を集約内で固定するか、外部から常に引くか」を列ごとに判断する、と捉えるほうが実務的です。
