在幾乎所有經歷過三到五年快速迭代的後端專案中,你都能在資料庫或領域層找到一個令人聞風喪膽的「神級物件(God Object)」:
它通常叫 User、Order 或是 Account。
以一個電商系統的 User 類別為例:它最初只有簡單的 id、email 和 password_hash。但隨著業務膨脹,行銷團隊塞入了 referral_code 與 vip_points;金流團隊補上了 tax_id、credit_card_token 與 invoice_title;物流團隊掛上了 shipping_address、receiver_phone 與 door_code;風控團隊又加了 kyc_status、risk_score 與 last_login_ip。
五年後,這個 users 資料表膨脹成了擁有 78 個欄位、12 個索引的怪獸實體;對應的 User.ts 或 User.java 檔案超過 2,500 行,並在全系統數百個地方被強行引用。
技術團隊很快會嚐到苦果:任何人都不敢改動 User。
修改一個會員忘記密碼的邏輯,竟會意外導致倉儲系統列印揀貨單時發生 NullPointerException;新增一個海外免稅標記,卻讓註冊頁面的 API 回應超時。
這不是「代碼寫得不夠乾淨」的問題,而是架構師掉進了物件導向與關聯式資料庫最誘人的思維陷阱:全域統一資料模型幻覺(The Illusion of a Single Unified Data Model)。
致命幻覺:世上不存在「唯一的真實 User」
人類的大腦傾向於為現實概念建立單一實體。在直覺上,「使用者就是使用者」,理所當然應該對應系統裡唯一的 User 類別與 users 資料表。
但領域驅動設計(DDD)給出了一個截然相反的殘酷真理:在不同的業務領域中,同一個名詞關注的本質完全不同,甚至根本就是不同的生命週期。
- 認證與授權領域(Identity Context): 它根本不在乎這個人叫什麼名字、住在台北還是台南。它只在乎身分憑證(Credentials):帳號、密碼雜湊、MFA Token、被賦予的權限角色。
- 帳務與計費領域(Billing Context): 它完全不關心使用者的密碼是什麼。它唯一在乎的是付款實體(Customer):統一編號、發票寄送抬頭、綁定的信用卡 Token、稅籍合規狀態。
- 物流與履約領域(Fulfillment Context): 它連使用者的 Email 都不想知道。它只在乎收件受體(Recipient):精確收件地址、聯絡市話/手機、社區門禁管制備註與司機送貨時段。
當硬把這三種完全不同的生命週期捏進同一個物件時,系統必然爆發三大反模式:
1. 欄位 Nullable 爆炸(Nullable Explosion)
因為註冊階段不可能要求填寫統一編號與送貨門禁密碼,為了讓新用戶成功建檔,資料庫中超過 70% 的欄位被迫設定為 NULLABLE。這意味著領域模型徹底失去了「資料完整性保證」,任何業務邏輯在使用欄位前,都必須加上層層防禦性 if (user.taxId !== null)。
2. 驗證規則互相踩踏(Conflicting Invariants)
在認證領域,Email 是不可更改且唯一的強制主鍵;但在物流領域,收件通知 Email 可能隨每筆包裹臨時變更,甚至允許留空(只留手機 SMS)。當兩者綁在同一實體時,驗證邏輯必然在不同情境中打架。
3. 併發狀態機死鎖(State Machine Entanglement)
使用者的狀態到底是「Active(已驗證)」、「Delinquent(欠費催繳中)」還是「Banned(被風控凍結)」?把三個維度的狀態塞進一個 status: string,會直接導致狀態轉移圖爆炸,出現 ACTIVE_BUT_DELINQUENT_SUSPENDED 這類無法維護的怪胎枚舉。
邊界切割的 4 大工程訊號
要解開 God Object 的詛咒,架構師必須拿起 DDD 的戰略手術刀——限界上下文(Bounded Context)。
限界上下文是領域模型的語義邊界。在邊界內部,任何領域名詞(Ubiquitous Language)都有且僅有唯一的確切含義;而走出邊界,這個概念就不復存在或由另一個獨立模型接管。
那麼,何時該切開邊界?你可以透過以下 4 大客觀的工程訊號來做決策:
訊號一:語義衝突(Semantic Collision)
當同一個名詞在不同團隊口中出現分歧時,就是邊界切割的絕對訊號。 例如「訂單(Order)」:
- 在**銷售上下文(Sales)**中:訂單是客戶挑選的商品清單與預估金額,狀態只有「待付款」、「已建立」。
- 在**倉儲上下文(Warehouse)**中:訂單變成了「揀貨履約單(Pick List)」,核心是貨架位置(Aisle/Bin)、包裹尺寸與實體重量,根本不需要商品原價。
- 在**財務記帳上下文(General Ledger)**中:訂單只是一連串具有會計科目的「分錄憑證(Journal Entries)」,完全不需要知道具體買了哪款顏色的 T-shirt。
停止規則: 只要一個名詞在與不同業務關係人溝通時,需要補上一句「我指的訂單不是那個揀貨單,而是⋯⋯」,這兩個概念就絕對不能共用同一個類別與資料表。
訊號二:變更步調(Change Cadence)
檢查代碼庫的變更歷史(Git Churn)。 認證授權(Identity)是基礎設施,半年可能都不會修改一次欄位結構;但行銷促銷(Marketing)每週都要根據營運活動新增會員標籤、積點規則與抽獎資格。
讓每週高頻變更的業務代碼,與半年前通過資安滲透測試的認證實體綁在同一個資料表,結果就是行銷團隊上線一次,全站使用者的登入功能就得跟著冒險重啟一次。變更頻率脫鉤,就是物理邊界切分的明確指標。
訊號三:組織與團隊依賴(Conway’s Law)
康威定律(Conway’s Law)指出:系統的架構往往映射出組織的溝通結構。 如果「會員表」的修改,需要同時發 PR 給身分安全小組、金流結算小組與物流運營小組審查,並且經常在 GitHub 上引發跨團隊的 Git Merge Conflict,這個類別就已經違反了團隊自治原則。邊界應當由單一自治團隊全權擁有。
訊號四:交易不變量範圍(Transaction Invariant Boundary)
這是在資料庫層面最硬核的技術判準。 問自己一個問題:「當使用者修改通訊地址時,這個動作需要跟他的信用卡扣款、或是重設密碼放在同一個資料庫交易(ACID Transaction)裡原子提交嗎?」
答案顯然是否定的。如果兩者不需要強一致性,它們就不屬於同一個交易不變量邊界(Transactional Boundary)。將它們強制放在同一個 DB 交易或關聯表中,只會增加資料庫行鎖(Row Lock)的鎖定時間與死鎖(Deadlock)機率。
TypeScript 重構實踐:從 God Object 到三位一體
接下來,我們以具體的代碼重構,將充斥 70 多個欄位的龐大 User 上帝物件,用手術刀精準拆解為三個獨立的限界上下文。
重構前:災難級的 God Object
// ❌ 反模式:上帝實體(揉雜認證、金流、收件、風控)
export class User {
// 認證關注
public id: string;
public email: string;
public passwordHash: string;
public roles: string[];
// 帳務關注 (Nullable 泛濫)
public taxId?: string;
public creditCardToken?: string;
public invoiceTitle?: string;
public billingAddress?: string;
// 物流關注 (業務雜訊)
public shippingAddress?: string;
public recipientName?: string;
public recipientPhone?: string;
public gateCode?: string;
// 風控關注
public riskScore: number;
public isKycVerified: boolean;
constructor(data: any) {
// 充滿防禦性檢查與潛在的驗證衝突
this.id = data.id;
this.email = data.email;
this.passwordHash = data.passwordHash;
this.roles = data.roles || [];
// ...
}
}
重構後:三個獨立自治的限界上下文模型
我們將其解構為三個邊界內純淨的 Aggregate Root,每個上下文只管自己的事情:
1. 身分與認證上下文(Identity Context)
// ✅ 認證領域:只關心帳號憑證與防護
export type AccountId = string & { readonly __brand: unique symbol };
export class Account {
private constructor(
public readonly id: AccountId,
private email: string,
private passwordHash: string,
private roles: ReadonlySet<string>,
private isSuspended: boolean,
) {}
public static create(id: AccountId, email: string, hash: string): Account {
if (!email.includes("@")) throw new Error("Invalid email address");
return new Account(id, email, hash, new Set(["USER"]), false);
}
public verifyPassword(inputHash: string): boolean {
if (this.isSuspended) return false;
return this.passwordHash === inputHash;
}
}
2. 帳務與計費上下文(Billing Context)
// ✅ 帳務領域:只關心付款實體與稅務發票合規
export type CustomerId = string & { readonly __brand: unique symbol };
export class Customer {
private constructor(
public readonly id: CustomerId,
public readonly accountId: AccountId, // 僅透過強型別 ID 參照,解除物理強綁定
private taxId: string | null,
private defaultCardToken: string,
private invoiceAddress: string,
) {}
public static enroll(
id: CustomerId,
accountId: AccountId,
cardToken: string,
address: string,
): Customer {
if (!cardToken)
throw new Error("Payment token is strictly required for Billing");
return new Customer(id, accountId, null, cardToken, address);
}
public updateTaxIdentifier(newTaxId: string): void {
// 嚴格依照財務統編邏輯驗證(非 Nullable 亂象)
if (!/^\d{8}$/.test(newTaxId))
throw new Error("Invalid Taiwan Tax ID format");
this.taxId = newTaxId;
}
}
3. 物流與履約上下文(Fulfillment Context)
// ✅ 物流領域:只關心包裹實體交付動線
export type RecipientId = string & { readonly __brand: unique symbol };
export interface DeliveryInstruction {
address: string;
contactPhone: string;
gateCode?: string;
preferredTimeSlot: "MORNING" | "AFTERNOON" | "EVENING";
}
export class Recipient {
private constructor(
public readonly id: RecipientId,
public readonly accountId: AccountId,
private name: string,
private instruction: DeliveryInstruction,
) {}
public updateDeliveryInstruction(newInstruction: DeliveryInstruction): void {
if (!newInstruction.contactPhone.startsWith("09")) {
throw new Error(
"Mobile phone is strictly required for parcel delivery notifications",
);
}
this.instruction = newInstruction;
}
}
關聯解耦的靈魂:消除跨庫外鍵,落實「純 ID 參照」
許多工程師在嘗試拆分限界上下文時,常常在資料庫層面功虧一簣:
「雖然我寫成了
Account與Customer兩個類別,但我的billing_customers表上,仍然建了FOREIGN KEY (account_id) REFERENCES identity_accounts(id) ON DELETE CASCADE啊!」
這正是最危險的半套重構。
物理外鍵(Foreign Key)帶來的隱形毒藥
當你在資料庫層加上實體外鍵時:
- 阻斷模組獨立演進: 當身分模組進行資料庫分庫分表(Sharding)、或遷移至專用 Auth 服務(如 Supabase/Clerk)時,資料庫的物理外鍵會讓遷移成本呈指數級暴增。
- 死鎖與鎖放大: 當
identity_accounts進行行鎖更新時,子表的billing_customers可能會被資料庫引擎掛上共享鎖(S-Lock),在高併發交易下引發連環死鎖。 - 刪除級聯災難: 使用者註銷帳號,意外觸發了金流表的級聯刪除(Cascade Delete),直接導致公司的會計發票紀錄被清空,觸犯稅法。
架構解法:純 ID 參照(Identity Reference)
在 DDD 的上下文邊界之間:
- 實體只保留其他上下文實體的純 ID(例如
accountId: AccountId)。 - 資料庫層禁止跨模組的外鍵約束(No cross-boundary FK)。
- 跨模組一致性依賴領域事件(Domain Events): 當
Account註銷時,發布AccountClosedEvent;帳務模組訂閱該事件後,將對應的Customer標記為封存或去識別化,但保留合規所需的十年交易憑單。
這樣做,每個限界上下文才能真正達成內部高內聚、邊界零耦合、具備獨立資料庫擴展與演進自由度的架構終極目標。
下一步:在邊界之間築起城牆
把 God Object 切開、定義了各自的限界上下文後,現實世界的另一個殘酷挑戰隨之而來:
上下文之間不可能老死不相往來。
業務流程始終需要跨上下文協同——銷售需要通知倉儲發貨,金流需要通知發票開立,甚至需要對接外部不可控的第三方金流 API(如 Stripe 或綠界科技)。
當這些外部資料湧入時,你的核心領域模型如何不被外部髒資料「餵毒」污染?
在下一篇中,我們將深入剖析戰略設計的第三大支柱:《拒絕被上游餵毒:上下文映射(Context Mapping)與防腐層(ACL)的工程模式》,拆解 7 種上下文拓撲關係,並帶來生產級的 TypeScript 防腐層架構代碼。
