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 呈現
1
User action → Controller → Model → Controller → View → User sees result

概念上, Controller 是中間人, 它不做邏輯判斷, 只負責協調 Model 和 View

.NET MVC 實務上的架構

教科書的 MVC 是三層, 但實務上 .NET MVC 的 Model 會再拆。因為把 business logic 跟 DB 存取全塞在 Model 裡, 東西一多就很難維護。所以實際上會拆成:

1
2
3
4
5
6
7
Controller  →  接 HTTP request, 決定呼叫哪個 Service, 回 response
Service     →  business logic 寫在這裡 (原本 Model 的 logic 部分)
Repository  →  資料庫存取 (原本 Model 的 data access 部分)
Entity      →  資料結構 (原本 Model 的 data 部分)

View 在 API 的情境下就是 JSON response, 不太需要額外處理

拿「啟用一個品牌」當例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Controller — receives HTTP request
[HttpPost("brands/{id}/activate")]
public IActionResult Activate(Guid id) {
    _brandService.Activate(id);
    return Ok();
}

// Service — all business logic lives here
public void Activate(Guid id) {
    var brand = _repo.GetById(id);
    if (brand.Status == BrandStatus.Archived)
        throw new InvalidOperationException("Cannot activate archived brand");
    brand.Status = BrandStatus.Active;
    _repo.Save(brand);
}

// Entity — just a data container, no logic
public class Brand {
    public Guid Id { get; set; }
    public string Name { get; set; }
    public BrandStatus Status { get; set; }
}

Brand 這個 entity 只有 property, 沒有任何方法, 就是一個裝資料的容器。所有的判斷邏輯 (「archived 不能 activate」) 都在 Service 裡。這種 entity 叫做 anemic entity (貧血 entity) — 有資料, 沒有行為

Repository Pattern

Repository Pattern 的概念:

用一個 interface 把「資料怎麼存取」抽象化, 讓 Service 不需要知道底層是 SQL Server、PostgreSQL 還是記憶體

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// Interface — defines the contract
public interface IBrandRepository {
    Brand GetById(Guid id);
    void Save(Brand brand);
}

// Implementation — actual database access
public class SqlBrandRepository : IBrandRepository {
    private readonly DbContext _db;

    public Brand GetById(Guid id) {
        return _db.Brands.Find(id);
    }

    public void Save(Brand brand) {
        _db.Brands.Update(brand);
        _db.SaveChanges();
    }
}

透過 DI (Dependency Injection) 把 interface 跟 implementation 接起來:

1
2
// Startup.cs
services.AddScoped<IBrandRepository, SqlBrandRepository>();

Service 只依賴 IBrandRepository interface, 不依賴具體的 SQL 實作。想換 DB 或寫測試時, 換 implementation 就好

知道這些之後, DDD 就不難了, 因為 Repository Pattern 本來就是 DDD 裡面的東西

DDD 到底多了什麼

DDD 全名 Domain-Driven Design (領域驅動設計)。一開始聽到覺得很抽象, 但搞懂之後發現核心就一件事:

把 business logic 從 Service 搬回 Entity 自己身上

同樣「啟用品牌」, DDD 的寫法:

1
2
3
4
5
6
7
8
9
# Entity — has behavior, not just data
class Brand:
    name: str
    status: BrandStatus = BrandStatus.PENDING

    def activate(self):
        if self.status == BrandStatus.ARCHIVED:
            raise ValueError("Cannot activate archived brand")
        self.status = BrandStatus.ACTIVE
1
2
3
4
5
6
7
# Service — becomes thin, just orchestration
class BrandService:
    def activate(self, brand_id: UUID) -> Brand:
        brand = self._repo.find_by_id(brand_id)  # load
        brand.activate()                           # call domain method
        self._repo.save(brand)                     # save
        return brand

比較一下:

.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, 有行為, 會變化

1
2
3
4
5
6
7
class User:
    id: UUID
    display_name: str
    is_private: bool

    def make_private(self):
        self.is_private = True

跟 .NET Entity 的差別就是多了方法。兩個 Entity 的比較方式是看 ID, 不是看 property 的值

Value Object (值物件)

沒有 ID, 比較的是「值」。不可變

1
2
3
4
5
6
7
@dataclass(frozen=True)
class CommissionRate:
    basis_points: int  # e.g. 1500 = 15%

    def __post_init__(self):
        if not (0 <= self.basis_points <= 10_000):
            raise ValueError("Must be 0-10000 bps")

比喻: 一張一百元鈔票跟另一張一百元鈔票, 不在乎是「哪一張」, 只在乎金額。這就是 Value Object — 比較值, 不是身分

.NET 裡最接近的是 C# 9 的 record, 預設 value-based equality

Aggregate (聚合)

這個概念在 .NET MVC 裡沒有, 我花了一段時間才搞懂

Aggregate 就是一組一定要一起存取的東西, 有一個 root entity 當入口。像是 Post (貼文) 跟它的 Comment (留言):

1
2
3
4
Post (aggregate root)
├── Comment 1
├── Comment 2
└── Comment 3

規則:

  • 外面只能碰 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 很直覺:

1
2
3
4
type BrandRepository interface {
    FindByID(ctx context.Context, id uuid.UUID) (*Brand, error)
    Save(ctx context.Context, brand *Brand) error
}

Entity with behavior 用 method on struct:

1
2
3
4
5
6
7
func (b *Brand) Activate() error {
    if b.Status == StatusArchived {
        return errors.New("cannot activate archived brand")
    }
    b.Status = StatusActive
    return nil
}

Python 的 Protocol (structural typing) 跟 Go 的 implicit interface 概念一樣 — 不用宣告「我實作了什麼 interface」, method signature 對上就算實作。跟 C# 要寫 class Foo : IFoo 明確宣告很不一樣

為什麼不直接用 .NET MVC 的模式

Repository Pattern 已經會了, 為什麼還要 DDD?

因為專案越來越大的時候, 碰到了幾個 .NET MVC 模式處理不好的問題:

  1. Service 越來越胖 — 所有 logic 都在 Service, 一個 BrandService 幾百行, 改一個地方要讀完整個檔案
  2. 規則散落各處 — 同一個 business rule 在多個 Service 裡各寫了一次, 改了一個忘了改另外的
  3. 模組之間糾纏 — UserService import BrandService, BrandService import WalletService, 蜘蛛網
  4. 不知道邊界在哪 — 想拆模組但不知道從哪裡切

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

0%