從「資料庫驅動」到「DDD 模組化單體」:面試反思與架構演進指南
在技術面試中,有一個看似陷阱、實則極具含金量的經典問題:
「如果讓你重新設計以前做過的系統,你最想修改什麼?」
初級工程師往往害怕承認過去程式碼的不足;而資深工程師(Senior Engineer)則懂得將這個問題轉化為展現「痛點感知能力」、「領域驅動設計(DDD)」與「架構演進思考」的最佳舞台。
本文將拆解如何將過去典型的「資料庫驅動(DB-Driven)」反模式,重新設計為現代化、低耦合的「模組化單體(Modular Monolith)」,並提供一份可以直接應用於面試與實務重構的完整架構指南。
一、 陷阱:DB-Driven 架構的技術債
在專案初期或 MVP(最小可行性產品)階段,以資料庫 Schema 為核心進行開發是非常常見的做法:
[使用者請求] ➔ [Controller] ➔ [Service / ORM] ➔ [MySQL / PostgreSQL (強外鍵關聯 & 複雜 JOIN)]
這種架構在業務簡單時開發極快,但隨著業務規模擴張,會迅速帶來三大災難:
-
資料庫成為全域變數:所有業務邏輯都圍繞著 SQL 表格。修改
products表的一個欄位,可能會意外破壞orders或inventory的查詢。 -
強外鍵(Foreign Key)導致併發瓶頸:高併發場景下,跨表外鍵會在寫入時觸發共享鎖(Shared Lock),導致連線池爆滿甚至死鎖(Deadlock)。
-
歷史資料被破壞:過度追求資料庫正規化(3NF)。當商品改名或調價時,如果歷史訂單採用 SQL JOIN 查詢,會導致歷史財務報表錯亂。
二、 思維轉變:DDD 與界限上下文(Bounded Context)
要擺脫「牽一髮而動全身」的泥淖,核心在於切斷跨領域的物理耦合,導入 Bounded Context (BC) 概念。
1. 斷開跨 BC 的資料庫外鍵,改用「ID-Only」與「快照(Snapshot)」
在模組化單體中,跨領域之間不設資料庫外鍵、不用 SQL JOIN,僅傳遞純 ID 與下單當下的業務快照。
❌ 反模式:ORM 強關聯與 SQL JOIN
Go
// 業務物件直接與 DB 表綁定,並設定跨模組外鍵
type Order struct {
ID int64
UserID int64 `gorm:"foreignKey:UserID"`
User user.User // 跨 BC 實體嵌套
Product product.Product `gorm:"foreignKey:ProductID"` // 跨 BC 實體嵌套
CreatedAt time.Time
}
✅ 現代模式:ID-Only + 快照(Snapshot)
Go
// 訂單是已發生的歷史事實,獨立保存交易當下的快照
type Order struct {
ID int64
UserID int64 `db:"user_id"` // 純 ID 欄位,DB 不設 Foreign Key
ProductID int64 `db:"product_id"` // 純 ID 欄位,DB 不設 Foreign Key
// 交易當下的快照(Snapshot),防止源頭商品資料變更影響歷史紀錄
ProductName string `db:"product_name"`
Price int64 `db:"price"`
Quantity int `db:"quantity"`
Status OrderStatus
CreatedAt time.Time
}
2. 敏感資產導入「不可變流水帳(Append-Only Ledger)」
對於庫存(Inventory)或金流等敏感資產,絕不能使用 UPDATE stock SET quantity = quantity - 1 直接覆蓋。
採用流水帳模式,所有異動均為不可變(Immutable)的記錄:
Go
type InventoryLog struct {
ID int64
ProductID int64
Delta int // 異動數量:+10 (進貨), -1 (扣減)
Type StockAdjustType // SECKILL_DEDUCT, ADMIN_MANUAL, ORDER_CANCEL
ReferenceID string // 關聯單號 (如 OrderID)
OperatorID int64 // 操作者 ID (系統任務或管理員)
CreatedAt time.Time
}
當前可用庫存只是歷史流水帳累加後的「快照(Snapshot)」。即便發生併發衝突或人工誤操作,系統依然具備完全的可追溯性與對帳能力。
三、 漸進式重構策略:絞殺者模式(Strangler Fig Pattern)
優秀的工程師不會輕易喊出「系統砍掉重練」。面對龐大的 Legacy 系統,應採用漸進式重構:
Plaintext
階段一:程式碼層級隔離 (Package Isolation)
┌────────────────────────────────────────────────────────┐
│ Go/Java App │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ User BC │ │ Product BC │ │ Order BC │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
└────────────────────────────────────────────────────────┘
│ (共用 DB,但禁止跨模組 SQL JOIN)
▼
[ Database ]
階段二:核心模組服務化 (Service Extraction)
┌─────────────────────────┐ ┌─────────────────────┐
│ Monolith App │ │ Inventory Service │
│ (User / Product / Order)──(gRPC)──► (獨立 DB / Redis) │
└─────────────────────────┘ └─────────────────────┘
-
第一步(Package 隔離):在 Go 專案中劃分
internal/user、internal/order等目錄。禁止跨 Package 直接調用 DB Model 或撰寫跨模組 JOIN 的 SQL。 -
第二步(介面抽象化):跨模組通訊必須透過 Go
Interface或 Application Service。 -
第三步(資料庫解耦):逐步移除跨模組的 Foreign Key,將查詢改為應用層平行組合(API Composition)或 CQRS 讀寫分離。
四、 面試實戰回答框架
當面試官問起「最想重構什麼」時,建議採用 問題(Problem)➔ 架構方案(Solution)➔ 執行策略(Execution) 的三段式回答結構:
1. 痛點切入 (Problem)
「過去專案在早期 MVP 階段採用了 DB-Driven(資料庫驅動) 架構,雖然前期開發快速,但隨著業務變複雜,資料庫變成了全域變數。跨模組的 SQL JOIN 與外鍵約束,導致任何業務邏輯變更都會『牽一髮而動全身』,測試與維護成本極高。」
2. DDD 方案 (Solution)
「如果重新設計,我會採用 模組化單體(Modular Monolith) 結合 DDD 領域驅動設計:
切分領域邊界:將 User、Product、Order、Inventory 切分為獨立的 Bounded Context,業務 Entity 與資料庫持久化 Model(PO)徹底解耦。
去除跨模組外鍵:跨模組只保留純 ID 與下單當下的『業務快照』,確保歷史資料獨立性與寫入效能。
庫存流水帳化:庫存異動採用 Append-Only 流水帳設計,確保全軌跡可追溯與冪等性。」
3. 落地策略 (Execution)
「在執行層面,我會採用 絞殺者模式(Strangler Fig Pattern) 漸進式重構。先在程式碼內部透過 Package 建立邊界,禁止跨模組直接 JOIN,再逐步將高風險模組(如庫存)抽象為獨立 Service。這樣能在貼合業務運營的前提下,以最低風險完成系統演進。」
結語
從 DB-Driven 到 Domain-Driven,本質上是從「圍繞資料結構思考」轉變為「圍繞業務邊界與歷史事實思考」。
理解並能清楚表達這一演進過程,不僅能讓你在面試中展現出成熟的架構思維,更能確保未來設計出的系統具備應對業務爆發式成長的擴充彈性。
其他參考
絞殺圖模式
留言
張貼留言