学完这篇,你能把一个"增删改查一把梭"的 Java 项目,按 DDD 的方式重新拆出领域层、应用层和基础设施层,并且知道每一层该写什么、不该写什么。
DDD 落地失败的项目,九成不是理论没学懂,而是一上来就建了 domain、application 一堆包,然后把原来的 Service 代码整包搬了进去——包变了,耦合没变。下面按顺序来,一步一个可检查的动作。
第一步:先划限界上下文,别急着建包
打开你的需求文档或数据库表清单,把业务名词分组。比如电商系统里,「订单」「支付」「库存」「物流」各自是一组,而不是一张表一个类。
分组后画一张上下文映射图,标清楚谁调用谁。电商里通常是:订单上下文通过「支付成功」事件通知库存上下文,而不是订单服务直接调库存 Mapper。
注意:限界上下文是业务边界,不是技术边界。如果你发现两个上下文共享同一张表,说明边界划错了,回头重新分。
第二步:给每个上下文搭四层骨架
以订单上下文为例,一个上下文的包结构长这样:
order/
├── domain/ # 领域层:实体、值对象、聚合、领域服务、仓储接口
│ ├── model/
│ ├── repository/ # 只有接口
│ └── event/
├── application/ # 应用层:用例编排、事务边界
│ ├── service/
│ └── dto/
├── infrastructure/ # 基础设施层:仓储实现、Mapper、RPC、MQ
└── interfaces/ # 接口层:Controller、定时任务入口
依赖方向只有一条:interfaces → application → domain,infrastructure → domain。domain 层不依赖任何其他层,也不依赖 Spring。
注意:先在 pom.xml 里用 Maven 模块或 ArchUnit 测试把这条依赖规则钉死,否则三个月后一定有人从 domain 里 import 一个 Mapper。
第三步:用聚合根守住一致性边界
聚合是 DDD 里最容易被忽略、也最值钱的部分。规则很简单:一次事务只修改一个聚合,聚合内部的一致性由聚合根负责。
public class Order { // 聚合根
private OrderId id;
private List<OrderItem> items; // 内部实体
private Money total;
private OrderStatus status;
public void pay(Money paid) {
if (status != OrderStatus.CREATED) {
throw new OrderStatusException("订单状态不允许支付");
}
if (!paid.equals(total)) {
throw new AmountMismatchException();
}
this.status = OrderStatus.PAID;
registerEvent(new OrderPaidEvent(id));
}
}
Money、OrderId 用值对象(不可变、通过值相等),别用裸的 BigDecimal 和 Long——这是最省事的防错手段。
注意:订单和订单项可以在一个聚合里,但订单和用户不行。如果两个对象需要强一致,才放同一个聚合;否则用 ID 引用,靠领域事件最终一致。
第四步:仓储接口放领域层,实现在基础设施层
domain 层只声明接口,用领域语言命名:
public interface OrderRepository {
Order findById(OrderId id);
void save(Order order);
}
infrastructure 层用 MyBatis 或 JPA 实现它,负责对象与表结构的转换。领域层完全不知道数据库长什么样。
注意:仓储的粒度是"聚合",不是"表"。一个聚合对应一个 Repository,不要给 OrderItem 单独建仓储。
第五步:应用服务只做编排,不写业务
应用服务的职责是:加载聚合 → 调用领域方法 → 保存聚合 → 发事件。它应该是"薄"的。
@Service
public class OrderApplicationService {
@Transactional
public void pay(PayCommand cmd) {
Order order = orderRepository.findById(new OrderId(cmd.getOrderId()));
order.pay(Money.of(cmd.getAmount()));
orderRepository.save(order);
eventPublisher.publish(order.pullEvents());
}
}
如果这个方法里出现了 if (status == ...) 这类状态判断,说明业务逻辑漏出到应用层了,搬回聚合根。
第六步:加一层防腐层隔离外部系统
调用第三方支付、老系统接口时,在 infrastructure 层写一个 Adapter,把对方的数据结构翻译成你领域内的对象。对方的字段改名、接口升级,只改这一个类。
第七步:用领域事件解耦跨上下文
OrderPaidEvent 发布后,库存上下文订阅并扣减库存。这样订单上下文不需要知道库存的存在。
注意:本地事件用 @TransactionalEventListener 绑定事务提交后触发,避免事务回滚了事件已经发出去。跨进程才上 MQ。
小结
- 先划限界上下文,再建包,顺序反了必返工
- 依赖方向单向:interfaces → application → domain ← infrastructure
- 聚合根负责不变量,一次事务只改一个聚合
- 仓储接口在领域层,实现在基础设施层,粒度按聚合
- 应用服务只编排,出现业务判断就是漏了
- 跨上下文用领域事件,别直接互调 Mapper