第十章:領域服務與規約模式
10.1 引言:當實體不夠用時
在上一章,我們學習了將業務邏輯封裝在 聚合根 (Aggregate Root) 中。這是最理想的情況,因為它保證了狀態的一致性。
然而,有些邏輯並不適合放在單一實體中:
- 涉及多個聚合:例如「將 A 帳戶的錢轉到 B 帳戶」。
- 依賴外部資源:例如「檢查使用者名稱是否唯一 (需查詢 DB)」。
- 複雜的計算:邏輯太過複雜,放入實體會讓實體變得臃腫。
這時,我們就需要 領域服務 (Domain Service)。
10.2 領域服務 (Domain Service)
領域服務是領域層的一部分,它包含無狀態的業務邏輯。在 ABP 中,我們通常將其命名為 Manager (例如 IdentityUserManager)。
1. 定義與實作
繼承 DomainService 類別。
public class IssueManager : DomainService
{
private readonly IRepository<Issue, Guid> _issueRepository;
public IssueManager(IRepository<Issue, Guid> issueRepository)
{
_issueRepository = issueRepository;
}
public async Task AssignToAsync(Issue issue, Guid userId)
{
// 規則:檢查使用者是否已有超過 3 個進行中的問題
var openIssueCount = await _issueRepository.CountAsync(i => i.AssignedUserId == userId && !i.IsClosed);
if (openIssueCount >= 3)
{
throw new BusinessException("Issue:UserHasTooManyOpenIssues");
}
issue.AssignTo(userId);
}
}2. 使用領域服務
通常在 應用服務 (Application Service) 中呼叫。
public class IssueAppService : ApplicationService
{
private readonly IssueManager _issueManager;
private readonly IRepository<Issue, Guid> _issueRepository;
public async Task AssignAsync(Guid id, Guid userId)
{
var issue = await _issueRepository.GetAsync(id);
// 呼叫領域服務執行業務邏輯
await _issueManager.AssignToAsync(issue, userId);
await _issueRepository.UpdateAsync(issue);
}
}3. 領域服務 vs 應用服務
- 領域服務:處理「核心業務邏輯」,不關心 DTO、HTTP、權限。
- 應用服務:處理「應用流程」,負責 DTO 轉換、權限檢查、協調領域物件。
10.3 規約模式 (Specification Pattern)
當我們有複雜的查詢條件或業務規則時,直接在 Repository 或 Service 中寫 LINQ 表達式會導致程式碼難以閱讀且無法重用。規約模式可以解決這個問題。
1. 定義規約
ABP 提供了 Specification<T> 基類。
public class InactiveUserSpecification : Specification<AppUser>
{
private readonly DateTime _thresholdDate;
public InactiveUserSpecification(DateTime thresholdDate)
{
_thresholdDate = thresholdDate;
}
public override Expression<Func<AppUser, bool>> ToExpression()
{
// 規則:最後登入時間早於閾值,且帳號未被鎖定
return user => user.LastLoginTime < _thresholdDate && !user.IsLocked;
}
}2. 在 Repository 中使用
ABP 的 Repository 支援直接傳入 ISpecification。
public async Task DeleteInactiveUsersAsync()
{
var spec = new InactiveUserSpecification(DateTime.Now.AddYears(-1));
// GetListAsync 支援 Specification
var inactiveUsers = await _userRepository.GetListAsync(spec);
await _userRepository.DeleteManyAsync(inactiveUsers);
}3. 組合規約
規約可以像積木一樣組合:And, Or, Not。
var spec = new InactiveUserSpecification(date)
.And(new UserInRegionSpecification("US"))
.And(new UserHasNoOrdersSpecification());10.4 實戰:重構複雜邏輯
假設我們有一個 BookStore,我們要查詢「熱門書籍」。 定義:
- 銷量 > 1000
- 評分 > 4.5
- 出版日期在 1 年內
重構前 (AppService):
var books = await _bookRepository.GetListAsync(b =>
b.Sales > 1000 &&
b.Rating > 4.5 &&
b.PublishDate > DateTime.Now.AddYears(-1));重構後 (Specification):
定義規約:
csharppublic class PopularBookSpecification : Specification<Book> { public override Expression<Func<Book, bool>> ToExpression() { var yearAgo = DateTime.Now.AddYears(-1); return b => b.Sales > 1000 && b.Rating > 4.5 && b.PublishDate > yearAgo; } }使用:
csharpvar books = await _bookRepository.GetListAsync(new PopularBookSpecification());
好處:
- 可讀性:
PopularBookSpecification清楚表達了業務意圖。 - 可重用性:可以在多個 Service 或其他規約中重用。
- 可測試性:可以單獨對
PopularBookSpecification撰寫單元測試。
10.5 習題
概念題(易)⭐
Q1. 為什麼 OrderItem 應在 Order 聚合內部而非獨立聚合?
請從生命週期依賴、一致性邊界、事務邊界和業務語義四個角度分析。
Q2. 值物件與實體的主要差異在持久化時如何體現?
請說明兩者在資料表設計、更新方式、相等性比較和 EF Core 配置上的差異,並提供範例。
計算 / 練習題(中)💻
Q3. 設計 Product 聚合,列舉應包含的欄位、驗證規則與至少兩個業務方法。
要求:
- 包含基本資訊、定價資訊、庫存管理和狀態
- 至少 5 個驗證規則
- 至少 2 個業務方法(如調整價格、庫存調整)
- 使用值物件封裝複合概念
- 發佈領域事件
Q4. 描述領域事件在分散式系統中保證一致性的流程(至少 5 步驟)。
要求:
- 說明 Outbox 模式的運作
- 解釋冪等性處理機制
- 提供補償機制範例
- 繪製完整流程圖
實作 / 編碼題(較難)🚀
Q5. 實作一個 ShoppingCart 聚合,包含 AddProduct、RemoveProduct、Calculate 方法,並撰寫完整單元測試。
要求:
- 實作完整的購物車聚合(包含 CartItem 實體)
- 至少 6 個業務方法(新增、移除、更新數量、計算、清空、結帳)
- 完整的驗證邏輯(狀態檢查、數量限制、過期檢查)
- 發佈領域事件
- 至少 10 個單元測試案例
Q6. 設計一個跨聚合的業務場景(例如訂單確認時驗證商品庫存),使用領域服務與事件實現,並說明如何保證一致性。
要求:
- 設計完整的訂單確認流程
- 實作 OrderManager 領域服務
- 使用領域事件處理庫存扣減
- 實作補償機制(庫存不足時取消訂單)
- 說明事務邊界和一致性保證
習題解答:請參考 content/solutions/ch10-solutions.md
10.6 總結
本章介紹了 DDD 的兩個進階工具:
- 領域服務:處理跨實體或依賴外部資源的業務邏輯。
- 規約模式:封裝可重用的查詢邏輯與業務規則。
掌握這些工具,可以讓您的領域層更加乾淨、模組化且易於測試。下一章,我們將進入 應用層 (Application Layer) 的細節,探討 DTO 設計與物件映射。
參考資源: