Skip to content

第十章:領域服務與規約模式

10.1 引言:當實體不夠用時

在上一章,我們學習了將業務邏輯封裝在 聚合根 (Aggregate Root) 中。這是最理想的情況,因為它保證了狀態的一致性。

然而,有些邏輯並不適合放在單一實體中:

  1. 涉及多個聚合:例如「將 A 帳戶的錢轉到 B 帳戶」。
  2. 依賴外部資源:例如「檢查使用者名稱是否唯一 (需查詢 DB)」。
  3. 複雜的計算:邏輯太過複雜,放入實體會讓實體變得臃腫。

這時,我們就需要 領域服務 (Domain Service)


10.2 領域服務 (Domain Service)

領域服務是領域層的一部分,它包含無狀態的業務邏輯。在 ABP 中,我們通常將其命名為 Manager (例如 IdentityUserManager)。

1. 定義與實作

繼承 DomainService 類別。

csharp
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) 中呼叫。

csharp
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> 基類。

csharp
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

csharp
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

csharp
var spec = new InactiveUserSpecification(date)
    .And(new UserInRegionSpecification("US"))
    .And(new UserHasNoOrdersSpecification());

10.4 實戰:重構複雜邏輯

假設我們有一個 BookStore,我們要查詢「熱門書籍」。 定義:

  1. 銷量 > 1000
  2. 評分 > 4.5
  3. 出版日期在 1 年內

重構前 (AppService)

csharp
var books = await _bookRepository.GetListAsync(b =>
    b.Sales > 1000 &&
    b.Rating > 4.5 &&
    b.PublishDate > DateTime.Now.AddYears(-1));

重構後 (Specification)

  1. 定義規約

    csharp
    public 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;
        }
    }
  2. 使用

    csharp
    var 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 設計與物件映射。


參考資源

Released under the MIT License.