很多軟體專案一開始都很順。
功能不多,規則也簡單。
「客戶」就是一個 Customer。
「訂單」就是一張 orders 資料表。
「會員」就是 users 裡多一個欄位。
但業務一成長,問題開始出現。
銷售說的「客戶」是有購買意願的人。
財務說的「客戶」是付款與開立發票的主體。
客服說的「客戶」是需要服務紀錄的人。
課程平台說的「客戶」可能其實是有存取資格的學員。
如果程式碼硬把這些需求都塞進同一個 Customer Model,最後很容易變成一個什麼都有、誰都不敢改的大物件。
這正是 DDD 想處理的問題。
DDD 要先理解業務,再談架構
Martin Fowler 對 DDD 的整理很清楚:它特別適合複雜領域,重點是先建立能表達業務規則的模型,再決定技術怎麼配合。
換句話說,開發團隊不能只問:
資料表怎麼設計?
API 要幾支?
要不要拆微服務?
還要問:
業務裡到底有哪些概念?
這些概念的邊界在哪裡?
哪些規則一定要同時成立?
同一個詞在不同情境裡,意思是不是一樣?
如果這些問題沒有被說清楚,技術架構愈複雜,錯的模型只會被放大。
第一個核心:共同語言,讓業務和程式碼講同一套話
共同語言(Ubiquitous Language)是 DDD 最重要的起點之一。
它不是一份寫完就放著的術語表。
實際做法,是讓領域專家、產品、設計與開發在討論時,盡量使用同一套精確詞彙;而且這些詞也直接出現在程式碼、測試與文件裡。
例如業務真正的動作叫「啟用課程資格」,程式碼就不應該只寫一個模糊的 processUser()。
如果商業規則叫「取消續訂」,方法名稱也應該清楚反映這個動作。
名稱如果一直很難取,通常代表團隊對業務概念還沒有想清楚。
語言會變,模型也要跟著變
共同語言不是一開始定好就永遠不能改。
隨著團隊更了解業務,原本模糊的詞可能需要被拆開。
例如一開始大家都說「訂單完成」。
後來才發現其實有幾個不同狀態:
訂單已建立。
付款已確認。
發票已開立。
服務資格已開通。
這些事情不是同一件事。
如果程式碼裡只剩一個 completed=true,未來每加一條規則都會變得更危險。
把語言拆清楚,模型才有機會跟著變清楚。
第二個核心:限界上下文,接受同一個詞可以有不同意思
限界上下文(Bounded Context)是 DDD 的策略設計核心。
它承認一件很現實的事:大型業務不一定能用一套模型描述全部事情。
「客戶」在銷售、帳務、客服與學習系統裡,可能真的就是不同模型。
這不是設計失敗。
問題反而常出在團隊硬要維持一個「全公司唯一 Customer」,最後所有需求互相污染。
限界上下文會替模型畫出明確範圍。
在銷售情境裡,客戶可以關心來源、商機階段與跟進紀錄。
在帳務情境裡,客戶更關心付款主體、統編與發票資料。
在課程交付情境裡,學員則關心存取資格、進度與課程紀錄。
它們可以代表同一個現實中的人,卻不必共享同一個巨大模型。
限界上下文不等於微服務
這一點非常容易被誤解。
限界上下文是語言與模型的邊界。
微服務是部署與執行的邊界。
兩者可以對齊,但沒有規定一個限界上下文一定要立刻拆成一個獨立服務。
對剛開始的產品來說,先在模組化單體裡維持清楚邊界,通常比一開始就建立一堆網路服務更簡單。
如果之後真的出現獨立部署、局部擴充或團隊分工需求,再考慮把某個上下文拆出去。
如果你正在思考這類架構取捨,可以延伸閱讀 新系統架構怎麼設計?先做難以逆轉的決策,其他先不要急。
第三個核心:實體和值物件要分清楚
DDD 會把領域物件區分成幾種不同角色。
實體(Entity)的重點是身分。
即使它的屬性改變,只要身分沒有變,它仍然是同一個東西。
例如同一筆訂單的收件地址、備註或狀態可以改,但 Order ID 不變,它仍是同一筆訂單。
值物件(Value Object)則相反。
它沒有需要長期追蹤的獨立身分,價值就在它的屬性本身。
例如金額、地址、日期區間、Email Address,都很適合被建模成值物件。
兩個 100 元如果幣別和數值都一樣,通常不需要問「這是不是同一個 100 元」。
值物件的好處,是把規則一起包進去
很多系統會把 Email、Money、Address 全部當成普通字串。
結果驗證邏輯散在各處。
某個頁面檢查 Email 格式,另一個 API 忘了檢查。
某段程式把台幣和美金直接相加。
值物件可以把這些規則集中起來。
例如 Money 可以要求金額與幣別永遠同時存在。
EmailAddress 可以保證建立成功的物件一定已通過基本格式驗證。
這會讓很多不合理狀態直接變得難以產生。
第四個核心:聚合是一致性的邊界
聚合(Aggregate)是一組需要一起維持商業規則的物件,而且會有一個聚合根(Aggregate Root)作為外部操作入口。
最常見的例子是訂單。
Order 可以是聚合根,裡面包含多個 Order Line。
外部程式不應該繞過 Order,直接任意修改某一個明細,因為訂單可能有總額、折扣、庫存與狀態規則需要一起檢查。
聚合的價值不在於把更多東西包在一起,而是畫出一條一致性邊界:邊界內的規則要在同一次業務操作中保持有效。
聚合不是越大越好
新手很容易把所有有關聯的東西都塞進同一個聚合。
訂單裡放付款、會員、發票、配送、客服紀錄。
這樣看起來很完整,實際上會讓交易範圍、載入成本與併發衝突一起變大。
比較穩健的方向,是讓聚合保持小而明確。
跨聚合的流程可以用 ID 關聯,必要時再透過領域事件協調。
這樣每個聚合只對自己需要立即成立的規則負責。
第五個核心:領域事件把發生過的業務事實說清楚
領域事件(Domain Event)代表領域裡已經發生,而且其他部分可能會在意的事情。
命名通常使用過去式。
例如:
OrderPlaced:訂單已建立。
PaymentConfirmed:付款已確認。
SubscriptionCancelled:訂閱已取消。
CourseAccessGranted:課程資格已開通。
這些事件比「資料表某一欄從 0 改成 1」更接近業務真正發生的事情。
當模型能說出這些事件,系統行為也會變得更容易理解。
領域事件可以減少隱藏的連鎖規則
假設付款確認後,需要:
開通會員資格。
寄付款成功通知。
建立發票資料。
更新 CRM 標籤。
如果這些動作全部硬寫在付款服務裡,付款模組就會知道太多事情。
改成「付款已確認」這個領域事件後,其他規則可以各自在適當位置反應。
這樣做的目的,是把「因為什麼業務事實,所以發生什麼後續」說得更清楚。
領域事件和整合事件不完全一樣
領域事件主要描述同一個領域模型裡的重要事實。
如果事件要跨到其他限界上下文、其他服務,通常會轉成整合事件(Integration Event)。
例如「付款已確認」是付款上下文裡的領域事件。
跨到課程交付上下文時,可以送出一個適合對外的事件,通知對方更新存取資格。
這中間可以有轉換層,避免付款系統內部的資料結構直接滲進其他模型。
第六個核心:情境對應讓不同上下文知道怎麼合作
情境對應(Context Mapping)處理的是不同限界上下文之間的關係。
不是畫一張箭頭圖就結束。
更值得問的是:
誰依賴誰?
誰控制介面?
資料要不要轉換?
兩邊可不可以共享一小部分模型?
外部系統的格式如果改變,會不會污染自己的核心模型?
這些關係如果沒有說清楚,邊界很快又會被破壞。
防腐層:不要讓外部系統定義你的業務語言
防腐層(Anti-Corruption Layer)是一個很實用的 DDD 模式。
它的工作是翻譯外部模型。
例如某家金流 API 回傳 payment_status=2。
你的核心業務不需要到處記住「2 代表成功」。
邊界層可以先把它轉成 PaymentConfirmed,再交給內部模型。
未來換金流時,核心規則就不需要跟著外部欄位一起改。
這和 Clean Architecture 的依賴方向很接近:外部系統可以變,核心業務語言應該保持穩定。
不是每個系統都需要一整套 DDD
如果只是簡單 CRUD、規則很少、流程也穩定,套完整 DDD 可能只會增加負擔。
DDD 最有價值的地方,是複雜業務。
當規則很多、同一詞彙有多重意思、不同團隊常常互相誤解,或需求變更會牽動很多模組時,DDD 才比較容易展現價值。
所以不要為了用了 Entity、Repository 就說自己在做 DDD。
關鍵仍然是:模型有沒有幫助團隊更準確地理解業務。
在課程網站裡,DDD 可以怎麼看?
假設一個課程網站同時有銷售、付款、會員與學習紀錄。
可以先把幾個上下文分開思考。
銷售上下文關心方案、成交狀態與優惠。
付款上下文關心交易、退款與發票。
會員上下文關心帳號與登入。
課程上下文關心存取資格、章節與進度。
這些模型之間可以交換資訊,但不必共享一個萬能的「會員物件」。
當付款成功時,付款上下文送出清楚事件;課程上下文收到後,再依自己的規則決定要開通哪些內容。
這樣每一邊的業務語言都比較乾淨。
DDD 也會影響團隊怎麼溝通
限界上下文不只是程式碼邊界。
Martin Fowler 在談 Bounded Context 時也指出,模型與共同語言和團隊文化密切相關。
如果同一個團隊同時維護完全不同的業務語言,又沒有清楚邊界,溝通成本很容易上升。
反過來,如果每個上下文有清楚責任、語言與整合方式,團隊就比較容易知道哪些變更需要和誰協調。
這也是 DDD 會被用來協助思考服務邊界與組織邊界的原因。
常見的 DDD 失敗,往往從沒有和業務一起建模開始
最常見的失敗方式之一,是工程團隊自己在會議室裡設計一套很漂亮的模型,卻很少和真正懂業務的人討論。
結果類別名稱看起來很專業,實際上和業務用語完全不同。
第二種失敗,是所有邏輯都放在 Service,Entity 只剩欄位與 getter/setter。
這會形成貧血領域模型(Anemic Domain Model)。
規則散在不同服務裡,久了還是會重複、衝突。
第三種失敗,是把 Bounded Context 當成「切微服務的公式」。
如果模型還沒弄清楚,先拆服務只會把錯誤邊界變成網路邊界。
可以先用這七題檢查自己的領域模型
一、領域專家看得懂主要類別與方法名稱嗎?
二、同一個詞在不同業務區域裡,有沒有不同意思?
三、限界上下文有沒有明確邊界?
四、實體和值物件有沒有被分清楚?
五、聚合是否小而清楚,而且只有聚合根負責維持主要規則?
六、重要業務事實有沒有被表達成領域事件?
七、不同上下文整合時,有沒有明確的轉換與責任邊界?
如果這些問題答不清楚,系統很可能只是把資料表包成物件,還沒有真正形成領域模型。
DDD 的價值,是讓系統跟得上業務變化
軟體複雜度很多時候來自業務本身的規則、例外與不同觀點,不只是程式碼行數。
DDD 不會讓複雜業務突然變簡單。
它做的,是把複雜度放到有名字、有邊界、能討論的位置。
共同語言讓人和程式碼使用同一套詞。
限界上下文讓不同模型可以各自保持一致。
實體、值物件與聚合把規則放回正確地方。
領域事件讓重要業務事實變得明確。
情境對應則讓不同模型知道怎麼合作。
當程式碼開始愈來愈不像真實業務時,答案通常不是再加一層 Helper 或 Service。
先回頭問:我們到底有沒有把這個業務理解對?
常見問題
DDD 一定要搭配微服務嗎?
不用。限界上下文是模型與語言的邊界,不是部署單位。很多團隊可以先在模組化單體裡實作清楚邊界,等獨立部署、擴充或團隊需求真的出現後,再考慮拆服務。
實體和值物件最簡單的判斷方式是什麼?
如果物件的身分會跨時間延續,即使屬性改變仍然是同一個東西,通常比較像實體;如果只需要看屬性值,不需要追蹤獨立身分,通常比較適合做成值物件。
聚合是不是越大越完整?
不是。聚合應該只包含需要在同一次業務操作中維持一致的最小範圍。太大的聚合會增加載入、交易與併發成本,也讓模型更難維護。
領域事件是不是每次資料更新都要發?
不用。領域事件應該代表領域專家會在意的業務事實,例如付款確認或訂閱取消。單純技術欄位更新,不一定值得成為領域事件。
什麼情況最值得導入 DDD?
當業務規則複雜、語言容易混淆、不同團隊對同一概念理解不同,或需求變更常牽動大量程式碼時,DDD 通常更有價值。簡單 CRUD 系統則不一定需要完整套用。
參考資料
Eric Evans. Domain-Driven Design: Tackling Complexity in the Heart of Software.
Martin Fowler. “Domain Driven Design.” MartinFowler.com.
Martin Fowler. “Ubiquitous Language.” MartinFowler.com.
Martin Fowler. “Bounded Context.” MartinFowler.com.
Microsoft Learn. “Use Tactical DDD to Design Microservices.”
Microsoft Learn. “Domain Events: Design and Implementation.”
