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

題材 — 在庫の要件
在庫は注文よりさらに整合性の制約が強い領域です。比較する要件:
- 在庫数: 商品ごとに「実在庫数」と「引当中数」を持ち、引当可能数 = 実在庫数 - 引当中数 が常に 0 以上であること
- 引当 (reservation): 注文確定時に在庫を引当てる。決済成功で出荷予約、決済失敗 / キャンセルで引当解除
- 入出庫履歴: 入庫・出庫・棚卸調整・返品など、すべての在庫変動を「いつ・誰が・なぜ・何個」を残す(会計・監査要件)
- 同時注文の競合: 同じ商品に対する複数の注文が同時に走っても、引当超過が発生しないこと
- アクセス頻度: 読み取り(在庫数の確認)が頻繁、書き込み(引当・出庫)も注文ピーク時には集中する
「同時に在庫が動く」という性質上、トランザクション境界とロック戦略が設計の主役になる領域です。
非 DDD 寄りの設計 — 在庫テーブルと履歴を FK でつなぐ
伝統的な正規化設計では、products・stocks・stock_movements・reservations を FK でつなぎます。引当時には stocks.reserved を UPDATE しつつ、reservations に行を INSERT、stock_movements にも行を残します。すべて同一トランザクションで。
特徴:
- FK 制約で
reservations.product_idがproductsを参照、stock_movements.product_idも同じ。整合性は DB が担保 - 引当判定は
SELECT ... FOR UPDATEでstocksの行ロックを取り、可用数を計算してからUPDATE - 「在庫数」と「引当数」と「履歴」が常に DB レベルで一致する。短期整合性は強い
- ただし 1 トランザクションに 3 テーブルが絡むため、長時間ロックが走るとデッドロックの温床になりやすい
-- 非 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 流の典型です。具体的には:
- 在庫集約のトランザクション:
stocks.reservedを更新し、reservations行を作る。ここまでが 1 トランザクション - 同じトランザクション内でドメインイベント(StockReserved)を発行する(同一 DB なら outbox テーブルへ INSERT)
- 別トランザクションで在庫履歴集約が StockReserved を購読し、
stock_movementsに追記する
これにより、注文ピーク時のホットパスが「在庫テーブル + reservations 2 行」だけのトランザクションになり、長時間ロックの心配が減ります。履歴の整合性は結果整合性で許容します(会計部門が翌日に締めるレベルなら問題にならない)。
-- 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 に追記する同時注文の競合 — ロックと楽観ロック
在庫設計では「同じ商品に対する複数の注文が同時に走ったとき、合計が在庫を超えないこと」が最も重要な業務不変条件です。両設計でアプローチが少し違います。
| 戦略 | 非 DDD | DDD |
|---|---|---|
悲観ロック (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_movements や reservations と同じテーブル群と物理的に近い場所にあるため、ロック競合や統計情報の悪化で前者まで遅くなることがあります。
DDD では集約が分かれているため、「今の在庫」は在庫集約のテーブルだけを見ます。履歴の重さや更新頻度の影響を受けません。一方で、経理レポートで「在庫数の現在値と履歴の合計が一致するか」のような集約をまたぐ検証を行うときは、両方のテーブルを JOIN するか、CQRS の読み取りモデルを別途持つ必要があります。
何が変わるか — 挙動の差を表で
| 場面 | 非 DDD | DDD |
|---|---|---|
| 引当処理のトランザクション | 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 に近い、という良い折衷点です。マイクロサービス化の予定がなく、会計が「履歴と在庫が常に一致している」ことを強く期待する社内システムでは現実的な選択肢です。
