DDD 是什麼 — 從 .NET MVC 和 Repository Pattern 講起
在專案開發中第一次碰到 DDD (Domain-Driven Design)。之前寫過 .NET MVC, 也學過 Go, 發現很多概念其實跟已經知道的東西有關聯, 特別是 Repository Pattern。這篇記錄一下我怎麼從這些已有的認知去理解 DDD
MVC 是什麼
MVC 是 Model-View-Controller, 三個角色:
- Model — 資料和 business logic
- View — 畫面 (HTML, JSON response 等)
- Controller — 接收使用者的操作, 呼叫 Model 處理, 把結果交給 View 呈現
|
|
概念上, Controller 是中間人, 它不做邏輯判斷, 只負責協調 Model 和 View
.NET MVC 實務上的架構
教科書的 MVC 是三層, 但實務上 .NET MVC 的 Model 會再拆。因為把 business logic 跟 DB 存取全塞在 Model 裡, 東西一多就很難維護。所以實際上會拆成:
|
|
View 在 API 的情境下就是 JSON response, 不太需要額外處理
拿「啟用一個品牌」當例子:
|
|
Brand 這個 entity 只有 property, 沒有任何方法, 就是一個裝資料的容器。所有的判斷邏輯 (「archived 不能 activate」) 都在 Service 裡。這種 entity 叫做 anemic entity (貧血 entity) — 有資料, 沒有行為
Repository Pattern
Repository Pattern 的概念:
用一個 interface 把「資料怎麼存取」抽象化, 讓 Service 不需要知道底層是 SQL Server、PostgreSQL 還是記憶體
|
|
透過 DI (Dependency Injection) 把 interface 跟 implementation 接起來:
|
|
Service 只依賴 IBrandRepository interface, 不依賴具體的 SQL 實作。想換 DB 或寫測試時, 換 implementation 就好
知道這些之後, DDD 就不難了, 因為 Repository Pattern 本來就是 DDD 裡面的東西
DDD 到底多了什麼
DDD 全名 Domain-Driven Design (領域驅動設計)。一開始聽到覺得很抽象, 但搞懂之後發現核心就一件事:
把 business logic 從 Service 搬回 Entity 自己身上
同樣「啟用品牌」, DDD 的寫法:
|
|
|
|
比較一下:
| .NET MVC | DDD | |
|---|---|---|
| Entity | 只有 property (anemic) | 有 property + 方法 (rich) |
| Service | 判斷邏輯全在這 | 只負責 load → call → save |
| Repository | interface + implementation | 一樣的概念 |
Repository 的部分完全沒變。差別只在 business rule 放在哪
為什麼要搬?
有 10 個 Service 都會操作 Brand 的情況下, .NET MVC 的寫法會讓「archived 不能 activate」這條規則散落在各個 Service 裡, 很容易有人漏掉
DDD 的話, 這條規則寫在 Brand.activate() 裡, 不管誰呼叫都會檢查。Entity 自己保護自己的狀態
DDD 的核心概念
Entity (實體)
有唯一 ID, 有行為, 會變化
|
|
跟 .NET Entity 的差別就是多了方法。兩個 Entity 的比較方式是看 ID, 不是看 property 的值
Value Object (值物件)
沒有 ID, 比較的是「值」。不可變
|
|
比喻: 一張一百元鈔票跟另一張一百元鈔票, 不在乎是「哪一張」, 只在乎金額。這就是 Value Object — 比較值, 不是身分
.NET 裡最接近的是 C# 9 的 record, 預設 value-based equality
Aggregate (聚合)
這個概念在 .NET MVC 裡沒有, 我花了一段時間才搞懂
Aggregate 就是一組一定要一起存取的東西, 有一個 root entity 當入口。像是 Post (貼文) 跟它的 Comment (留言):
|
|
規則:
- 外面只能碰 root (
Post), 不能直接操作Comment - 存取時整個 aggregate 一起 load / save
- 不同 aggregate 之間只用 ID 互相參考, 不持有對方的 object
第三點跟 .NET EF Core 的 navigation property 剛好相反。EF Core 可以寫 post.Author.Name 直接拿到 User 物件, DDD 不這樣做 — 只存 author_id, 要拿 User 就自己去查
理由是: 如果 Post 直接持有 User 物件, 改 User 的時候就要擔心 Post 那邊會不會壞。用 ID 切開, 每個 aggregate 管好自己
Bounded Context (限界上下文)
怎麼決定哪些東西放在同一個模組?
DDD 的做法是看「語言」。同一個詞在不同場景代表不同意思, 就是不同的 context
像是 「User」, 對社交功能來說是「有 follow、有 profile 的人」; 對錢包來說是「有餘額、有交易紀錄的帳戶」。雖然都叫 User, 關心的面向完全不同, 所以 Users module 和 Ledger module 是兩個 Bounded Context
專案裡的切法:
| Context | 關心什麼 |
|---|---|
| Users | 身分、profile、follow、策展 |
| Brand | 品牌資料、Shopify 整合 |
| Ledger | 錢包、交易、佣金 |
| Affiliate | 聯盟行銷、佣金比例設定 |
| Community | 貼文、留言 |
Go 的做法
學 Go 的時候好奇過: Go 社群有在用 DDD 嗎?
結論是有, 但不常見, 做法也比較鬆散。Go 社群重視 simplicity, 不太喜歡多層抽象, 所以完整的 DDD 分層在 Go 專案裡很少看到。標準做法還是 handler → service → repository, 跟 .NET MVC 類似
不過有幾個 DDD 概念在 Go 裡很自然:
Go 的 interface 是 implicit (不用寫 implements), 定義 Repository contract 很直覺:
|
|
Entity with behavior 用 method on struct:
|
|
Python 的 Protocol (structural typing) 跟 Go 的 implicit interface 概念一樣 — 不用宣告「我實作了什麼 interface」, method signature 對上就算實作。跟 C# 要寫 class Foo : IFoo 明確宣告很不一樣
為什麼不直接用 .NET MVC 的模式
Repository Pattern 已經會了, 為什麼還要 DDD?
因為專案越來越大的時候, 碰到了幾個 .NET MVC 模式處理不好的問題:
- Service 越來越胖 — 所有 logic 都在 Service, 一個 BrandService 幾百行, 改一個地方要讀完整個檔案
- 規則散落各處 — 同一個 business rule 在多個 Service 裡各寫了一次, 改了一個忘了改另外的
- 模組之間糾纏 — UserService import BrandService, BrandService import WalletService, 蜘蛛網
- 不知道邊界在哪 — 想拆模組但不知道從哪裡切
DDD 的解法:
- 問題 1 → entity 自己帶 logic, service 變薄
- 問題 2 → rule 只寫在 entity 上, 單一來源
- 問題 3 → 跨模組只能透過 presentation 層接線, 不能直接 import
- 問題 4 → Bounded Context 提供切模組的判斷標準
整理
| .NET MVC 已知概念 | DDD 對應 | 差在哪 |
|---|---|---|
| Entity (property only) | DDD Entity | DDD 的 entity 有行為 |
| Repository Pattern | DDD Repository | 幾乎一樣, DDD 更嚴格 (只 return aggregate root) |
| Service | Application Service | DDD 的 service 更薄, 只做 load → call → save |
| Controller | Presentation (route) | 概念一樣 |
| DI Container (Startup.cs) | Composition Root (providers.py) | 概念一樣, 位置不同 |
| 沒有直接對應 | Value Object | 不可變, 比較值 |
| 沒有直接對應 | Aggregate | 一組一定要一起存取的東西 |
| 沒有直接對應 | Bounded Context | 用「語言」來切模組邊界 |
下一篇記錄實際在專案裡踩到的坑: aggregate boundary 畫錯、double-entry ledger 怎麼設計、event system 怎麼讓模組之間溝通
References
- Domain-Driven Design Reference — Eric Evans
- Martin Fowler — AnemicDomainModel
- Martin Fowler — BoundedContext
- Martin Fowler — DDD Aggregate
- Martin Fowler — ValueObject
- Martin Fowler — Repository
- Microsoft — ASP.NET MVC Pattern
- Microsoft — Repository Pattern
- Python dataclasses — frozen
- Python typing — Protocol
- Go — Effective Go (Interfaces)