領域驅動設計 —— 戰術模式
- 發布於
- ...AI 翻譯查看English原文
領域驅動設計(Domain-Driven Design,DDD)是一種把業務領域和它的模型放在程式碼正中央的軟體設計方法。它給了我們一組模式,讓業務概念、規則和物件的生命週期能在軟體裡被清楚地表達出來。
這篇文章會介紹 DDD 裡的術語和概念,並配上程式碼範例。
速查表
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';
No output yet.
2. 實體(Entities)
由屬性而不是身分定義的物件。它通常是不可變的。
實體是 DDD 裡最基本的建構元素之一。它給了我們一個可靠的方式,去判斷兩個物件是不是代表同一個東西,通常是透過像 ID 這樣的唯一身分。實體一般用在有生命週期或歷程、而且使用者需要分辨不同實例的物件上。
實務上,把實體的 UUID 在建立的當下就指派好、而不是等到寫進資料庫時才給,往往是有幫助的。否則實體會有一段時間沒有身分,這會讓相等性判斷、以及對剛建立的實體的參照,變得比較困難也比較容易出錯。
太晚才到的識別碼不算身分
<?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';
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';
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';
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';
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';
No output yet.
8. Repository(Repositories)
一個行為上像是「某型別所有實例的記憶體集合」的物件。
Repository 談的是我們怎麼找到並取回既有的聚合,同時不讓持久化的細節滲進領域。與其讓呼叫端知道資料是怎麼儲存、查詢或重建的,Repository 給了我們一個像集合一樣的介面去存取聚合根。
舉例來說,如果我們需要一份 Tenancy,應用層可以拿 ID 去跟 TenancyRepository 要。Repository 負責查資料庫、把 Tenancy 聚合重建起來,其他程式碼則只跟領域物件打交道。
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';
No output yet.
結語
這篇文章聚焦在領域模型是怎麼被表達出來的,但領域物件也有它們的生命週期,這就帶出了聚合、工廠和 Repository:用來界定交易邊界、建立合法的物件,以及在之後把它們再找回來。
訂閱更新
訂閱以取得每週精選,包含更多脈絡、製作筆記,以及背後的想法。