
1. 从贫血模型到领域驱动设计
先说个挺常见的情况:以前写 Java 后端,一上来都是 Controller → Service → Repository 三层,Controller 只收参数、返回结果,Service 里堆满业务逻辑,Repository 就是简单的 CRUD。这种写法有个经典的名字,叫"贫血模型"。对象里全是字段和 getter/setter,业务行为基本没有。
业务规则简单的时候,这么干确实省事,系统也很清晰。但一旦规则复杂起来,比如订单状态流转、支付对账、库存扣减、优惠策略叠加,问题就来了:Service 方法动不动几百行,到处都是 if...else,同一个规则可能在好几个入口重复出现,改一处又怕带崩别的地方。代码腐化得比你想象快。
这时候领域驱动设计(DDD)的价值就体现出来了。它不是银弹,但确实能帮我们梳理复杂业务:把业务知识沉淀成领域模型,让对象自己表达语义,把内聚的业务逻辑放到领域层,从而控制复杂度。这篇文章就基于 Spring Boot,讲一下 DDD 分层怎么落地,包括领域建模、聚合根、事件驱动,再举个订单和支付串起来的例子。
2. 业务场景与设计动机
假设我们做一个电商订单。订单生命周期有创建、已支付、已发货、已完成、已取消这几个状态,状态变化有严格规则:
- 只有待支付订单能支付;
- 只有已支付订单能发货;
- 取消订单时,如果已经支付,要触发退款;
- 库存不足不能下单;
- 支付成功后要通知库存扣减,还要发短信。
如果用贫血模型,你会在 OrderService 里看到这样的写法:if (order.getStatus() == Status.PAYABLE && payment.getAmount() >= ...),然后一长串业务逻辑散落在 Service、Controller,甚至工具类。这不是显性的业务规则,而是"隐性概念"。代码完全看不出业务意图。
所以 DDD 的核心目标就是:把订单状态机、支付规则这些抽象成领域模型,让领域对象自己维护不变量(Invariant),必要时通过领域事件跟其他上下文协作。
3. DDD 核心概念梳理
先过一遍基本概念,后面看代码才有共识。
3.1 限界上下文(Bounded Context)
限界上下文就是业务边界的显式表达。订单上下文、支付上下文、库存上下文、用户上下文各自维护独立的模型,模型之间不共享概念。比如"订单"这个对象,在订单上下文里它是聚合根,但在支付上下文里,它可能只是一个 PaymentOrderRef 引用。上下文之间通过防腐层(ACL)集成。
3.2 实体(Entity)
实体有唯一标识,状态会在生命周期里变化。订单(Order)就是实体,订单号 orderId 是标识。实体的业务方法应该封装真实行为,而不是暴露各种 setter 让外部随便改。
3.3 值对象(Value Object)
值对象没有唯一标识,两个对象相等是靠属性值判断的。它描述领域中的定量或定性特征。比如收货地址 Address、金额 Money、订单明细里的商品快照 ProductSnapshot 都是值对象。值对象不可变,要改就生成新的。
3.4 聚合根(Aggregate Root)
聚合根是根实体,它负责保证聚合内的一致性边界。聚合是一组关联对象的集合,聚合根是对外访问的唯一入口,外部不能直接改聚合里的其他实体或值对象。就拿订单聚合来说,Order 是根,OrderItem 只能通过 Order 操作,比如 addItem()、removeItem(),你不能让外部直接拿到内部集合改。
3.5 领域服务(Domain Service)
有些业务行为放不进单个实体或值对象,那就用领域服务来承载。领域服务处理跨聚合、跨多个对象的逻辑,但它自己不管理状态。比如"检查库存是否充足"这种协作行为,要同时看商品信息和库存数量,放在 StockService 里比较合适。
3.6 应用服务(Application Service)
应用服务在应用层,负责编排用例,把领域对象串起来完成一次用户交互,然后管好事务、授权等横切关注点。注意,它不包含业务规则,只表达"我要做什么",具体规则由领域层执行。
4. DDD 四层架构与依赖规则
DDD 经典的四层架构:
- Interfaces 层(用户接口层):处理 HTTP 请求、消息、命令,转成应用层能懂的 DTO,返回响应。不碰业务逻辑。
- Application 层(应用层):定义用例流程,管理事务、并发控制、权限校验。调用领域服务、聚合根来完成业务操作。
- Domain 层(领域层):核心业务逻辑所在地。实体、值对象、聚合根、领域服务、领域事件定义和发布都在这里。
- Infrastructure 层(基础设施层):提供技术支撑,比如数据库持久化、消息队列、外部服务调用、文件存储,并且实现 Domain 层定义的 Repository 接口。
依赖规则是"上层依赖下层,外层依赖内层",领域层最稳定,不该依赖外部框架(Spring 注解最好也慎用)。基础设施层依赖领域层接口,但领域层不反向依赖基础设施层。
Spring Boot 工程里,通常用包结构来约束分层:
com.example
├── interfaces
│ ├── controller
│ └── dto
├── application
│ └── service
├── domain
│ ├── model
│ ├── repository
│ ├── service
│ └── event
└── infrastructure
├── repository
└── adapter
5. Spring Boot 集成 Spring Data 实现 Repository
领域层先定义 Repository 接口,用来从聚合根获取聚合:
java
public interface OrderRepository {
Order findById(OrderId orderId);
void save(Order order);
}
注意参数和返回值都是领域对象,不是数据库实体。基础设施层用 Spring Data JPA 来实现接口:
java
@Repository
public interface OrderJpaRepository extends JpaRepository<Order, Long> {
// Spring Data 自动实现
}
但我们可以通过自定义实现类把领域仓库转成 JPA 仓库:
java
@Repository
public class SpringDataOrderRepository implements OrderRepository {
private final OrderJpaRepository jpaRepository;
@Override
public Order findById(OrderId orderId) {
return jpaRepository.findById(orderId.getId()).orElseThrow(OrderNotFoundException::new);
}
@Override
public void save(Order order) {
jpaRepository.save(order);
}
}
这样领域层完全看不见 JpaRepository 相关接口,领域模型不会被持久化框架污染。
聚合根上的 JPA 映射
实际操作中,方便起见,有人直接在聚合根 Order 上加 JPA 注解(比如 @Entity),这会让领域层依赖基础设施。但很多团队走"务实防腐"路线,只要你知道自己在干什么,可以接受。更严格的做法是用 Mapper 把领域对象转成独立的持久化对象,不过代码量会上来。权衡下来,中型项目直接标注 JPA 注解还是效率高,关键是要把业务方法留在领域层。
6. 领域事件发布与异步监听
领域事件是聚合根对外广播的"已经发生的事实"。比如 OrderPaidEvent 表示订单已支付。用事件驱动,订单上下文和支付、库存、通知这些上下文就能解耦。
Spring Boot 提供 ApplicationEventPublisher 和 @EventListener,我们可以让聚合根在自己内部维护个未发布事件集合,保存后统一发布,保证事件和聚合状态变更的事务一致性。
定义领域事件
java
public class OrderPaidEvent {
private final OrderId orderId;
private final Instant paidAt;
// 构造函数、getter...
}
聚合根管理事件
我习惯不用 Spring Data 的 AbstractAggregateRoot,虽然它封装好了事件注册和释放,但领域模型依赖 Spring Data 总觉得不干净。自己维护一个 List<Object> 就行:
java
public class Order {
private final List<Object> domainEvents = new ArrayList<>();
// 其他字段和构造方法
public void pay(Money paidAmount) {
if (status != OrderStatus.PAYABLE) {
throw new IllegalStateException("订单当前状态不可支付");
}
if (!amount.equals(paidAmount)) {
throw new IllegalArgumentException("支付金额与订单金额不一致");
}
this.status = OrderStatus.PAID;
domainEvents.add(new OrderPaidEvent(orderId, Instant.now()));
}
public List<Object> getDomainEvents() {
return Collections.unmodifiableList(domainEvents);
}
public void clearDomainEvents() {
domainEvents.clear();
}
}
应用服务发布事件
发布事件放在应用服务里,事务边界也在应用服务控制:
java
@Service
@Transactional
public class PaymentApplicationService {
private final OrderRepository orderRepository;
private final ApplicationEventPublisher eventPublisher;
public void handlePayCommand(PayCommand command) {
Order order = orderRepository.findById(command.getOrderId());
order.pay(command.getAmount());
orderRepository.save(order);
order.getDomainEvents().forEach(eventPublisher::publishEvent);
order.clearDomainEvents();
}
}
更优雅的方案是用 Spring Data 的 AbstractAggregateRoot,save 的时候自动释放事件。不过我个人还是喜欢手动控制,毕竟少一个隐式依赖,出问题也好排查。
如果你希望事件在事务提交后再发,避免事务回滚导致事件误发,可以用 @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)。
异步监听
java
@Component
public class OrderPaidListener {
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onOrderPaid(OrderPaidEvent event) {
// 调用库存服务扣减库存
inventoryClient.deduct(event.getOrderId());
// 发送短信通知
notificationClient.notifyUser(event.getOrderId());
}
}
记得在启动类或配置类加 @EnableAsync,监听方法也要标 @Async。如果是微服务架构,跨服务的事件还是建议走 Kafka 或 RocketMQ 这类消息中间件,Spring 事件机制更适合单应用内解耦。
7. 实战案例:订单状态机与支付领域建模
7.1 订单聚合根设计
订单是典型的聚合根。它有多个 OrderItem 值对象、Money 值对象、状态枚举、收货地址等。订单聚合根暴露 create、pay、cancel、ship、complete 这类业务方法,每个方法都包含状态流转。
状态机可以嵌入聚合根:
java
public class Order {
private OrderId id;
private List<OrderItem> items;
private OrderStatus status;
private Money totalAmount;
private Address shippingAddress;
private final List<Object> domainEvents = new ArrayList<>();
public void pay(Money paidAmount) {
ensureStatus(OrderStatus.WAITING_PAYMENT);
if (!totalAmount.equals(paidAmount)) {
throw new PaymentAmountMismatchException();
}
this.status = OrderStatus.PAID;
domainEvents.add(new OrderPaidEvent(id, Instant.now()));
}
public void cancel() {
if (status == OrderStatus.PAID) {
// 已支付订单取消,需要退款
domainEvents.add(new OrderCancelledWithRefundEvent(id));
this.status = OrderStatus.CANCELLED;
} else if (status == OrderStatus.WAITING_PAYMENT) {
// 未支付订单直接取消
this.status = OrderStatus.CANCELLED;
} else {
throw new InvalidOrderStateException("当前状态不可取消");
}
}
private void ensureStatus(OrderStatus expected) {
if (status != expected) {
throw new InvalidOrderStateException("当前状态不是 " + expected);
}
}
// 其他方法...
}
提醒一下 cancel() 里的写法:我特意在每个分支里自己设置状态,而不是最后统一赋值。否则如果状态不匹配走到 else 抛异常,虽然不会执行最后一行,但如果你在 PAID 分支忘了设置状态,后面统一赋值就有潜在风险。规则放聚合根里,服务层永远不需要知道这些分支。
7.2 支付领域建模
支付本身是另一个限界上下文。我们可以有 Payment 聚合根,包含支付流水号、支付金额、支付方式、支付状态等。订单上下文向支付上下文发起"支付"命令,支付成功后支付上下文发出 PaymentSuccessEvent,订单上下文监听事件后,调用订单的 pay() 方法完成状态变更。
这里要注意,订单和支付是两个限界上下文,模型不一致。支付上下文不会直接改订单状态,而是通过事件协同,达成最终一致性。
如果业务要求支付状态和订单状态必须强一致,那可以考虑把支付作为订单聚合内部的子实体。但我觉得支付流水独立存在、有自己的生命周期,拆分成两个聚合更合理。然后在应用服务层通过本地事务+事件做最终一致,或者用分布式事务框架兜底。
7.3 命令与查询分离
实践中,写操作(CUD)和读操作最好分开。查询别走领域模型,直接写个 lightweight DTO 映射,专门查列表,避免加载整个聚合根。比如订单列表页用专用查询接口,性能好,也不会被领域模型拖累。
8. 聚合根一致性边界设计
聚合根的核心作用是划分一致性边界。同一个聚合内,数据变更必须立刻完全一致(事务性);跨聚合的变更可以最终一致。设计聚合时要遵循这些原则:
- 小聚合更优:别试图把一堆关联对象全塞进一个聚合。订单聚合包含订单项、地址、金额是合理的,但把支付流水、物流信息全塞进去,只会让聚合过大,并发写入性能下降。
- 通过 ID 引用其他聚合 :聚合根里保存其他聚合的 ID,而不是直接持有对象引用。比如
Order里有buyerId,而不是一个User对象,这样不会形成跨聚合的级联修改。 - 基于业务不变量判断边界:两个对象如果必须始终保持强一致,就放同一个聚合;如果只要求最终一致,就分开。
- 一次事务只修改一个聚合:事务边界应该和聚合边界一致。如果用例要同时改多个聚合,考虑发领域事件,让监听器做后续操作。
在订单示例里,Order 聚合包含 OrderItem、Money、Address,因为它们是强一致的------订单总金额等于所有明细之和,地址也是订单生成后不能随意改动。而 Payment 是独立聚合,因为支付流水创建后,订单不应该直接改它。
9. 防腐层隔离外部系统
防腐层(Anti-Corruption Layer,ACL)在 DDD 里是用来隔离限界上下文之间翻译映射的组件。微服务架构里,我们经常要对接外部支付网关、物流、ERP,外部系统的模型和内部领域模型不一样,直接拿外部模型进来会把领域层污染。
比如支付网关返回的数据里有 transaction_id、status_code 这些,和内部的 Payment 模型对不上。我们就可以在基础设施层写一个适配器:
java
public interface PaymentGateway {
PaymentResult requestPayment(PaymentRequest request);
}
public class AlipayGatewayAdapter implements PaymentGateway {
private final AlipaySdk alipaySdk;
@Override
public PaymentResult requestPayment(PaymentRequest request) {
// 把内部 PaymentRequest 转换成 Alipay 参数
// 调用 SDK
// 把 Alipay 响应转换成统一的 PaymentResult
}
}
应用层只依赖 PaymentGateway 接口,不需要关心是支付宝还是微信。外部系统变了,影响的只是适配器,维护起来舒服多了。
10. 与微服务拆分的关系
DDD 能帮我们找到微服务拆分的边界。一个限界上下文对应一个微服务或模块,聚合根是服务内部数据一致性边界。别按技术层拆(比如 order-controller、order-service、order-dao),要按业务能力拆。
拿订单域来说,可以拆成订单服务、支付服务、库存服务、通知服务。每个服务内部都有自己的领域模型,通过轻量级事件(Kafka)或 API 协作。DDD 的战略建模可以帮我们画出上下文映射关系,分清共享内核、客户/供应商、防腐层,从而定服务集成方式。
但得说清楚:DDD 并不强制微服务。单体应用里照样可以用 DDD 分层架构。拆不拆微服务,取决于团队规模、部署频率和故障隔离需求,别盲目套。
11. 常见误区与实施路径
11.1 常见误区
说几个我踩过的坑,或者看到别人踩过的坑:
- 简单 CRUD 项目也生套 DDD。DDD 是处理复杂业务的,业务就是增删改查,你强行建模只会增加抽象层级,代码又绕又没用。
- 实体变成"血稠模型" 。有行为是好事,但别把内部状态到处暴露。
getId()、getStatus()可以给,但绝不要setStatus()。聚集体内字段私有,只能通过方法改。 - 忽略值对象 。要么是习惯用
String当金额、用long当数量,摸模糊糊的。自定义Money、Quantity能提高类型安全和语义,值得做。 - 聚合根设计过大。把订单相关的全塞一个聚合里,并发冲突和性能问题随之而来,要勇于拆分,用领域事件把副作用传递出去。
- 滥用领域事件。事件不是万能胶水,内部方法能解决就别发事件。事件用于跨聚合或跨上下文协作,而且处理事件和事务一致性问题要谨慎。
- 过度设计。中间层、抽象接口、工厂、规格模式一堆,实际上业务根本没那么复杂。DDD 是为业务认知服务的,不是用来炫技的。
11.2 实施路径
我的建议是渐进式重构,别想着一口气全改完。
- 识别核心域:找出业务痛点最集中的领域,比如订单、结算、风控。
- 统一语言:跟业务专家对齐术语,建一个概念表,保证代码命名和业务语言一致。
- 建模并划分限界上下文:画上下文映射图,明确依赖关系。
- 选一个核心域试点:挑一个模块改成 DDD 四层架构,写用例清单,逐步把业务逻辑从 Service 转移到领域层。
- 持续沉淀:每次迭代重构一部分贫血逻辑,维护好聚合、事件和防腐层边界。
- 引入领域事件与异步机制:跨聚合协作变多时,再用事件驱动解耦。
- 最后考虑微服务拆分:等单体应用启动慢到不行、协作效率太低时,再按业务能力拆。
12. 结语
说白了,DDD 不是银弹,它的价值在于帮我们管理复杂度。Spring Boot 自带的依赖注入、事务管理、事件发布确实降低了 DDD 的落地成本,但真正的关键永远是建模。你得理解业务,把规则放到领域模型里,保持聚合一致性,通过领域事件打破硬编码依赖,让系统架构随着业务演进自然生长。
别小看这个转变,代码还是那套代码,但你对业务的理解会完全不一样。这篇就算给大家一个参考,希望能少走点弯路。
官网:www.farerboy.com
