データモデル設計ガイド

在庫設計で比較 — 非 DDD と DDD

EC の在庫数・引当・入出庫履歴を、正規化中心と在庫集約・履歴集約の分離でそれぞれ設計して比較する。

在庫設計で比較 — 非 DDD と DDD eyecatch

題材 — 在庫の要件

在庫は注文よりさらに整合性の制約が強い領域です。比較する要件:

  • 在庫数: 商品ごとに「実在庫数」と「引当中数」を持ち、引当可能数 = 実在庫数 - 引当中数 が常に 0 以上であること
  • 引当 (reservation): 注文確定時に在庫を引当てる。決済成功で出荷予約、決済失敗 / キャンセルで引当解除
  • 入出庫履歴: 入庫・出庫・棚卸調整・返品など、すべての在庫変動を「いつ・誰が・なぜ・何個」を残す(会計・監査要件)
  • 同時注文の競合: 同じ商品に対する複数の注文が同時に走っても、引当超過が発生しないこと
  • アクセス頻度: 読み取り(在庫数の確認)が頻繁、書き込み(引当・出庫)も注文ピーク時には集中する

「同時に在庫が動く」という性質上、トランザクション境界とロック戦略が設計の主役になる領域です。

非 DDD 寄りの設計 — 在庫テーブルと履歴を FK でつなぐ

伝統的な正規化設計では、productsstocksstock_movementsreservations を FK でつなぎます。引当時には stocks.reservedUPDATE しつつ、reservations に行を INSERT、stock_movements にも行を残します。すべて同一トランザクションで。

特徴:

  • FK 制約で reservations.product_idproducts を参照、stock_movements.product_id も同じ。整合性は DB が担保
  • 引当判定は SELECT ... FOR UPDATEstocks の行ロックを取り、可用数を計算してから UPDATE
  • 「在庫数」と「引当数」と「履歴」が常に DB レベルで一致する。短期整合性は強い
  • ただし 1 トランザクションに 3 テーブルが絡むため、長時間ロックが走るとデッドロックの温床になりやすい
sql
-- 非 DDD: 4 テーブルを FK で接続
CREATE TABLE products (
  product_id BIGSERIAL PRIMARY KEY,
  name       VARCHAR(200) NOT NULL
);

CREATE TABLE stocks (
  product_id BIGINT PRIMARY KEY REFERENCES products(product_id),
  on_hand    INT    NOT NULL CHECK (on_hand >= 0),
  reserved   INT    NOT NULL CHECK (reserved >= 0),
  CHECK (reserved <= on_hand),                  -- 不変条件: 引当 <= 実在庫
  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE reservations (
  reservation_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),
  status         VARCHAR(20) NOT NULL,           -- held / shipped / released
  reserved_at    TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE stock_movements (
  movement_id BIGSERIAL PRIMARY KEY,
  product_id  BIGINT      NOT NULL REFERENCES products(product_id),
  delta       INT         NOT NULL,              -- + 入庫 / - 出庫
  reason      VARCHAR(30) NOT NULL,              -- inbound / outbound / adjust / return
  ref_id      BIGINT,                            -- reservations / orders / adjustments への参照
  occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- 引当処理: 1 トランザクションで 3 テーブルを更新
BEGIN;
SELECT on_hand, reserved FROM stocks WHERE product_id = $1 FOR UPDATE;
-- アプリ側で残数チェック
UPDATE stocks SET reserved = reserved + $2 WHERE product_id = $1;
INSERT INTO reservations (order_id, product_id, quantity, status) VALUES ($3, $1, $2, 'held');
INSERT INTO stock_movements (product_id, delta, reason, ref_id) VALUES ($1, 0, 'reserve', currval('reservations_reservation_id_seq'));
COMMIT;

DDD 寄りの設計 — 在庫集約と履歴集約を分ける

DDD では、「現在の在庫数」と「在庫の履歴」は別の集約として分けるのが典型です。

  • 在庫集約 (Stock Aggregate): stocks + reservations。「実在庫 - 引当 ≥ 0」「ある reservation の数量は held → shipped → released のライフサイクルでしか変化しない」など在庫まわりの不変条件をすべてこの集約内で守る
  • 在庫履歴集約 (Inventory Ledger Aggregate): stock_movements 単独。会計・監査用の append-only な追記型エンティティ。集約内では「過去の行は不変」「delta の合計と現在の在庫数が一致する」が不変条件
  • 商品集約 (Product Aggregate): products。在庫集約からは product_id で ID 参照のみ

引当処理(注文確定時に在庫を確保する)は在庫集約のトランザクション内で完結させ、履歴は非同期で書くのが DDD 流の典型です。具体的には:

  1. 在庫集約のトランザクション: stocks.reserved を更新し、reservations 行を作る。ここまでが 1 トランザクション
  2. 同じトランザクション内でドメインイベント(StockReserved)を発行する(同一 DB なら outbox テーブルへ INSERT)
  3. 別トランザクションで在庫履歴集約が StockReserved を購読し、stock_movements に追記する

これにより、注文ピーク時のホットパスが「在庫テーブル + reservations 2 行」だけのトランザクションになり、長時間ロックの心配が減ります。履歴の整合性は結果整合性で許容します(会計部門が翌日に締めるレベルなら問題にならない)。

sql
-- DDD: 在庫集約 (stocks + reservations) と履歴集約 (stock_movements) を分離
CREATE TABLE stocks (
  product_id BIGINT PRIMARY KEY,                 -- products(product_id) への ID 参照
  on_hand    INT    NOT NULL CHECK (on_hand >= 0),
  reserved   INT    NOT NULL CHECK (reserved >= 0),
  CHECK (reserved <= on_hand),                   -- 在庫集約の不変条件
  version    INT NOT NULL DEFAULT 0,             -- 楽観ロック用
  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE reservations (
  reservation_id BIGSERIAL PRIMARY KEY,
  product_id     BIGINT      NOT NULL,           -- 在庫集約内の ID
  order_id       BIGINT      NOT NULL,           -- 注文集約への ID 参照
  quantity       INT         NOT NULL CHECK (quantity > 0),
  status         VARCHAR(20) NOT NULL,
  reserved_at    TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- ドメインイベント outbox(書き込みトランザクションに含める)
CREATE TABLE outbox_events (
  event_id    BIGSERIAL PRIMARY KEY,
  aggregate   VARCHAR(40) NOT NULL,              -- 'Stock' / 'Order' / ...
  event_type  VARCHAR(60) NOT NULL,              -- 'StockReserved' / 'StockReleased' / ...
  payload     JSONB       NOT NULL,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  published_at TIMESTAMPTZ
);

-- 別集約: 在庫履歴 (append-only)
CREATE TABLE stock_movements (
  movement_id BIGSERIAL PRIMARY KEY,
  product_id  BIGINT      NOT NULL,              -- 在庫集約への ID 参照
  delta       INT         NOT NULL,
  reason      VARCHAR(30) NOT NULL,
  ref_event   BIGINT,                            -- outbox_events への ID 参照
  occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- 引当: 在庫集約のトランザクション(短い)
BEGIN;
SELECT on_hand, reserved, version FROM stocks WHERE product_id = $1 FOR UPDATE;
UPDATE stocks
SET reserved = reserved + $2,
    version  = version + 1
WHERE product_id = $1 AND version = $3;
INSERT INTO reservations (product_id, order_id, quantity, status) VALUES ($1, $4, $2, 'held');
INSERT INTO outbox_events (aggregate, event_type, payload)
VALUES ('Stock', 'StockReserved',
        jsonb_build_object('product_id', $1, 'quantity', $2, 'order_id', $4));
COMMIT;
-- 履歴集約は別ジョブが outbox_events を購読して stock_movements に追記する

同時注文の競合 — ロックと楽観ロック

在庫設計では「同じ商品に対する複数の注文が同時に走ったとき、合計が在庫を超えないこと」が最も重要な業務不変条件です。両設計でアプローチが少し違います。

戦略非 DDDDDD
悲観ロック (SELECT ... FOR UPDATE)素直にこれを使う。stocks 行をロック → 残数チェック → UPDATE。FK 越しに reservations / stock_movements も同一トランザクション内で書くため、ロック保持時間が長くなりがち在庫集約だけが対象でロック保持時間が短い。履歴は同一トランザクションに含まれない
楽観ロック (version 列)採用しても可能だが、複数テーブル同時更新と相性が悪い集約境界がはっきりしているので楽観ロックと相性が良いUPDATE ... WHERE version = ? が失敗したら再試行
デッドロック頻度3 テーブル同時 UPDATE で発生しやすい2 テーブルかつ短時間で発生しにくい
在庫超過の防ぎ方FK + CHECK 制約 + 行ロック集約内 CHECK 制約 + 楽観 or 悲観ロック

注文ピーク時に同時引当が殺到するシステムでは、DDD 的に集約を切ったほうがロック保持時間が短くなり、結果的にスループットが伸びるのが大きな実益です。

履歴の整合性 — 結果整合性と二重記帳の罠

履歴を別集約にすると、「在庫が動いたのに履歴が書かれていない」「履歴が書かれたのに在庫が動いていない」という二重記帳の罠を避ける設計が必要です。素直にやろうとすると次のようなコードになりがちですが、これは事故の温床です。

BEGIN;
  UPDATE stocks ... ;
  INSERT INTO reservations ... ;
COMMIT;

// その後で...
INSERT INTO stock_movements ... ;  // ← ここで失敗したら?

2 回目の INSERT でアプリがクラッシュしたり、別 DB だったり、外部メッセージブローカへの送信に失敗したりすると、在庫と履歴が永久にずれます。

解決策が Transactional Outbox パターンです。在庫集約のトランザクションの中で outbox_events テーブルにイベントを INSERT し、別ジョブがそれを読んで履歴集約を更新します。「在庫の更新」と「イベント記録」が同じトランザクションに収まるので、片方だけ書かれることがありません。後段のジョブがリトライ可能になっていれば、結果整合性は保証できます。

非 DDD では FK と同一トランザクションで素直に解決できる問題が、DDD だと outbox という追加の仕組みを要求する。これはDDD 側のコストとして明確に認識しておくべき点です。

読み取り — 在庫照会と履歴照会の違い

読み取りパターンは大きく 2 つに分かれます。

  • 「今の在庫を見る」 — 商品ページ・カート・注文確定画面。レイテンシ厳守で、頻度も最も高い
  • 「在庫の動きを見る」 — 経理・棚卸・分析。日次集計やレポートで、レイテンシ要件は緩い

非 DDD では両方とも同じテーブル群から JOIN で取れるため、クエリの書き分けが不要です。ただし、頻度の高い「今の在庫を見る」が stock_movementsreservations と同じテーブル群と物理的に近い場所にあるため、ロック競合や統計情報の悪化で前者まで遅くなることがあります。

DDD では集約が分かれているため、「今の在庫」は在庫集約のテーブルだけを見ます。履歴の重さや更新頻度の影響を受けません。一方で、経理レポートで「在庫数の現在値と履歴の合計が一致するか」のような集約をまたぐ検証を行うときは、両方のテーブルを JOIN するか、CQRS の読み取りモデルを別途持つ必要があります。

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

場面非 DDDDDD
引当処理のトランザクション3 テーブル(stocks + reservations + stock_movements)を 1 トランザクションで更新2 テーブル(stocks + reservations)+ outbox。履歴は別トランザクション
ピーク時のロック保持時間長い(履歴 INSERT を含む)短い(履歴を含まない)
在庫数と履歴の整合性常に一致(同一トランザクション)結果整合性(一時的にズレる時間あり)
同時引当の競合制御悲観ロック中心楽観ロックも採用しやすい
会計レポート(履歴の重い集計)同じ DB に履歴が混在しているため、本番系の負荷に影響しやすい履歴集約を別 DB / 別レプリカに置きやすい
実装コスト低(FK と CHECK 制約だけで済む)中〜高(outbox + リトライ可能な購読ジョブが必要)
マイクロサービス化FK 越しの分割が困難在庫サービスと履歴サービスに分けやすい

太字でまとめると、非 DDD は実装が単純で短期整合性が強いDDD はピーク性能と将来分割に強い代わりに outbox の運用コストが乗る。在庫の規模・ピーク負荷・会計部門の要件によって、どちらに倒すかが決まります。

中間形 — 履歴をトリガで書く

「在庫と履歴を分けたいが outbox の運用は重い」という現場でよく取られる折衷が、DB トリガで stock_movements を自動追記する方式です。

  • stocks / reservations の更新が走るたびに、トリガで stock_movements に行を INSERT する
  • 同一トランザクション内なので整合性は短期的にも保証される
  • アプリは履歴の存在を意識しなくてよい
  • ただしトリガの可視性が低く、「なぜこの行が増えたのか」の追跡がデバッグ時に困難になる弱点はある

これは集約境界としては DDD 的だが、実装としては非 DDD に近い、という良い折衷点です。マイクロサービス化の予定がなく、会計が「履歴と在庫が常に一致している」ことを強く期待する社内システムでは現実的な選択肢です。