领域驱动设计实战:聚合根设计与领域模型实现

笔记哥 / 05-09 / 47点赞 / 0评论 / 847阅读
## **附:源代码参考** - [Eleven 低成本可落地的 DDD 技术方案脚手架](https://github.com/c5ms/eleven) - [现代化领域驱动设计原书示例](https://github.com/c5ms/modern-ddd-cargotracker) ## 背景介绍 我清楚的知道一点,其实大家如果上网找文章,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,来区分领域服务和应用服务 - 让领域服务来之行领域模型业务的代理,同时管理领域事件,这一点是**因为一些技术条件限制** - **架构师开始评审技范围和技术手段** - 要求团队将实现手段写成文档,作为核心技术文档好好保管 - 要求代码评审的时候按照原则逐条评审,不允许有人轻易破坏规则 - 提出了很多其他技术管理需求 ### 实现采购单领域模型 在定义了上述技术手段之后,开始落地实现,首先定义**采购单聚合根**(只展示部分核心代码): ```java @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 items = new ArrayList<>(); @Builder @SuppressWarnings("unused") private PurchaseOrder(String orderNumber, Long supplierId, Collection 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 getItems() { return ImmutableValues.of(items); } private void calculateAmount() { this.amount = 0D; for (PurchaseOrderItem item : this.getItems()) { this.amount += item.getAmount(); } } void setItems(List 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机制来满足扩展性。 ```java /** * 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 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); } } ``` 接下来团队为采购单实现了领域的查询服务: ```java @Slf4j @Component @RequiredArgsConstructor public class PurchaseOrderFinder implements DomainFinder { private final PurchaseOrderRepository purchaseOrderRepository; public Optional get(Long orderId) { return purchaseOrderRepository.findById(orderId); } public PurchaseOrder require(Long orderId) throws NoDomainEntityException { return purchaseOrderRepository.findById(orderId).orElseThrow(NoDomainEntityException::instance); } public Page 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 statusIs(@Nullable String status) { return (root, query, builder) -> builder.equal(root.get(PurchaseOrder.Fields.status), status); } } } ``` ### 实现采购应用逻辑 到此,技术经理提出,现在实现的事**采购**应用服务,不是**采购单**,之所以事采购,不是采购单是因为: - 应用层允许跨多个领域聚合根 - 应用层是很轻薄的一层,不会有大片的逻辑 - 团队发现现实世界中,只有采购部门,没有采购单部门,采购部门掌管着采购单,以及其他采购相关资源。 - **技术经理和团队成员开始定义应用服务的技术手段:** - 应用服务只负责创建或通过领域查询服务查询出领域对象,然后交给领域服务处理具体逻辑。 - 应用服务的业务逻辑可以明确的分割出1,2,3步骤,如果出现复杂逻辑则沉淀到领域服务中。 - 应用层有义务提供领域层的领域对象转换为数据传输对象,即DTO,的责任。 - **架构师开始评审应用服务的技术手段**: - 老规矩:写好文档,做好评审 ```java @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); } } ``` ### 分包结构 最后附上分包结构,有助于大家对全文的理解。 ![](https://cdn.res.knowhub.vip/c/2505/10/612237f1.png?G1QAAOTcVkxwPye2iOF0Q2CDZkCiiqBSz3q956x9A3x%2fMLLmZ7Q%2bY3%2f4Q%2bszQKq5VWBkQ0PySCyGwlQ9SdGLiqPmNQI%3d) ## 常见答疑 **分许需求的时候(实时/写操作/强一致)(异步/写操作/最终一致)(异步/只读) 这些都是干什么用的?** - **实时/写操作/强一致**:这几个条件几乎都是同时出现的,出现之后通常意味着单体应用下会设计通过直接调用领域服务处理操作,且在同一个数据库事务中。 - **异步/写操作/最终一致**: 这种情况下,可以考虑异步写入MQ,然后订阅处理,通常都是业务实时性要求不高,但是数据处理量较大,异步处理有助于性能提升。 - **异步/只读** : 绝大多数可以说是一种通知场景,没有同步处理的意义,所以异步操作即可以满足提高性能,又可以分离出单独服务有助于解耦。 **为什么领域层不分单独的包,比如 /repository /entity ?** > > > 领域层的逻辑很复杂,复杂的东西都在这里,难道我实现24种设计模式要创建24个包吗? > **领域层的拦截器机制会不会污染核心逻辑?** > > > 是不是核心逻辑取决于你的设计,不取决于技术手段,没有那种设计模式能拦得住你瞎胡乱写。 > **为什么领域服务不命名为DomainService?** > > > 领域驱动是教你如何应对复杂软件的,这个过程包括:统一语言,建模,解耦实现。但不是《程序员装逼指南》,是哲学,不是数学。 如果你认真研究过DDD就会发现,拘泥于命名和分包而不是思想战术的话,只会万劫不复,堕走火入魔。 > **你是不是想告诉我门 DDD 不只适用于互联网?** > > > 我想告诉你DDD不适用于互联网。 > **架构师让我们写文档这段,你想表达什么?** > > > 《敏捷开发》书中说过,要写最重要的文档,而不是流水账的海量文档,这种核心设计文档可以用来推导出所有代码,但是海量的流水文档一旦疏于管理,更加百害而无一益。