DDD 的戰術設計(Aggregate、Entity、Value Object)通常在討論 code 層如何建模,但這些概念對 DB schema 設計同樣有指引作用。
這篇是 C04 DDD 戰術設計的 DB 視角,配合那篇讀效果更好。
Aggregate Root 和 Table 關係
原則:Aggregate Root 對應一個主要的 table,Aggregate 內的 Entity 對應子 table,外部只透過 Root 的 FK 連接。
Aggregate: Order
├── Order(Root)→ orders 表
├── OrderItem(Entity)→ order_items 表(FK: order_id)
└── ShippingAddress(Value Object)→ 存在 orders 表裡(embedded)或獨立表
這意味著:沒有任何 FK 直接指向 Aggregate 內部的 Entity。其他表不能有 order_item_id 的 FK,只能有 order_id。
為什麼重要:Aggregate 的邊界是一致性邊界。如果外部表能直接 FK 到 order_items,就繞過了 Order Aggregate 的封裝,可能導致訂單項目在不經過 Order 驗證的情況下被修改。
Value Object 的兩種 Schema 選擇
Value Object 沒有獨立 identity,是不可變的描述性資料。兩種 schema 策略:
Embedded(嵌入):欄位平鋪在 Root 的 table 裡。
-- Address Value Object 嵌入 orders 表
CREATE TABLE orders (
id BIGINT PRIMARY KEY,
-- Address fields
shipping_street VARCHAR(200),
shipping_city VARCHAR(100),
shipping_country CHAR(2),
-- ...
);適合:Value Object 欄位不多、只被這個 Aggregate 使用。
Separate Table:獨立的 table,但不允許外部直接 FK。
CREATE TABLE order_addresses (
id BIGINT PRIMARY KEY,
order_id BIGINT UNIQUE REFERENCES orders(id), -- 1:1
street VARCHAR(200),
city VARCHAR(100)
);適合:Value Object 欄位多、或同一個 Aggregate 有多個同類 Value Object(billing address + shipping address)。
JSONB:PostgreSQL 裡把整個 Value Object 存成 JSON。
ALTER TABLE orders ADD COLUMN shipping_address JSONB;
-- {"street": "...", "city": "...", "country": "TW"}適合:Value Object 結構可能動態變化、或 schema 不需要對每個欄位做查詢。
Bounded Context 和 Schema 分離
一個 Bounded Context 通常對應一個獨立的 schema(或資料庫)。
OrderContext → orders schema(orders, order_items, order_events)
UserContext → users schema(users, user_profiles, sessions)
在微服務架構裡,每個服務有自己的 DB(database-per-service 模式)。不同 Context 之間不共用 table,只透過 API 或 domain event 通信。
在單體架構裡,可以用 PostgreSQL 的 schema(CREATE SCHEMA orders;)做邏輯分離,讓不同 Bounded Context 的 table 有清楚的 namespace。
為什麼不能跨 Context 共用 table:user_id 在 OrderContext 和 UserContext 意義相同,但如果 UserContext 的 users 表結構改變,OrderContext 的查詢可能也要改——這就違反了 Bounded Context 的獨立性。OrderContext 只應該存它需要的 user 資訊(可能只有 user_id 和 display_name),不應該 JOIN 到 UserContext 的 users 表。
Domain Event 的持久化
Domain Event(「訂單已建立」「付款已完成」)需要持久化,有兩種策略:
事件日誌表(Outbox Pattern):
CREATE TABLE domain_events (
id UUID PRIMARY KEY,
aggregate_type VARCHAR(100), -- 'Order'
aggregate_id BIGINT,
event_type VARCHAR(100), -- 'OrderCreated'
payload JSONB,
occurred_at TIMESTAMPTZ DEFAULT NOW(),
published_at TIMESTAMPTZ -- NULL = 尚未發布到 message broker
);在同一個事務裡同時寫 orders 和 domain_events,再用一個獨立的 publisher 把未發布的事件送到 message broker。這保證了「事件一定跟資料狀態一致」。
Event Sourcing(更進一步):aggregate 的狀態完全由事件重播決定,不存「當前狀態」的快照。這是 C04 Event Sourcing 章節的主題,這裡不展開。
DDD 的資料建模原則可以歸結為:DB schema 是領域知識的具體化,不是 code 的輔助存儲。 讓 schema 的邊界反映 Aggregate 的邊界,讓 table 的命名反映 Ubiquitous Language,讓 Bounded Context 的分離反映在 schema 的分離——這樣的 DB 設計才能在系統演進時保持可維護性。