ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

DDD 重构七步实录:从一段下单代码开始拆

DDD 重构七步实录:从一段下单代码开始拆 这是一次真实的下单流程重构记录。起点是最普通的那种代码——Controller 收参数、Service 里塞逻辑、实体干干净净只剩 getter/setter终点则用领域事件收口。先说句得罪人的话这七步的性价比差得很远。有的是做完再也回不去的刚需有的纯粹是给项目添堵、给简历加分。所以别把它当成 checklist 从第一行抄到最后一行把它当成一张地图——每一站我都讲清楚它治什么病、要付什么代价你按自己项目的疼点决定在哪站下车。基线一段挑不出错的代码先贴一下动手前的代码。它跟线上版本基本一致我只删了几个和这次重构无关的分支Service Transactional public class OrderServiceImpl implements OrderService { Autowired private OrderMapper orderMapper; Autowired private ProductMapper productMapper; Autowired private PaymentClient paymentClient; Autowired private NotificationClient notificationClient; Override public Long createOrder(CreateOrderRequest req) { // 1. 校验 if (req.getUserId() null || req.getItems().isEmpty()) throw new IllegalArgumentException(参数错误); // 2. 算价 查库存 BigDecimal total BigDecimal.ZERO; ListOrderItem orderItems new ArrayList(); for (CreateOrderRequest.Item item : req.getItems()) { Product p productMapper.selectById(item.getProductId()); if (p null) throw new RuntimeException(商品不存在); if (p.getStock() item.getQuantity()) throw new RuntimeException(库存不足: p.getName()); OrderItem oi new OrderItem(); oi.setProductId(p.getId()); oi.setQuantity(item.getQuantity()); oi.setPrice(p.getPrice()); total total.add(p.getPrice().multiply(BigDecimal.valueOf(item.getQuantity()))); orderItems.add(oi); p.setStock(p.getStock() - item.getQuantity()); // 直接扣 productMapper.updateById(p); } // 3. 建订单 Order order new Order(); order.setUserId(req.getUserId()); order.setItems(orderItems); order.setTotalAmount(total); order.setStatus(UNPAID); // 魔法字符串 order.setCreateTime(new Date()); orderMapper.insert(order); // 4. 调支付 PaymentResult r paymentClient.pay(order.getId(), total); if (!r.isSuccess()) { // 支付失败 → 库存已经扣了得补偿这里只抛异常事务回滚也不一定救得回库存 throw new RuntimeException(支付失败); } // 5. 更新状态 orderMapper.updateStatus(order.getId(), PAID); notificationClient.sendOrderConfirmation(order.getId(), req.getUserId()); return order.getId(); } }第一次 review 的时候我甚至觉得“这写得挺规范”。问题恰恰就在这儿它规范但它经不起改。具体别扭在哪摆出来你就知道后面每一步是冲谁去的order.setStatus(PAID)是个裸字符串谁都能调。哪天手滑写成paid编译器一声不吭等订单状态发疯了才发现。库存扣减直接写在OrderService里。哪天要做预售——下单只预占、发货才真扣——这段逻辑你只能复制出去改。支付失败的补偿全靠throw 事务回滚。本地库还凑合一旦掺进远程调用或者第二个数据源回滚根本兜不住已经扣掉的库存。业务规则散在if里if (status.equals(UNPAID))这种。想改一条得先把整个方法读完还容易漏。Step 2把行为还给对象这一步改动最便宜别让 Service 去setStatus了状态怎么变是 Order 自己的事。public enum OrderStatus { UNPAID, PAID, CANCELED, SHIPPED } Entity public class Order { private Long id; private Long userId; private ListOrderItem items; private BigDecimal totalAmount; private OrderStatus status; // 枚举替代字符串 private Date createTime; public Order(Long userId, ListOrderItem items, BigDecimal total) { this.userId userId; this.items items; this.totalAmount total; this.status OrderStatus.UNPAID; this.createTime new Date(); } /** 支付 */ public void pay() { if (this.status ! OrderStatus.UNPAID) throw new IllegalStateException(只有未支付的订单才能支付); this.status OrderStatus.PAID; } /** 取消 */ public void cancel() { if (this.status OrderStatus.SHIPPED) throw new IllegalStateException(已发货的订单无法取消); if (this.status OrderStatus.CANCELED) throw new IllegalStateException(订单已取消无需重复操作); this.status OrderStatus.CANCELED; } public boolean canCancel() { return this.status OrderStatus.UNPAID || this.status OrderStatus.PAID; } // 只有 getter没有 setter }Service 里的调用从order.setStatus(PAID)变成order.pay()。刚改完我其实有点不以为然——不就是把一堆 setter 换成带名字的方法吗后来才回过味来这一下顺手做成了两件事一是状态机有了唯一入口规则跟着状态走不会再到处漏二是代码开始说人话了cancel()这种词产品也能听懂。它对应 DDD 里的统一语言和充血模型。性价比高得离谱不改架构、不加层次、不引依赖单体项目当天就能用。整篇只做一件事的话就做它。Step 3把 Order 立成聚合根上一步之后还留了个洞order.getItems().get(0).setQuantity(999)依然能编译通过。OrderItem 裸在外面随便谁都能改改完还不重算总价。办法是把 Order 定义成聚合根OrderItem 是聚合内的实体外面只能通过 Order 暴露的方法动手。Entity public class Order { // ... 其他同上 /** 外部只读不能改集合 */ public ListOrderItem getItems() { return Collections.unmodifiableList(items); } /** 改商品数量 —— 只能在 UNPAID 状态 */ public void changeItemQuantity(Long productId, int newQty) { if (this.status ! OrderStatus.UNPAID) throw new IllegalStateException(只能修改未支付订单的商品数量); OrderItem item findItem(productId); if (item null) throw new IllegalArgumentException(商品不在订单中); item.setQuantity(newQty); // OrderItem 的 setQuantity 收成包级私有 recalcTotal(); } /** 移除商品 */ public void removeItem(Long productId) { if (this.status ! OrderStatus.UNPAID) throw new IllegalStateException(只能修改未支付订单); items.removeIf(i - i.getProductId().equals(productId)); recalcTotal(); } private void recalcTotal() { this.totalAmount items.stream() .map(i - i.getPrice().multiply(BigDecimal.valueOf(i.getQuantity()))) .reduce(BigDecimal.ZERO, BigDecimal::add); } private OrderItem findItem(Long pid) { /* ... */ } }Service 里的调用从order.getItems().get(0).setQuantity(x)变成order.changeItemQuantity(pid, x)一步到位。这步的反馈是“跟上一步差不多就是把方法挪到实体上”。动作是像但概念不一样聚合和聚合根。Order 是根OrderItem 不能脱离 Order 单独存在一次事务只改一个聚合。这里有个容易踩的坑库存扣减千万别顺手写进changeItemQuantity。库存是另一个聚合的事塞进来就成了跨聚合写事务边界会糊掉。这个我们留到 Step 7 用事件解决。Step 4用 Repository 隔一层到这一步Service 里还在直接注 Mapper。加一层 Repository接口放 domain 层实现放 infrastructure 层MyBatis Mapper 在实现里用。// domain/repository/OrderRepository.java public interface OrderRepository { Order findById(Long orderId); void save(Order order); // 整聚合保存不分 insert/update void delete(Order order); ListOrder findByUserId(Long userId); } // infrastructure/persistence/OrderRepositoryImpl.java Repository public class OrderRepositoryImpl implements OrderRepository { Autowired private OrderMapper orderMapper; Autowired private OrderItemMapper orderItemMapper; Override Transactional public void save(Order order) { if (order.getId() null) { orderMapper.insert(order); } else { orderMapper.update(order); orderItemMapper.deleteByOrderId(order.getId()); } for (OrderItem item : order.getItems()) { item.setOrderId(order.getId()); orderItemMapper.insert(item); } } // findById / delete / findByUserId 略 }Service 里的调用从orderMapper.updateStatus(id, PAID)变成order.pay(); orderRepository.save(order)。坦白说这步我的评价最低。第一眼看就是把 Mapper 又包了一层除了多俩文件没看出好在哪。但要把它说全它确实有两个价值一是依赖倒置领域层只认OrderRepository接口不认 MyBatis哪天换存储只改一个实现类二是聚合完整性save(order)保证主表和子表一起落库不会漏掉子表。不过话说回来——如果你的实体就是单表、确定不换 ORM、也不打算写纯领域层的单测这层收益就挺鸡肋。用 Spring Data JPA 的话JpaRepository本身就是 Repository没必要再套用 MyBatis 的小项目直接 Mapper 也活得很好。这步属于“知道它为什么存在但可以不写”。Step 5拆开应用服务和领域服务这是七步里我认为最该做的一步。原来的OrderServiceImpl里混了两类完全不同的东西领域逻辑库存校验、订单状态变更应用逻辑开事务、调支付网关、发通知。前者回答“业务是什么”后者回答“这件事怎么串起来”。把它们拆开// domain/service/InventoryService.java —— 领域服务纯业务无状态 public class InventoryService { private final ProductRepository productRepository; public InventoryService(ProductRepository productRepository) { this.productRepository productRepository; } /** 预占库存返回快照 */ public ListProductSnapshot reserveStock(ListOrderItemRequest items) { ListProductSnapshot snapshots new ArrayList(); for (OrderItemRequest item : items) { Product p productRepository.findById(item.getProductId()); if (p null) throw new IllegalArgumentException(商品不存在); if (p.getAvailableStock() item.getQuantity()) throw new InsufficientStockException(p.getId(), p.getAvailableStock(), item.getQuantity()); p.deductStock(item.getQuantity()); // 扣减是商品自己的行为 snapshots.add(new ProductSnapshot(p.getId(), p.getPrice(), item.getQuantity())); } return snapshots; } /** 释放预占支付失败回滚用 */ public void releaseStock(ListProductSnapshot snapshots) { /* ... */ } } // application/service/OrderAppService.java —— 应用服务只做编排 Service Transactional public class OrderAppService { Autowired private OrderRepository orderRepository; Autowired private InventoryService inventoryService; // 领域服务 Autowired private PaymentGateway paymentGateway; Autowired private NotificationService notificationService; public Long createOrder(CreateOrderCommand cmd) { // 1. 领域服务预占库存 ListProductSnapshot snapshots inventoryService.reserveStock(cmd.getItems()); // 2. 建聚合 Order order new Order(cmd.getUserId()); for (ProductSnapshot s : snapshots) order.addItem(s.getProductId(), s.getPrice(), s.getQuantity()); orderRepository.save(order); // 3. 调外部支付 try { PaymentResult r paymentGateway.pay(order.getId(), order.getTotalAmount()); if (!r.isSuccess()) { throw new PaymentFailedException(支付失败); } order.pay(); orderRepository.save(order); } catch (Exception e) { // 无论支付失败还是后续异常都在这里做一次补偿取消订单 释放预占 order.cancel(); orderRepository.save(order); inventoryService.releaseStock(snapshots); throw e; } // 4. 通知 notificationService.sendOrderConfirmation(order.getId(), cmd.getUserId()); return order.getId(); } }拆完以后库存校验这种纯业务逻辑挪进了InventoryService无状态、好单测OrderAppService只剩编排预占库存 → 建聚合 → 调支付 → 通知。两者的测试方式也分开了——领域服务给个入参就能断言应用服务才需要 mock 外部依赖。这一步值不值值。哪怕你其他 DDD 一点都不碰只做这一条——把领域服务从大 Service 里拆出来——可维护性和可测试性就上一个台阶。我的建议是任何项目都值得做。Step 6限界上下文和防腐层问题出在依赖上OrderAppService直接依赖了ProductMapper或者InventoryClient商品模型一改字段订单代码就得跟着动。思路是把“订单”“库存”“支付”当成三个限界上下文订单侧跨上下文调用时中间垫一层防腐层ACL, Anti-Corruption Layer// 订单上下文里的防腐层接口 public interface InventoryServicePort { // Port 是六边形架构的叫法 ReservationResult reserveStock(ListOrderItemRequest items); void releaseStock(String reservationId); } // 库存上下文适配器 Component public class InventoryAdapter implements InventoryServicePort { Autowired private RemoteInventoryClient client; // 调库存微服务的 Feign Client Override public ReservationResult reserveStock(ListOrderItemRequest items) { ReserveReq req convert(items); // 订单上下文的模型 → 库存上下文的模型 ReserveResp resp client.reserve(req); return convert(resp); // 再转回来 } }OrderAppService现在只认InventoryServicePort这个接口不碰远程 Client。这步我第一反应是把参数塞进一个类里再转来转去绕。连带 Repository 那步当时的评价都是“可有可无”。但 ACL 的价值确实不在“封装参数”这四个字上而在隔离外部模型的漂移。判断标准就一条对方稳不稳、你会不会换。调支付宝/微信/银联这类——SDK 版本、字段命名都不归你管这层是刚需调公司另一个微服务、而且两边团队就坐一桌吃饭——直接调就行别为了 DDD 而 DDD。所以这步的关键词不是“跨不跨服务”是“隔不隔离变化”。Step 7用领域事件收口最后回到最开始那个createOrder预占库存 → 建订单 → 调支付 → 发通知一路同步串下来。支付网关要是转 3 秒这条请求线程就在那儿干等 3 秒而且顺序写死了想加个“下单送积分”还得回来改方法。办法是让聚合在状态变化时抛出领域事件应用服务负责收集并发布支付和通知都退化成监听器。// domain/event/OrderCreatedEvent.java public class OrderCreatedEvent extends DomainEvent { private Long orderId; // 落库生成后回填 private final Long userId; private final BigDecimal totalAmount; private final String reservationId; // 库存预占 ID失败时用来回滚 // 构造、getter、setOrderId 略 }聚合根里产生事件Entity public class Order { Transient private ListDomainEvent events new ArrayList(); public Order(Long userId, ListProductSnapshot snapshots) { // ... 构造逻辑 // 此刻 id 还没生成数据库自增事件里先不带 orderId events.add(new OrderCreatedEvent(userId, totalAmount, reservationId)); } public ListDomainEvent getEvents() { return Collections.unmodifiableList(events); } public void clearEvents() { events.clear(); } }应用服务里收集并发布public Long createOrder(CreateOrderCommand cmd) { ListProductSnapshot snapshots inventoryService.reserveStock(cmd.getItems()); Order order new Order(cmd.getUserId(), snapshots); orderRepository.save(order); // 落库后 id 才有值 // 回填 orderId再发事件监听器里失败也能靠 reservationId 补偿 order.getEvents().forEach(e - ((OrderCreatedEvent) e).setOrderId(order.getId())); order.getEvents().forEach(eventBus::publish); order.clearEvents(); return order.getId(); } // 支付 / 通知退化成异步监听器 EventListener Async public void onOrderCreated(OrderCreatedEvent e) { // 调支付网关成功发 OrderPaidEvent失败回滚库存 }这里我不太敢只说好话。领域事件是这七步里最“重”的一步一旦上异步你就要正面回答幂等、补偿、顺序、最终一致性这一串问题很多时候还得引入消息中间件。收益是解耦和响应变快代价是排查难度陡增。如果下单流程本来就不慢、也没几个下游那这步可以先放着。收尾这七步到底该怎么用七步走完把我的真实取舍摊开大概是这么个排序必做Step 2把行为还给对象、Step 5拆开应用服务和领域服务。两步几乎零成本收益立竿见影任何业务系统都值得做。按需Step 3聚合根、Step 6防腐层。看你的对象有没有真正的行为、系统之间有没有稳定的边界有就做没有别硬凑。谨慎Step 4Repository、Step 7领域事件。前者在“单表 不换 ORM”时收益有限后者会引入异步和最终一致性先想清楚幂等和补偿再动手。比步骤排序更重要的是这七步其实在做同一件事把散落各处的规则收回它该在的地方。状态流转收回对象里聚合规则收回根里业务逻辑收回领域层里外部模型的变化挡在防腐层外。DDD 不是在加架构是在给“变化”划边界。想明白这一点用不用那几个术语反倒没那么重要。最后说句实话DDD 不值得炫技。我见过太多项目把四层、聚合、事件总线一股脑堆上去结果业务没理清复杂度倒是翻了几倍。能解决你当下痛点的那个度才是最合适的度。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进