跳到主要內容

面試反思與架構演進指南

面試反思與架構演進指南

從「資料庫驅動」到「DDD 模組化單體」:面試反思與架構演進指南

在技術面試中,有一個看似陷阱、實則極具含金量的經典問題:

「如果讓你重新設計以前做過的系統,你最想修改什麼?」

初級工程師往往害怕承認過去程式碼的不足;而資深工程師(Senior Engineer)則懂得將這個問題轉化為展現「痛點感知能力」「領域驅動設計(DDD)」「架構演進思考」的最佳舞台。

本文將拆解如何將過去典型的「資料庫驅動(DB-Driven)」反模式,重新設計為現代化、低耦合的「模組化單體(Modular Monolith)」,並提供一份可以直接應用於面試與實務重構的完整架構指南。

一、 陷阱:DB-Driven 架構的技術債

在專案初期或 MVP(最小可行性產品)階段,以資料庫 Schema 為核心進行開發是非常常見的做法:

[使用者請求] ➔ [Controller] ➔ [Service / ORM] ➔ [MySQL / PostgreSQL (強外鍵關聯 & 複雜 JOIN)]

這種架構在業務簡單時開發極快,但隨著業務規模擴張,會迅速帶來三大災難:

  1. 資料庫成為全域變數:所有業務邏輯都圍繞著 SQL 表格。修改 products 表的一個欄位,可能會意外破壞 ordersinventory 的查詢。

  2. 強外鍵(Foreign Key)導致併發瓶頸:高併發場景下,跨表外鍵會在寫入時觸發共享鎖(Shared Lock),導致連線池爆滿甚至死鎖(Deadlock)。

  3. 歷史資料被破壞:過度追求資料庫正規化(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)  │
└─────────────────────────┘        └─────────────────────┘

  1. 第一步(Package 隔離):在 Go 專案中劃分 internal/userinternal/order 等目錄。禁止跨 Package 直接調用 DB Model 或撰寫跨模組 JOIN 的 SQL。

  2. 第二步(介面抽象化):跨模組通訊必須透過 Go Interface 或 Application Service。

  3. 第三步(資料庫解耦):逐步移除跨模組的 Foreign Key,將查詢改為應用層平行組合(API Composition)或 CQRS 讀寫分離。

四、 面試實戰回答框架

當面試官問起「最想重構什麼」時,建議採用 問題(Problem)➔ 架構方案(Solution)➔ 執行策略(Execution) 的三段式回答結構:

1. 痛點切入 (Problem)

「過去專案在早期 MVP 階段採用了 DB-Driven(資料庫驅動) 架構,雖然前期開發快速,但隨著業務變複雜,資料庫變成了全域變數。跨模組的 SQL JOIN 與外鍵約束,導致任何業務邏輯變更都會『牽一髮而動全身』,測試與維護成本極高。」

2. DDD 方案 (Solution)

「如果重新設計,我會採用 模組化單體(Modular Monolith) 結合 DDD 領域驅動設計

  1. 切分領域邊界:將 User、Product、Order、Inventory 切分為獨立的 Bounded Context,業務 Entity 與資料庫持久化 Model(PO)徹底解耦。

  2. 去除跨模組外鍵:跨模組只保留純 ID 與下單當下的『業務快照』,確保歷史資料獨立性與寫入效能。

  3. 庫存流水帳化:庫存異動採用 Append-Only 流水帳設計,確保全軌跡可追溯與冪等性。」

3. 落地策略 (Execution)

「在執行層面,我會採用 絞殺者模式(Strangler Fig Pattern) 漸進式重構。先在程式碼內部透過 Package 建立邊界,禁止跨模組直接 JOIN,再逐步將高風險模組(如庫存)抽象為獨立 Service。這樣能在貼合業務運營的前提下,以最低風險完成系統演進。」

結語

從 DB-Driven 到 Domain-Driven,本質上是從「圍繞資料結構思考」轉變為「圍繞業務邊界與歷史事實思考」。

理解並能清楚表達這一演進過程,不僅能讓你在面試中展現出成熟的架構思維,更能確保未來設計出的系統具備應對業務爆發式成長的擴充彈性。

其他參考
絞殺圖模式

留言

這個網誌中的熱門文章

如何使用relive.cc 搭配Strava 製作單車軌跡動畫

先完成前置工作 首先要先有 Strava 的帳號,如果沒有請先去註冊 用你的智慧型手機下載StravaAPP  (Android版本連結)  | ( iOS 版本連結 ) 到 relive.cc 跟Strava的帳號做綁定

職涯的先後手:你是「因工作而學習」,還是「因學習而找到工作」?

職涯的先後手:你是「因工作而學習」,還是「因學習而找到工作」? 職涯的先後手:你是「因工作而學習」,還是「因學習而找到工作」? 在職場的馬拉松裡,我們無時無刻不在學習。但你是否思考過,推動你拿起書本或打開文檔的動力是什麼? 學習與工作之間存在著一種有趣的「因果糾纏」。有人是為了完成手頭的任務而不得不學,有人則是為了跨入理想的門檻而刻意修煉。這兩者路徑不同,收穫也大不相同。今天我們就來拆解這兩種學習型態的本質區別。 一、 因工作而學習:實戰驅動的「以戰養戰」 這種模式通常發生在我們已經「在線」的狀態。可能是接手了一個不熟悉的專案,或是遇到了一個令人頭痛的效能瓶頸。 1. 核心特質:極致的目標導向 這類學習的目標非常明確: 解決眼前的問題 。你不會從第一章慢慢讀到第十章,而是直接跳到技術文檔的 Troubleshooting 或 API Reference 。 2. 優勢:學習留存率最高 因為你有真實的場景可以立刻實踐。當你為了排查「為什麼 Docker 環境在雲端變慢」而翻遍底層架構時,那些知識會伴隨著解決問題後的快感,深深烙印在腦海中。 3. 挑戰:知識碎片化 這種學習方式像是在「補洞」,哪裡漏了補哪裡。雖然解決問題的速度快,但容易缺乏整體的系統感,形成「知其然,而不知其所以然」的技術斷層。 二、 因學習而找到工作:投資未來的「門票思維」 這多半出現在職涯的轉折點,例如新鮮人求職,或者是資深工程師計畫跨領域轉職。 1. 核心特質:系統性的打底 為了說服雇主,你需要展現完整的技能樹。因此,學習路徑通常是從基礎理論開始,一步步構建完整的知識體系。 2. 優勢:基礎紮實,應變力強 因為你經歷過系統性的薰陶,當新技術出現時,你更能看透其背後的邏輯(例如從底層原理理解新框架的演進),這讓你在職涯長跑中更有後勁。 3. 挑戰:學非所用的焦慮 最大的挫折感往往來自於「我學了這麼多,真的用得到嗎?」。如果缺乏實作練習,這種學習很容易變成「紙上談兵」,在面對真實世界的複雜問題時顯得力不從心。 三、 兩者的本質區別 維度 因為工作而學習 (Necessity) 因為學習而找到工作 (Preparation) 觸發點 被動問題觸發 主動目標驅動 路徑 由果溯因(從問...

[GOV]勞工保險局 讀卡機找不到

使用自然人到 勞動部勞工保險e 化服務系統 查詢勞保資料 登入一直出現錯誤訊息"讀卡機找不到" 解決方法是 到內政部行政管理中心下載"HiCOS卡片管理工具" 下載後安裝在重新開啟IE即可再次登入即可 http://moica.nat.gov.tw/download_1.html