ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

DDD分层架构实战:解决微服务分布式单体问题

DDD分层架构实战:解决微服务分布式单体问题 1. 从一次重构事故说起为什么我又把DDD翻了出来去年年底我接手了一个跑了三年多的订单系统。表面上看它是个微服务架构拆了七八个服务每个服务独立部署、独立数据库CI/CD流水线也跑得挺顺。但真正进去改需求的时候我整个人是懵的一个简单的下单送积分逻辑代码从订单服务跳到积分服务又从积分服务回调订单服务中间还夹着一个公共工具包里不知道谁写的OrderHelper里面塞了两千多行代码订单、库存、支付、积分的逻辑全搅在一起。改一个字段三个服务跟着编译报错。这不是微服务这是分布式单体。我花了大概两周时间做重构核心思路就是把领域驱动设计DDD的分层架构真正落地进去。重构完之后同样的需求改动从原来平均两天缩短到半天服务之间的依赖关系从一张蜘蛛网变成了一棵清晰的树。这篇文章就是那次重构的完整复盘我会把DDD分层架构的核心思路、每一层的职责边界、依赖倒置怎么用、代码怎么组织、踩过哪些坑全部摊开讲清楚。如果你正在做微服务拆分或者手上的微服务已经出现了改一处动全身的症状又或者你听说过DDD但一直没搞明白它到底怎么落到代码里那这篇内容应该能帮到你。我不打算讲太多学院派的理论而是从一个一线开发者的角度把DDD分层架构拆成可以直接抄的工程实践。2. DDD分层架构到底在解决什么问题2.1 微服务拆分的常见误区按技术分层而不是按业务分很多人做微服务拆分的时候第一反应是按技术职责来切一个服务管数据库访问一个服务管业务逻辑一个服务管对外接口。这种切法在项目初期看起来挺整齐但它有个致命问题——业务逻辑被切碎了。举个例子用户下单这个业务动作它天然需要校验库存、计算价格、生成订单、扣减余额。如果你按技术分层拆校验库存在数据服务、计算价格在逻辑服务、生成订单在业务服务那一个完整的业务动作就被拆到了三个服务里。每次改需求你都得同时改三个服务还要保证它们之间的调用顺序不出错。这就是典型的分布式单体——物理上拆开了逻辑上还绑在一起。DDD的思路完全相反按业务能力拆分每个服务是一个完整的业务闭环。订单服务就管订单相关的所有事情从接收请求到落库全在它自己内部完成。它需要库存信息通过定义好的接口去问库存服务要而不是把库存的逻辑搬到自己这里。2.2 分层架构的本质让变化被隔离在最小范围内DDD分层架构最核心的价值用一句话概括就是让每一层只关心自己的事变化被隔离在最小范围内。传统的三层架构Controller-Service-DAO其实也有分层但它的分层是按技术职责切的业务逻辑全部堆在Service层。结果就是Service层越来越臃肿最后变成一个什么都往里塞的上帝类。DDD的分层不一样它把系统分成四层层次职责变化频率依赖方向用户接口层接收请求、参数校验、返回响应高依赖应用层应用层编排业务流程、事务控制中依赖领域层领域层核心业务逻辑、领域模型低不依赖任何层基础设施层数据库、消息队列、外部服务中实现领域层定义的接口关键点在于领域层不依赖任何其他层。它是整个系统的核心包含了最纯粹的业务逻辑。其他所有层都是为它服务的。这样设计的好处是当数据库从MySQL换成PostgreSQL或者消息队列从RabbitMQ换成Kafka领域层的代码一行都不用改。2.3 依赖倒置让核心业务不被技术细节绑架依赖倒置原则Dependency Inversion Principle是DDD分层架构能成立的关键。它的核心思想是高层模块不应该依赖低层模块两者都应该依赖抽象。在传统架构里业务逻辑直接调用数据库操作比如orderService.save(order)直接调orderDao.insert(order)。这意味着业务逻辑依赖了具体的数据库实现。如果哪天要换数据库业务逻辑就得跟着改。DDD的做法是领域层定义一个接口OrderRepository声明我需要一个能保存订单的东西但不关心这个东西具体怎么实现。基础设施层去实现这个接口用MySQL也好用MongoDB也好领域层完全不知道。这就是依赖倒置——抽象不依赖细节细节依赖抽象。// 领域层定义接口不关心实现 public interface OrderRepository { void save(Order order); Order findById(OrderId orderId); } // 基础设施层实现接口关心具体技术 Repository public class OrderRepositoryImpl implements OrderRepository { Autowired private OrderJpaMapper orderJpaMapper; Override public void save(Order order) { OrderPO po convertToPO(order); orderJpaMapper.insert(po); } Override public Order findById(OrderId orderId) { OrderPO po orderJpaMapper.selectById(orderId.getValue()); return convertToDomain(po); } }这段代码看起来简单但它带来的灵活性是巨大的。测试的时候我可以给领域层注入一个内存实现的OrderRepository不需要启动数据库就能跑单元测试。换数据库的时候只需要写一个新的实现类领域层纹丝不动。3. 四层架构的职责边界与代码组织3.1 用户接口层只做转换不做业务用户接口层User Interface Layer的职责非常明确接收外部请求转换成应用层能理解的输入然后把应用层的输出转换成外部需要的格式返回。这一层最容易犯的错误是把业务逻辑写进来。比如在Controller里直接判断如果订单金额大于100就免运费这就是把业务逻辑泄露到了接口层。正确的做法是把这个判断放到领域层Controller只负责把请求参数传给应用层。RestController RequestMapping(/orders) public class OrderController { Autowired private OrderApplicationService orderApplicationService; PostMapping public ResultOrderDTO createOrder(RequestBody CreateOrderRequest request) { // 只做参数转换不写业务逻辑 CreateOrderCommand command new CreateOrderCommand( request.getUserId(), request.getItems(), request.getAddress() ); OrderDTO orderDTO orderApplicationService.createOrder(command); return Result.success(orderDTO); } }这一层还需要处理一些横切关注点比如参数校验、异常处理、日志记录。但这些都应该通过注解或AOP来完成而不是把代码写进业务方法里。3.2 应用层编排流程不写业务规则应用层Application Layer是很多人容易搞混的一层。它的职责是编排不是实现。它负责把领域层的多个领域对象组合起来完成一个完整的用例。举个例子创建订单这个用例应用层需要做的是调用领域服务创建订单对象调用库存服务扣减库存调用支付服务发起支付保存订单发布订单创建事件注意应用层只是调用这些操作具体的业务规则比如订单金额怎么算、库存够不够都在领域层实现。Service public class OrderApplicationService { Autowired private OrderDomainService orderDomainService; Autowired private InventoryService inventoryService; Autowired private PaymentService paymentService; Autowired private OrderRepository orderRepository; Autowired private DomainEventPublisher eventPublisher; Transactional public OrderDTO createOrder(CreateOrderCommand command) { // 1. 调用领域服务创建订单 Order order orderDomainService.createOrder( command.getUserId(), command.getItems(), command.getAddress() ); // 2. 扣减库存 inventoryService.deduct(order.getItems()); // 3. 发起支付 paymentService.pay(order); // 4. 保存订单 orderRepository.save(order); // 5. 发布领域事件 eventPublisher.publish(new OrderCreatedEvent(order.getId())); return convertToDTO(order); } }应用层还有一个重要职责是事务控制。在上面的代码里Transactional注解加在应用层方法上因为只有应用层才知道一个完整的用例包含哪些操作需要保证哪些操作在同一个事务里。3.3 领域层业务逻辑的唯一归属地领域层Domain Layer是整个系统的核心包含了所有的业务规则和业务逻辑。这一层的代码应该是最纯粹的不依赖任何框架、任何技术细节。领域层通常包含以下几类元素实体Entity有唯一标识的业务对象比如订单、用户、商品。实体的状态会变化但标识不变。public class Order { private OrderId id; private UserId userId; private ListOrderItem items; private OrderStatus status; private Money totalAmount; // 业务方法计算总金额 public Money calculateTotalAmount() { return items.stream() .map(OrderItem::getSubTotal) .reduce(Money.ZERO, Money::add); } // 业务方法确认订单 public void confirm() { if (this.status ! OrderStatus.CREATED) { throw new OrderStatusException(只有已创建的订单才能确认); } this.status OrderStatus.CONFIRMED; } // 业务方法取消订单 public void cancel() { if (this.status OrderStatus.SHIPPED) { throw new OrderStatusException(已发货的订单不能取消); } this.status OrderStatus.CANCELLED; } }值对象Value Object没有唯一标识通过属性值来判断相等性的对象比如金额、地址、颜色。public class Money { private final BigDecimal amount; private final String currency; public Money(BigDecimal amount, String currency) { this.amount amount; this.currency currency; } public Money add(Money other) { if (!this.currency.equals(other.currency)) { throw new IllegalArgumentException(不能对不同币种进行加法运算); } return new Money(this.amount.add(other.amount), this.currency); } // 值对象是不可变的所有修改操作都返回新对象 }领域服务Domain Service当某个业务逻辑不属于任何一个实体或值对象时把它放到领域服务里。比如创建订单这个操作它需要协调多个对象不适合放在Order实体里。领域事件Domain Event领域中发生的、有意义的事情比如订单已创建、支付已完成。领域事件可以用来解耦不同的领域对象。3.4 基础设施层技术细节的实现者基础设施层Infrastructure Layer负责所有技术相关的实现数据库访问、消息队列、缓存、外部服务调用等。它的核心原则是实现领域层定义的接口而不是被领域层调用。这一层最常见的实现是Repository模式。领域层定义Repository接口基础设施层提供具体实现。除了Repository基础设施层还包括消息队列的发送和接收缓存的读写外部服务的HTTP调用文件存储定时任务Repository public class OrderRepositoryImpl implements OrderRepository { Autowired private OrderMapper orderMapper; Autowired private OrderItemMapper orderItemMapper; Override public void save(Order order) { OrderPO orderPO OrderConverter.toPO(order); orderMapper.insert(orderPO); ListOrderItemPO itemPOs order.getItems().stream() .map(OrderConverter::toPO) .collect(Collectors.toList()); orderItemMapper.batchInsert(itemPOs); } Override public Order findById(OrderId orderId) { OrderPO orderPO orderMapper.selectById(orderId.getValue()); if (orderPO null) { return null; } ListOrderItemPO itemPOs orderItemMapper.selectByOrderId(orderId.getValue()); return OrderConverter.toDomain(orderPO, itemPOs); } }注意这里的转换逻辑数据库的PO对象和领域的Order对象是分开的。这样做的好处是领域对象不需要关心数据库表结构数据库表结构变化时只需要改Converter领域对象不受影响。4. 从零搭建一个DDD分层架构的订单服务4.1 项目结构规划按层分包还是按业务分包在动手写代码之前先要决定项目结构怎么组织。常见的做法有两种按层分包所有实体放一个包所有Repository放一个包所有Service放一个包。com.example.order ├── interfaces │ ├── controller │ └── dto ├── application │ ├── service │ └── command ├── domain │ ├── entity │ ├── valueobject │ ├── service │ └── repository └── infrastructure ├── persistence └── messaging按业务分包每个业务模块一个包包内再分层。com.example.order ├── order │ ├── interfaces │ ├── application │ ├── domain │ └── infrastructure ├── payment │ ├── interfaces │ ├── application │ ├── domain │ └── infrastructure我的建议是单体应用按层分包微服务按业务分包。因为微服务本身已经是一个业务边界了服务内部再按业务分包意义不大。而且按层分包更符合DDD的分层理念依赖关系一目了然。4.2 领域层建模先画事件风暴再写代码领域层建模是整个DDD落地过程中最关键的一步。我的做法是先组织一次事件风暴Event Storming把业务专家和开发人员拉到一起用便利贴把业务流程贴出来。事件风暴的核心产出是领域事件业务中发生的关键事情用过去式命名比如订单已创建、库存已扣减命令触发领域事件的动作比如创建订单、扣减库存聚合一组紧密相关的领域对象的集合比如订单聚合包含Order和OrderItem限界上下文业务边界的划分每个上下文对应一个微服务以订单服务为例事件风暴的产出可能是这样的命令聚合领域事件创建订单订单订单已创建确认订单订单订单已确认取消订单订单订单已取消支付订单订单订单已支付有了这些领域层的代码就有了骨架。每个聚合对应一个实体类每个命令对应一个领域方法每个领域事件对应一个事件类。4.3 聚合根设计一致性边界怎么划聚合Aggregate是DDD里最重要的概念之一。一个聚合是一组相关对象的集合它们作为一个整体被修改。每个聚合有一个聚合根Aggregate Root外部只能通过聚合根来访问聚合内的对象。聚合设计的核心是一致性边界哪些对象必须在同一个事务里保持一致这些对象就应该放在同一个聚合里。以订单为例Order和OrderItem必须在同一个事务里保持一致——订单创建时订单项也必须同时创建。所以Order是聚合根OrderItem是聚合内的实体。public class Order { private OrderId id; private ListOrderItem items; private OrderStatus status; // 聚合根负责维护聚合内的一致性 public void addItem(ProductId productId, int quantity, Money price) { if (this.status ! OrderStatus.CREATED) { throw new OrderStatusException(只有已创建的订单才能添加商品); } OrderItem item new OrderItem(productId, quantity, price); this.items.add(item); } public void removeItem(OrderItemId itemId) { if (this.status ! OrderStatus.CREATED) { throw new OrderStatusException(只有已创建的订单才能删除商品); } this.items.removeIf(item - item.getId().equals(itemId)); } }聚合设计的一个常见误区是聚合过大。有人把User、Order、Product都放在一个聚合里结果就是每次操作都要加载大量数据性能极差。正确的做法是聚合尽量小只包含必须保持一致的对象。Order和User之间通过ID关联而不是对象引用。4.4 仓储实现领域层定义接口基础设施层实现仓储Repository是领域层和基础设施层之间的桥梁。领域层定义接口基础设施层实现。这个模式看起来简单但有几个细节需要注意。第一仓储的接口应该用领域语言。不要用insert、update、delete这种数据库术语而应该用save、findById、remove这种领域术语。第二仓储的返回值应该是领域对象而不是数据库的PO对象。领域层不应该知道PO的存在。第三仓储的实现要考虑性能。比如查询订单时是否需要同时加载订单项如果每次查询都加载所有订单项性能会很差。我的做法是提供两个方法findById加载完整聚合findSummaryById只加载订单基本信息。// 领域层接口 public interface OrderRepository { void save(Order order); Order findById(OrderId orderId); OrderSummary findSummaryById(OrderId orderId); ListOrder findByUserId(UserId userId); } // 基础设施层实现 Repository public class OrderRepositoryImpl implements OrderRepository { Override public Order findById(OrderId orderId) { OrderPO orderPO orderMapper.selectById(orderId.getValue()); ListOrderItemPO itemPOs orderItemMapper.selectByOrderId(orderId.getValue()); return OrderConverter.toDomain(orderPO, itemPOs); } Override public OrderSummary findSummaryById(OrderId orderId) { OrderPO orderPO orderMapper.selectById(orderId.getValue()); return OrderConverter.toSummary(orderPO); } }4.5 应用服务编排事务边界与领域事件发布应用服务是编排层它负责把领域层的操作组合成一个完整的用例。在编排过程中有两个关键点需要处理好事务边界和领域事件发布。事务边界应该划在应用层因为只有应用层知道一个用例包含哪些操作。在上面的createOrder方法里Transactional注解保证了创建订单、扣减库存、保存订单这三个操作在同一个事务里。领域事件发布有两种时机事务提交前和事务提交后。事务提交前发布的问题是如果事务回滚了事件已经发出去了会导致数据不一致。事务提交后发布的问题是如果发布失败事件就丢了。我的做法是使用事务同步机制在事务提交后发布事件Service public class OrderApplicationService { Autowired private ApplicationEventPublisher eventPublisher; Transactional public OrderDTO createOrder(CreateOrderCommand command) { Order order orderDomainService.createOrder(command); orderRepository.save(order); // 注册事务同步回调事务提交后发布事件 TransactionSynchronizationManager.registerSynchronization( new TransactionSynchronization() { Override public void afterCommit() { eventPublisher.publishEvent(new OrderCreatedEvent(order.getId())); } } ); return convertToDTO(order); } }5. 依赖倒置在微服务架构中的实战应用5.1 服务间调用定义防腐层隔离外部变化在微服务架构里服务之间需要互相调用。如果直接在订单服务里写调用库存服务的代码订单服务就依赖了库存服务的具体实现。库存服务的接口一变订单服务就得跟着改。DDD的解决方案是防腐层Anti-Corruption Layer。订单服务定义一个InventoryService接口声明我需要一个能扣减库存的东西然后在基础设施层实现这个接口去调用库存服务的HTTP接口。// 领域层定义接口 public interface InventoryService { void deduct(ListOrderItem items); void restore(ListOrderItem items); } // 基础设施层实现接口调用外部服务 Component public class InventoryServiceImpl implements InventoryService { Autowired private InventoryFeignClient inventoryFeignClient; Override public void deduct(ListOrderItem items) { DeductRequest request new DeductRequest(); request.setItems(items.stream() .map(item - new DeductItem(item.getProductId(), item.getQuantity())) .collect(Collectors.toList())); ResultVoid result inventoryFeignClient.deduct(request); if (!result.isSuccess()) { throw new InventoryDeductException(result.getMessage()); } } }这样设计的好处是库存服务的接口变了只需要改InventoryServiceImpl领域层和应用层完全不受影响。而且测试的时候可以给领域层注入一个Mock实现不需要启动库存服务就能跑测试。5.2 领域事件驱动用事件解耦跨服务协作除了直接调用微服务之间还可以通过领域事件来协作。比如订单创建后需要通知积分服务给用户加积分。如果直接在订单服务里调用积分服务订单服务就依赖了积分服务。用事件驱动的方式订单服务只需要发布一个订单已创建事件积分服务订阅这个事件自己决定怎么处理。// 订单服务发布事件 Service public class OrderApplicationService { Autowired private DomainEventPublisher eventPublisher; Transactional public OrderDTO createOrder(CreateOrderCommand command) { Order order orderDomainService.createOrder(command); orderRepository.save(order); eventPublisher.publish(new OrderCreatedEvent( order.getId().getValue(), order.getUserId().getValue(), order.getTotalAmount() )); return convertToDTO(order); } } // 积分服务订阅事件 Component public class OrderCreatedEventListener { Autowired private PointApplicationService pointApplicationService; EventListener public void handle(OrderCreatedEvent event) { pointApplicationService.addPoints( event.getUserId(), calculatePoints(event.getTotalAmount()) ); } }事件驱动的好处是解耦订单服务不需要知道积分服务的存在积分服务挂了也不影响订单创建。但代价是最终一致性订单创建成功后积分可能过几秒才到账。这个 trade-off 需要根据业务场景来权衡。5.3 接口定义的艺术用领域语言而不是技术语言依赖倒置的关键是定义好接口。接口定义得好系统就灵活定义得不好还不如直接调用。我的经验是接口要用领域语言而不是技术语言。比如好的接口inventoryService.deduct(items)——用业务术语扣减库存不好的接口inventoryService.updateStock(productId, quantity)——用技术术语更新库存再比如好的接口paymentService.pay(order)——用业务术语支付不好的接口paymentService.createPaymentRecord(orderId, amount)——用技术术语创建支付记录用领域语言定义接口的好处是接口的语义更清晰而且不容易被技术实现绑架。如果哪天支付方式从微信支付换成支付宝pay这个接口不需要变只需要换一个实现。6. 踩坑实录DDD落地过程中最常见的五个问题6.1 贫血模型把领域层写成了数据容器这是DDD落地过程中最常见的问题。很多人把领域层写成了这样// 贫血模型只有getter/setter没有业务逻辑 public class Order { private Long id; private Long userId; private ListOrderItem items; private Integer status; // 只有getter/setter }所有的业务逻辑都写在了Service里领域对象变成了纯粹的数据容器。这就是贫血模型它违背了DDD的初衷。正确的做法是把业务逻辑放到领域对象里// 充血模型业务逻辑在领域对象里 public class Order { private OrderId id; private UserId userId; private ListOrderItem items; private OrderStatus status; public void confirm() { if (this.status ! OrderStatus.CREATED) { throw new OrderStatusException(只有已创建的订单才能确认); } this.status OrderStatus.CONFIRMED; } public Money calculateTotalAmount() { return items.stream() .map(OrderItem::getSubTotal) .reduce(Money.ZERO, Money::add); } }判断一个领域对象是不是充血模型有个简单的标准如果把这个对象的所有getter/setter去掉它还剩下什么如果什么都不剩那就是贫血模型。6.2 聚合过大一个聚合加载了整张数据库表聚合设计的一个常见错误是聚合过大。有人把Order、OrderItem、OrderLog、OrderAttachment都放在一个聚合里结果每次加载订单都要加载所有关联数据性能极差。聚合的设计原则是只把必须保持一致的对象放在同一个聚合里。OrderLog和OrderAttachment不需要和Order保持强一致它们可以独立存在通过OrderId关联。// 不好的设计聚合过大 public class Order { private OrderId id; private ListOrderItem items; private ListOrderLog logs; // 不需要强一致 private ListOrderAttachment files; // 不需要强一致 } // 好的设计聚合精简 public class Order { private OrderId id; private ListOrderItem items; // 必须和Order强一致 } // OrderLog和OrderAttachment独立存在 public class OrderLog { private OrderId orderId; // 通过ID关联 // ... }6.3 领域事件滥用把事件当成了异步调用的万能药领域事件是个好东西但滥用会带来问题。有人把所有的跨服务调用都改成了事件驱动结果系统变得难以调试一个请求发出去不知道会触发哪些事件也不知道事件的处理顺序。我的经验是只有真正需要解耦的场景才用事件。比如订单创建后通知积分服务加积分——适合用事件因为积分到账可以延迟订单创建前校验库存——不适合用事件因为库存校验必须同步完成另外事件驱动会带来最终一致性问题。如果业务不能接受最终一致就不要用事件。6.4 仓储泄露领域层直接依赖了JPA这个问题在Spring Data JPA项目里特别常见。有人直接在领域层定义JpaRepository结果领域层依赖了Spring Data JPA。// 不好的设计领域层依赖JPA public interface OrderRepository extends JpaRepositoryOrderPO, Long { // ... }正确的做法是领域层定义纯粹的接口基础设施层实现// 领域层纯粹接口 public interface OrderRepository { void save(Order order); Order findById(OrderId orderId); } // 基础设施层JPA实现 Repository public class OrderRepositoryImpl implements OrderRepository { Autowired private OrderJpaRepository orderJpaRepository; Override public void save(Order order) { OrderPO po OrderConverter.toPO(order); orderJpaRepository.save(po); } }6.5 过度设计小项目硬套DDDDDD不是银弹不是所有项目都适合。如果一个项目只有几个简单的CRUD接口硬套DDD只会增加复杂度。我的判断标准是如果业务逻辑复杂到需要和业务专家反复沟通才能理清那就适合DDD如果业务逻辑简单到看一眼需求文档就能写代码那就不需要DDD。对于简单项目传统的三层架构反而更高效。DDD的价值在于管理复杂性如果本身不复杂DDD就是过度设计。7. 一些实操心得和工具推荐7.1 从单体到微服务的渐进式拆分策略如果你手上是一个单体应用想往DDD微服务架构迁移我的建议是渐进式拆分不要一次性全拆。第一步在单体应用内部按DDD分层重构把业务逻辑从Service层下沉到领域层。这一步不需要拆服务但能让代码结构变清晰。第二步识别限界上下文把关联最紧密的模块先拆出来。比如订单和订单项关联紧密先拆订单服务用户和权限关联紧密先拆用户服务。第三步服务拆出来后通过防腐层隔离服务间的依赖。先用同步调用等稳定了再考虑事件驱动。第四步持续重构逐步把剩下的模块拆完。7.2 代码审查清单怎么判断DDD落地是否到位每次代码审查的时候我会用下面这个清单来检查DDD落地情况检查项合格标准常见问题领域层依赖不依赖任何框架领域层出现Autowired业务逻辑位置在领域对象里业务逻辑写在Service里聚合大小只包含强一致对象聚合加载了整张表仓储接口用领域语言命名用insert/update命名领域事件只在需要解耦时使用所有调用都改成事件应用层职责只编排不实现应用层写了业务规则7.3 测试策略领域层单元测试怎么写DDD分层架构的一个好处是领域层可以独立测试。因为领域层不依赖任何框架测试的时候不需要启动Spring容器直接new对象就能测。public class OrderTest { Test public void should_throw_exception_when_confirm_shipped_order() { // given Order order new Order(new OrderId(1L), new UserId(1L)); order.confirm(); order.ship(); // when then assertThrows(OrderStatusException.class, () - order.confirm()); } Test public void should_calculate_correct_total_amount() { // given Order order new Order(new OrderId(1L), new UserId(1L)); order.addItem(new ProductId(1L), 2, new Money(10, CNY)); order.addItem(new ProductId(2L), 1, new Money(20, CNY)); // when Money total order.calculateTotalAmount(); // then assertEquals(new Money(40, CNY), total); } }这种测试跑起来非常快几百个测试几秒钟就能跑完。而且因为不依赖数据库测试结果很稳定不会出现在我机器上能跑的问题。7.4 常用工具和框架推荐最后推荐几个我在DDD落地过程中常用的工具建模工具Event Storming用便利贴就行远程协作可以用Miro或Excalidraw。代码框架Spring Boot Spring Data JPA是主流选择。如果追求更纯粹的DDD可以考虑Axon Framework它内置了聚合、事件溯源等DDD概念。架构守护ArchUnit可以用来做架构约束检查比如领域层不能依赖基础设施层这种规则可以用ArchUnit写成测试用例CI的时候自动检查。AnalyzeClasses(packages com.example.order) public class ArchitectureTest { ArchTest public static final ArchRule domain_should_not_depend_on_infrastructure noClasses().that().resideInAPackage(..domain..) .should().dependOnClassesThat().resideInAPackage(..infrastructure..); ArchTest public static final ArchRule domain_should_not_depend_on_spring noClasses().that().resideInAPackage(..domain..) .should().dependOnClassesThat().resideInAPackage(org.springframework..); }这两个规则加上之后CI会自动拦截违反分层架构的代码提交。我实测下来很稳团队里再也没人把Autowired写到领域层了。事件溯源如果业务需要完整的审计日志可以考虑事件溯源Event Sourcing。不过事件溯源会显著增加系统复杂度除非业务明确需要否则不建议一开始就上。8. 最后再分享几个小技巧关于领域对象的ID设计我踩过一个坑。一开始我用数据库自增ID作为领域对象的ID结果领域层就依赖了数据库。后来改成用UUID或者雪花算法生成的ID领域层就完全独立了。如果你也在用自增ID建议尽早改成应用层生成的ID。关于DTO和领域对象的转换我建议用MapStruct而不是手写Converter。手写Converter代码量大而且容易漏字段。MapStruct在编译期生成转换代码性能好而且字段不匹配的时候编译就会报错。关于领域事件的命名我建议用过去式比如OrderCreatedEvent而不是CreateOrderEvent。过去式表示已经发生的事情语义更准确。而且事件是不可变的不应该有setter。关于聚合根的引用我建议用ID引用而不是对象引用。比如Order引用User应该用UserId而不是User对象。这样聚合之间就是松耦合的加载Order的时候不需要加载User。关于应用层的返回值我建议返回DTO而不是领域对象。领域对象包含业务逻辑不应该暴露给外部。而且领域对象的结构可能会变化DTO可以保持稳定。这些技巧都是我在实际项目中踩坑总结出来的不一定适用于所有场景但至少可以帮你少走一些弯路。DDD落地是个持续迭代的过程不要指望一次就做到完美先跑起来再慢慢优化。
返回列表