領域驅動設計 —— 戰術模式

發布於
...
AI 翻譯

領域驅動設計(Domain-Driven Design,DDD)是一種把業務領域和它的模型放在程式碼正中央的軟體設計方法。它給了我們一組模式,讓業務概念、規則和物件的生命週期能在軟體裡被清楚地表達出來。

這篇文章會介紹 DDD 裡的術語和概念,並配上程式碼範例。

速查表

#模式什麼時候用它
1分層你要動手做任何活得比一個 sprint 久的東西。
2實體使用者需要在時間軸上分辨不同的實例。
3值物件把它換成一個一模一樣的副本,什麼都不會變。
4服務一個操作需要兩個以上的物件,或需要外部系統。
5模組你正在幫最上層的資料夾命名。
6聚合有一條規則必須在每次交易結束時都成立。
7工廠建構要好幾個步驟,或需要協作的物件。
8Repository必須在不順著參照走的情況下找到聚合根。

1. 分層(Layers)

在 DDD 裡,軟體通常會被切成四層:UI、應用、領域、基礎設施。最重要的是依賴只往一個方向流動,尤其是領域必須和 UI 與應用的關注點隔離開來。

UI → Application → Domain

同一條規則,兩個不同的呼叫端

躺在 controller 或 SQL 裡的規則很難找,而且沒有一個資料庫和一個 request 就測不動。

分層 —— 一條規則,兩個呼叫端
<?php

declare(strict_types=1);

/*
 * Layers — the same rule, reached by two different callers.
 *
 * The two halves are separate tabs, so each can be read on its own:
 * 1-before.php is the version that breaks, 2-after.php is the version
 * that holds. Run executes both, in that order.
 */

require __DIR__ . '/1-before.php';
echo "\n";
require __DIR__ . '/2-after.php';
ReadyRuns in your browser
Console

No output yet.

2. 實體(Entities)

由屬性而不是身分定義的物件。它通常是不可變的。

實體是 DDD 裡最基本的建構元素之一。它給了我們一個可靠的方式,去判斷兩個物件是不是代表同一個東西,通常是透過像 ID 這樣的唯一身分。實體一般用在有生命週期或歷程、而且使用者需要分辨不同實例的物件上。

實務上,把實體的 UUID 在建立的當下就指派好、而不是等到寫進資料庫時才給,往往是有幫助的。否則實體會有一段時間沒有身分,這會讓相等性判斷、以及對剛建立的實體的參照,變得比較困難也比較容易出錯。

太晚才到的識別碼不算身分

實體 —— 身分要早於 INSERT
<?php

declare(strict_types=1);

/*
 * Entities — an identifier that arrives late is not an identity.
 *
 * The two halves are separate tabs, so each can be read on its own:
 * 1-before.php is the version that breaks, 2-after.php is the version
 * that holds. Run executes both, in that order.
 */

require __DIR__ . '/1-before.php';
echo "\n";
require __DIR__ . '/2-after.php';
ReadyRuns in your browser
Console

No output yet.

3. 值物件(Value Objects)

只由屬性定義的物件。它沒有 id,建構之後也不再改變。

值物件是 DDD 裡另一個最基本的建構元素。當「把一個物件換成另一個屬性相同的物件」對系統的行為完全沒有影響時,通常就該用值物件。重要的是值本身,而不是那個物件的身分。

舉例來說,new Money(200) 不管屬於 John 還是 Josh,代表的都是同一個值。我們不在乎手上拿到的是哪一個 Money 實例,只在乎它的值是 200。

為什麼被共用的值一定要不可變

值物件 —— 不可變,共用才安全
<?php

declare(strict_types=1);

/*
 * Value objects — why a shared value has to be immutable.
 *
 * The two halves are separate tabs, so each can be read on its own:
 * 1-before.php is the version that breaks, 2-after.php is the version
 * that holds. Run executes both, in that order.
 */

require __DIR__ . '/1-before.php';
echo "\n";
require __DIR__ . '/2-after.php';
ReadyRuns in your browser
Console

No output yet.

4. 服務(Services)

一個沒有狀態的操作,用活動來命名。

當一個重要的領域操作不自然地屬於任何一個實體或值物件時,通常就會用到服務。與其硬把這個行為塞進一個放不下它的物件,不如把它表達成一個服務。

舉例來說,在兩個銀行帳戶之間轉帳,也許用 FundsTransferService 來表達會更好,因為這個操作牽涉多個 Account,而且它本身就是銀行領域裡一個重要的概念。

一個好的領域服務應該沒有狀態、在介面上使用領域的概念,並且代表一個在通用語言(Ubiquitous Language)裡說得通的操作。

那個把實體的規則搶過去的服務

服務 —— 規則該和資料待在一起
<?php

declare(strict_types=1);

/*
 * Services — the service that holds rules the entity should hold.
 *
 * The two halves are separate tabs, so each can be read on its own:
 * 1-before.php is the version that breaks, 2-after.php is the version
 * that holds. Run executes both, in that order.
 */

require __DIR__ . '/1-before.php';
echo "\n";
require __DIR__ . '/2-after.php';
ReadyRuns in your browser
Console

No output yet.

5. 模組(Modules)

一組應該被放在一起理解的領域概念,在程式碼裡通常以模組、套件或命名空間呈現。

模組談的是我們怎麼圍繞著有意義的業務概念,去組織模型和程式碼。因為最上層的資料夾常常是新人最先看到的東西,取自業務的名字有助於描述這個系統實際上在做什麼,而取自技術模式的名字,幾乎可以拿來描述任何系統。

因為之後才改命名空間會產生很大的 diff、合併衝突,以及吵雜的歷史,這個結構最好一開始就弄對。按業務概念分組,像是 Tenant/Tenancy/,而不是按模式分組,像是 Entity/Service/Dto/

# 按模式分組 —— 拿這個問題問它:「押金的規則在哪裡?」
src/
├── Entity/
│ ├── Landlord.php
│ ├── Property.php
│ └── Tenancy.php
├── Repository/
│ └── TenancyRepository.php
├── Service/
│ ├── TenancyService.php
│ └── DepositService.php
└── Dto/
├── TenancyDto.php
└── TenancyMapper.php
# 按概念分組 —— 同樣那些類別,重新編組
src/
└── Letting/
├── Booking/
│ ├── Tenancy.php
│ ├── Rent.php
│ ├── DepositCap.php
│ └── TenancyRepository.php
├── Property/
│ ├── Property.php
│ ├── Address.php
│ └── PropertyRepository.php
├── Customer/
│ ├── Landlord.php
│ └── Tenant.php
└── Infrastructure/
└── Persistence/
└── DoctrineTenancyRepository.php

6. 聚合(Aggregates)

一組被當成單一單位來變更的物件,有唯一的根,以及一條明確的邊界。

到目前為止,我們介紹了實體和值物件這兩個 DDD 裡最基本的建構元素。但一旦這些物件開始彼此互動,我們就需要一些規則,來界定哪些物件屬於同一組、它們可以怎麼被存取,以及一致性必須在交易結束時的哪個範圍內被保證。聚合給了我們一個界定那條邊界的方式。

聚合是一組在變更時被當成單一單位看待的實體和值物件。每個聚合都有一個實體作為它的聚合根,聚合外部的物件應該透過這個根去和聚合互動。根同時也負責確保聚合的業務規則和不變條件,在交易完成的時候仍然成立。

舉例來說,我們可能把一份租約(Tenancy)建模成聚合根,裡面裝著承租人、租金資訊、押金細節這些相關的物件。與其讓系統的其他部分直接改動那些物件,變更會走過 Tenancy,讓它能確保整份租約維持在合法的狀態。

兩個各自合法的編輯,湊出一個不合法的結果

聚合 —— 兩個人一起破掉的上限
<?php

declare(strict_types=1);

/*
 * Aggregates — two valid edits that produce one invalid result.
 *
 * The two halves are separate tabs, so each can be read on its own:
 * 1-before.php is the version that breaks, 2-after.php is the version
 * that holds. Run executes both, in that order.
 */

require __DIR__ . '/1-before.php';
echo "\n";
require __DIR__ . '/2-after.php';
ReadyRuns in your browser
Console

No output yet.

7. 工廠(Factories)

專門負責建構複雜物件或完整聚合的程式碼。

工廠談的是我們怎麼建立複雜的實體或聚合,而不用把它們全部的建構邏輯攤在呼叫端面前。當建立一個物件需要很多步驟、規則或內部物件時,把這些邏輯全塞進建構子,會讓模型更難理解。

取而代之,工廠提供一個清楚的方式,去建立一個完整而且合法的物件。呼叫端只需要提供建立它所需要的資訊,內部的建構細節則交給工廠負責。

沿用前面的例子,一份 Tenancy 可能需要建立承租人、租金細節、押金資訊,以及聚合內部的其他物件。與其讓呼叫端一個一個把它們建出來,TenancyFactory 可以建立整個聚合,並回傳一個合法的 Tenancy 聚合根。

半成品物件,以及載入時走的另一道門

工廠 —— 建立和重建是兩道不同的門
<?php

declare(strict_types=1);

/*
 * Factories — half-built objects, and the second door for loading.
 *
 * The two halves are separate tabs, so each can be read on its own:
 * 1-before.php is the version that breaks, 2-after.php is the version
 * that holds. Run executes both, in that order.
 */

require __DIR__ . '/1-before.php';
echo "\n";
require __DIR__ . '/2-after.php';
ReadyRuns in your browser
Console

No output yet.

8. Repository(Repositories)

一個行為上像是「某型別所有實例的記憶體集合」的物件。

Repository 談的是我們怎麼找到並取回既有的聚合,同時不讓持久化的細節滲進領域。與其讓呼叫端知道資料是怎麼儲存、查詢或重建的,Repository 給了我們一個像集合一樣的介面去存取聚合根。

舉例來說,如果我們需要一份 Tenancy,應用層可以拿 ID 去跟 TenancyRepository 要。Repository 負責查資料庫、把 Tenancy 聚合重建起來,其他程式碼則只跟領域物件打交道。

Repository 一般只為聚合根建立,而不是為聚合裡的每一個實體都建一個。內部的物件應該透過它們的聚合根來存取。

同一個答案,兩種差很多的代價

Repository —— 一萬個物件,或是零個
<?php

declare(strict_types=1);

/*
 * Repositories — the same answer, at two very different costs.
 *
 * The two halves are separate tabs, so each can be read on its own:
 * 1-before.php is the version that breaks, 2-after.php is the version
 * that holds. Run executes both, in that order.
 */

require __DIR__ . '/1-before.php';
echo "\n";
require __DIR__ . '/2-after.php';
ReadyRuns in your browser
Console

No output yet.

結語

這篇文章聚焦在領域模型是怎麼被表達出來的,但領域物件也有它們的生命週期,這就帶出了聚合、工廠和 Repository:用來界定交易邊界、建立合法的物件,以及在之後把它們再找回來。

如果你喜歡這篇文章,歡迎點擊下方按鈕分享給更多人,你的支持對我來說意義重大。

訂閱更新

訂閱以取得每週精選,包含更多脈絡、製作筆記,以及背後的想法。

Buy me a coffee