動態

詳情 返回 返回

領域驅動設計實戰:聚合根設計與領域模型實現 - 動態 詳情

附:源代碼參考

  • Eleven 低成本可落地的 DDD 技術方案腳手架
  • 現代化領域驅動設計原書示例

背景介紹

我清楚的知道一點,其實大家如果上網找文章,90%以上的人肯定是想知道具體編程的時候怎麼落地,尤其是聚合根。

現在互聯網的文章要麼是水軍寫的,要麼是宣傳廣告來的,他們的問題如下

  • 一來要麼是衝你錢包來的,實際參考價值不足0;
  • 二來全部清一色電商平台,就好像全世界都是淘寶亞馬遜。

所以這篇文章誕生了,我承諾

  1. 沒有電商,沒有訂單、庫存、優惠這個例子。
  2. 沒有廢話,不吹DDD,不黑MVC。
  3. 能落地,能有一個可用套路。
  4. 這只是一種實現思路,不是唯一實現思路。

需求如下

業務需求

某服裝企業的業務專家+技術團隊要實現自己的ERP系統,實現採購和庫存管理,經過頭腦風暴的出業務規則如下:(這裏是為了演示而提出的假設需求,並不保證符合真實業務,實際比這個複雜太多)

  • 採購上下文
    • 採購員可以錄入採購訂單
    • 採購員可以提交採購訂單,以便讓訂單開始進入審批流程
    • 訂單審批通過之後,採購員可以完成訂單,以便記錄庫存入庫。
      • 觸發庫存入庫(實時/寫操作/強一致)
      • 給庫管發送通知準備驗收(異步/寫操作/最終一致)
  • 庫存上下文
    • 系統可以自動根據採購單入庫
    • 庫管可以查看到庫存入庫的記錄
    • 庫管可以接收到庫存量不足的通知(異步/只讀)
    • 庫管可以查詢到過去任意時間點的庫存歷史。
    • ... 其他庫存需求

根據與業務專家確定業務價值之後,他們決定先開發價值最高的需求,即:帶來80%的業務價值的20%需求,於是暫時將不實現的需求標記為刪除線

技術需求

架構師+技術經理+技術團隊根據領域驅動設計對領域模型的需求描述,團隊決定先對業務建模,並且遵循如下規則:

  • 實體
    • 具有唯一標識符(ID)。
    • 生命週期需顯式管理,狀態變化通過業務操作實現。
    • 封裝業務邏輯,避免貧血模型。
    • 在聚合內的ID僅需局部唯一,由聚合根統一管理。
    • 不能直接引用其他聚合的實體,需通過聚合根ID關聯。
  • 聚合根
    • 聚合的唯一入口點,外部只能通過聚合根ID訪問聚合內部對象。
    • 具有全局唯一ID,控制聚合內實體的生命週期。
    • 邊界保護:聚合內業務規則必須通過聚合根校驗。
    • 小巧設計:聚合應儘可能小,避免包含無關實體。
    • 標識符引用:外部僅能通過聚合根ID引用其他聚合,禁止直接關聯內部實體。
  • 值對象
    • 無唯一標識符,通過屬性值定義身份。
    • 不可變性:創建後屬性不可修改,狀態變化需創建新實例。
    • 無生命週期,作為數據載體存在。
    • 通過屬性值比較相等性,而非引用比較。
  • 領域服務
    • 包含了業務領域的核心邏輯。
    • 可對多個實體對象,或者聚合根進行操作。
    • 不包含任何技術層面實現,比如事務,GUI。
    • 如果需要依賴某些技術手段,將其封裝為接口進行依賴。

架構與技術經理和團隊一起協商之後,根據技術成本和價值決定暫時放棄一些規則,暫時放棄的規則被標記為刪除線。

其他需求

其實他們還討論了很多其他需求,比如如下的需求,但是本文重點是帶你感受一種業務建模的手段,這裏列出了這麼多詳細的需求和過程,只是為了模擬一下全部的開發流程。

  1. 數據需求
  2. 性能需求
  3. 用户體驗需求
  4. 成本控制需求
  5. 風險管理需求
  6. ... 其他奇奇怪怪的需求

技術實現

  • 技術經理和團隊開始定義領域模型的技術範圍
    • 領域模型必須包含領域實體
    • 領域模型必須包含領域聚合根
    • 領域模型必須包含領域實體倉庫接口
    • 領域模型必須包含領域事件
    • 領域模型必須包含領域聚合根查詢能力(即讀操作)
    • 領域模型必須包含領域聚合根操作能力(即寫操作)
    • 領域模型必須包含領域業務規則
    • 領域模型可以包含複雜邏輯的設計模型
  • 技術經理和團隊開始定義領域模型的技術手段
    • 使用JPA標記實體類,暫時不考慮實體類與存儲技術解耦,因為技術團隊目前很熟練jpa,而且預估未來不會出現替換持久化技術的可能。
    • 使用cqrs實現讀寫分離,加大力度保證單一職責原則,即一個方法要麼執行寫入,要麼執行讀取。
    • 使用《領域驅動設計》原書中的對象規格(domain spec)技術手段,來查詢對象。
    • 讓領域服務後綴為Manager,來區分領域服務和應用服務
    • 讓領域服務來之行領域模型業務的代理,同時管理領域事件,這一點是因為一些技術條件限制
  • 架構師開始評審技範圍和技術手段
    • 要求團隊將實現手段寫成文檔,作為核心技術文檔好好保管
    • 要求代碼評審的時候按照原則逐條評審,不允許有人輕易破壞規則
    • 提出了很多其他技術管理需求

實現採購單領域模型

在定義了上述技術手段之後,開始落地實現,首先定義採購單聚合根(只展示部分核心代碼):

  
  
@Table(name = "purchase_order")  
@Entity  
@Getter  
@Setter(AccessLevel.PRIVATE)  
@FieldNameConstants  
@NoArgsConstructor(access = AccessLevel.PROTECTED)  
public class PurchaseOrder extends AbstractDomainEntity {  
  
    public final static DomainError ERR_ORDER_NOT_INITIALIZED = SimpleDomainError.of("order_not_initialized", "the Purchase order is not just initialized");  
    public final static DomainError ERR_ORDER_NOT_SUBMITTED = SimpleDomainError.of("order_not_submitted", "the Purchase order has not been submitted");  
    public final static DomainError ERR_ORDER_NOT_APPROVED = SimpleDomainError.of("order_not_approved", "the Purchase order has not been approved");  
  
    public final static String STATUS_INITIALIZED = "initialized";  
    public final static String STATUS_SUBMITTED = "submitted";  
    public final static String STATUS_APPROVED = "approved";  
    public final static String STATUS_REJECTED = "rejected";  
    public final static String STATUS_COMPLETED = "completed";  
  
    @Id  
    @Column(name = "order_id")  
    @GeneratedValue(strategy = GenerationType.IDENTITY)  
    private Long orderId;  
  
    @Column(name = "order_number", unique = true, nullable = false)  
    private String orderNumber;  
  
    @Column(name = "order_date", nullable = false)  
    private LocalDate orderDate = LocalDate.now();  
  
    @Column(name = "supplier_id", nullable = false)  
    private Long supplierId;  
  
    @Column(name = "status", nullable = false)  
    private String status;  
  
    @Column(name = "amount", nullable = false)  
    private double amount = 0;  
  
    @Embedded  
    private Audition audition = Audition.empty();  
  
    @JoinColumn(name = "purchase_order_id", nullable = false)  
    @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)  
    private List<PurchaseOrderItem> items = new ArrayList<>();  
  
    @Builder  
    @SuppressWarnings("unused")  
    private PurchaseOrder(String orderNumber,  
                          Long supplierId,  
                          Collection<PurchaseOrderItem> items) {  
        this.setSupplierId(supplierId);  
        this.setOrderNumber(orderNumber);  
        this.setStatus(STATUS_INITIALIZED);  
        this.setOrderDate(LocalDate.now());  
        this.setItems(new ArrayList<>(items));  
    }  
  
  
    public boolean isState(String state) {  
        return StringUtils.equals(state, this.getStatus());  
    }  
  
    public ImmutableValues<PurchaseOrderItem> getItems() {  
        return ImmutableValues.of(items);  
    }  
  
    private void calculateAmount() {  
        this.amount = 0D;  
        for (PurchaseOrderItem item : this.getItems()) {  
            this.amount += item.getAmount();  
        }  
    }  
      
    void setItems(List<PurchaseOrderItem> items) {  
        this.items.clear();  
        this.items.addAll(items);  
        this.calculateAmount();  
    }  
  
    void update(PurchaseOrderPatch patch) {  
        if (Objects.nonNull(patch.getItems())) {  
            this.setItems(patch.getItems());  
        }  
  
        if (Objects.nonNull(patch.getSupplierId())) {  
            this.setSupplierId(patch.getSupplierId());  
        }  
  
    }  
  
    void submit() {  
        DomainValidator.must(this.isState(STATUS_INITIALIZED), ERR_ORDER_NOT_INITIALIZED);  
  
        this.setStatus(STATUS_SUBMITTED);  
    }  
  
    void approve() {  
        DomainValidator.must(this.isState(STATUS_SUBMITTED), ERR_ORDER_NOT_SUBMITTED);  
  
        this.setStatus(STATUS_APPROVED);  
    }  
  
    void reject() {  
        DomainValidator.must(this.isState(STATUS_SUBMITTED), ERR_ORDER_NOT_SUBMITTED);  
  
        this.setStatus(STATUS_REJECTED);  
    }  
  
    void complete() {  
       DomainValidator.must(this.isState(STATUS_APPROVED), ERR_ORDER_NOT_APPROVED);  
        this.setStatus(STATUS_COMPLETED);  
    }  
}

該聚合根滿足如下特點:

  • 包含業務ID。
  • 無參構造方法只有包級別和子類可訪問,避免創建出來的對象沒有完整的初始化好所有屬性。
  • 提供builder方法來創建對象,所有的對象創建邏輯都由這個構造函數來處理,保證了屬性初始化完整性。
  • 沒有公共setter方法,避免了被任意設置對象的屬性,從而破壞狀態。
  • 修改狀態的方法(寫入操作)都定義在實體內,並且訪問權限為包級別,這意味着只有維護這個包的開發人員才有寫入數據的權限,這避免了被應用層隨意修改屬性。
  • 讀方法是公共訪問權限,這意味着在任何時候只要拿到這個對象,就可以調用其內部的聚合計算邏輯,這避免了多處出現相同計算邏輯的可能。
  • 使用了自定義的ImmutableValues來返回items對象,避免外部讀取集合之後操作add,remove等寫操作。

總結:

  • 創建對象的入口只有一個,且一定滿足數據最小初始化邏輯,且所有創建對象入口可統一追蹤。
  • 所有寫操作得到保護,不會被除了當前包以外的類修改狀態。
  • 所有讀操作被公開,保證了同樣的計算邏輯不重複出現。

實現採購單領域服務

採購單的領域服務不是簡單的一個manager完成,而是配合了一個Interceptor機制,原因是團隊經過調研,發現採購單在業務上線之後可能會隨時增加一個新的教研或者一些其他的需求,這些需求可能是臨時的,也可能是集團決策層需要試錯用的,總是很可能不是核心邏輯,但是由經常變。於是暫時考慮使用Interceptor機制來滿足擴展性。

/**  
 * This is a demo to demonstrate interceptor pattern. * With this pattern, you can design your interceptor for the domain logic to get a high level of extensibility. * Ps: use this pattern only if you are sure you need it. */
public interface PurchaseOrderInterceptor {  
  
    default void preCreate(PurchaseOrder order) {  
    }  
  
    default void afterCreate(PurchaseOrder order) {  
    }  
  
    default void preDelete(PurchaseOrder order) {  
    }  
  
    /// ...
}


@Slf4j  
@Component  
@RequiredArgsConstructor  
public class PurchaseOrderManager implements DomainManager {  
    private final List<PurchaseOrderInterceptor> interceptors;  
    private final PurchaseOrderRepository purchaseOrderRepository;  
  
    public void createOrder(PurchaseOrder order) {  
        interceptors.forEach(purchaseOrderInterceptor -> purchaseOrderInterceptor.preCreate(order));  
  
        purchaseOrderRepository.save(order);  
  
        interceptors.forEach(purchaseOrderInterceptor -> purchaseOrderInterceptor.afterCreate(order));  
  
        var event = PurchaseOrderCreatedEvent.of(order);  
        DomainHelper.publishDomainEvent(event);  
    }  
  
    public void updateOrder(PurchaseOrder order, PurchaseOrderPatch patch) {  
        //...
    }  
  
    public void deleteOrder(PurchaseOrder order) {  
     //...
    }  
  
    public void submit(PurchaseOrder order) {  
       //...
    }  
  
    public void approve(PurchaseOrder order) {  
      //...
    }  
  
    public void reject(PurchaseOrder order) {  
      //...
    }  
  
    public void complete(PurchaseOrder order) {  
        interceptors.forEach(purchaseOrderInterceptor -> purchaseOrderInterceptor.preComplete(order));  
  
        order.complete();  
        purchaseOrderRepository.save(order);  
  
        interceptors.forEach(purchaseOrderInterceptor -> purchaseOrderInterceptor.afterComplete(order));  
  
        var event = PurchaseOrderCompletedEvent.of(order);  
        DomainHelper.publishDomainEvent(event);  
    }  
}

接下來團隊為採購單實現了領域的查詢服務:

  
@Slf4j  
@Component  
@RequiredArgsConstructor  
public class PurchaseOrderFinder implements DomainFinder {  
  
    private final PurchaseOrderRepository purchaseOrderRepository;  
  
    public Optional<PurchaseOrder> get(Long orderId) {  
        return purchaseOrderRepository.findById(orderId);  
    }  
  
    public PurchaseOrder require(Long orderId) throws NoDomainEntityException {  
        return purchaseOrderRepository.findById(orderId).orElseThrow(NoDomainEntityException::instance);  
    }  
  
    public Page<PurchaseOrder> query(PurchaseOrderFilter filter, Pageable pageable) {  
        var spec = Specifications.query(PurchaseOrder.class)  
            .and(StringUtils.isNotBlank(filter.getStatus()), Specs.statusIs(filter.getStatus()))  
            .getSpec();  
        return purchaseOrderRepository.findAll(spec, pageable);  
    }  
  
    @UtilityClass  
    public class Specs {  
  
        Specification<PurchaseOrder> statusIs(@Nullable String status) {  
            return (root, query, builder) ->  
                builder.equal(root.get(PurchaseOrder.Fields.status), status);  
        }  
  
    }  
  
}

實現採購應用邏輯

到此,技術經理提出,現在實現的事採購應用服務,不是採購單,之所以事採購,不是採購單是因為:

  • 應用層允許跨多個領域聚合根

  • 應用層是很輕薄的一層,不會有大片的邏輯

  • 團隊發現現實世界中,只有採購部門,沒有采購單部門,採購部門掌管着採購單,以及其他採購相關資源。

  • 技術經理和團隊成員開始定義應用服務的技術手段:

    • 應用服務只負責創建或通過領域查詢服務查詢出領域對象,然後交給領域服務處理具體邏輯。
    • 應用服務的業務邏輯可以明確的分割出1,2,3步驟,如果出現複雜邏輯則沉澱到領域服務中。
    • 應用層有義務提供領域層的領域對象轉換為數據傳輸對象,即DTO,的責任。
  • 架構師開始評審應用服務的技術手段

    • 老規矩:寫好文檔,做好評審
  
@Slf4j  
@Service  
@Transactional
@RequiredArgsConstructor  
public class PurchaseService implements ApplicationService {  
  
    public static DomainError ERROR_NO_SUCH_MATERIAL = SimpleDomainError.of("no_such_material", "the materials don't exist");  
  
    private final IdentityGenerator orderNumberGenerator = new RaindropGenerator();  
  
    private final FinanceManager financeManager;  
    private final InventoryManager inventoryManager;  
    private final PurchaseOrderManager purchaseOrderManager;  
  
    private final MaterialFinder materialFinder;  
    private final PurchaseOrderFinder purchaseOrderFinder;  
  
    public PurchaseOrder createPurchaseOrder(PurchaseOrderCreateCommand command) {  
        // 1. create the entire order  
        var order = PurchaseOrder.builder()  
            .orderNumber(nextOrderNumber())  
            .items(command.getItems().toList(this::createPurchaseOrderItem))  
            .supplierId(command.getSupplierId())  
            .build();  
  
        // 2. invoke the order creation logic  
        purchaseOrderManager.createOrder(order);  
  
        return order;  
    }  
  
    public void updatePurchaseOrder(PurchaseOrderModifyCommand command) {  
        var order = purchaseOrderFinder.require(command.getOrderId());  
  
        var patch = PurchaseOrderPatch.builder()  
            .items(command.getItems().toList(this::createPurchaseOrderItem))  
            .supplierId(command.getSupplierId())  
            .build();  
  
        purchaseOrderManager.updateOrder(order, patch);  
    }  
  
    public void submitPurchaseOrder(PurchaseOrderSubmitCommand command) {  
      // ... 
    }  
  
    public void reviewPurchaseOrder(PurchaseOrderReviewCommand command) {  
       // ...
    }  
  
    public void deletePurchaseOrder(PurchaseOrderDeleteCommand command) {  
       // ...
    }  
  
    public void completePurchaseOrder(PurchaseOrderCompleteCommand command) {  
        var order = purchaseOrderFinder.require(command.getOrderId());  
  
        // 1. complete by the domain logic  
        purchaseOrderManager.complete(order);  
  
        // 2. stock in for each item in the order  
        var tracings = order.getItems()  
            .stream()  
            .map(item -> createTransaction(order, item))  
            .map(inventoryManager::stockIn)  
            .toList();  
  
        // 2. record the purchase cost  
        var cost = PurchaseCost.builder()  
            .purchaseOrderId(order.getOrderId())  
            .purchaseCost(order.getAmount())  
            .transportationCost(command.getTransportationCost())  
            .build();  
  
        financeManager.createCost(cost);  
    }  
  
}

分包結構

最後附上分包結構,有助於大家對全文的理解。

常見答疑

分許需求的時候(實時/寫操作/強一致)(異步/寫操作/最終一致)(異步/只讀) 這些都是幹什麼用的?

  • 實時/寫操作/強一致:這幾個條件幾乎都是同時出現的,出現之後通常意味着單體應用下會設計通過直接調用領域服務處理操作,且在同一個數據庫事務中。
  • 異步/寫操作/最終一致: 這種情況下,可以考慮異步寫入MQ,然後訂閲處理,通常都是業務實時性要求不高,但是數據處理量較大,異步處理有助於性能提升。
  • 異步/只讀 : 絕大多數可以説是一種通知場景,沒有同步處理的意義,所以異步操作即可以滿足提高性能,又可以分離出單獨服務有助於解耦。

為什麼領域層不分單獨的包,比如 /repository /entity ?

領域層的邏輯很複雜,複雜的東西都在這裏,難道我實現24種設計模式要創建24個包嗎?

領域層的攔截器機制會不會污染核心邏輯?

是不是核心邏輯取決於你的設計,不取決於技術手段,沒有那種設計模式能攔得住你瞎胡亂寫。

為什麼領域服務不命名為DomainService?

領域驅動是教你如何應對複雜軟件的,這個過程包括:統一語言,建模,解耦實現。但不是《程序員裝逼指南》,是哲學,不是數學。 如果你認真研究過DDD就會發現,拘泥於命名和分包而不是思想戰術的話,只會萬劫不復,墮走火入魔。

你是不是想告訴我門 DDD 不只適用於互聯網?

我想告訴你DDD不適用於互聯網。

架構師讓我們寫文檔這段,你想表達什麼?

《敏捷開發》書中説過,要寫最重要的文檔,而不是流水賬的海量文檔,這種核心設計文檔可以用來推導出所有代碼,但是海量的流水文檔一旦疏於管理,更加百害而無一益。

你還有問題嗎?可以寫到評論區。

Add a new 評論

Some HTML is okay.