Spring Boot DDD 分层架构落地:领域模型、聚合根与事件驱动的业务实战

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 常见误区

说几个我踩过的坑,或者看到别人踩过的坑:

  1. 简单 CRUD 项目也生套 DDD。DDD 是处理复杂业务的,业务就是增删改查,你强行建模只会增加抽象层级,代码又绕又没用。
  2. 实体变成"血稠模型" 。有行为是好事,但别把内部状态到处暴露。getId()、getStatus() 可以给,但绝不要 setStatus()。聚集体内字段私有,只能通过方法改。
  3. 忽略值对象 。要么是习惯用 String 当金额、用 long 当数量,摸模糊糊的。自定义 Money、Quantity 能提高类型安全和语义,值得做。
  4. 聚合根设计过大。把订单相关的全塞一个聚合里,并发冲突和性能问题随之而来,要勇于拆分,用领域事件把副作用传递出去。
  5. 滥用领域事件。事件不是万能胶水,内部方法能解决就别发事件。事件用于跨聚合或跨上下文协作,而且处理事件和事务一致性问题要谨慎。
  6. 过度设计。中间层、抽象接口、工厂、规格模式一堆,实际上业务根本没那么复杂。DDD 是为业务认知服务的,不是用来炫技的。

11.2 实施路径

我的建议是渐进式重构,别想着一口气全改完。

  • 识别核心域:找出业务痛点最集中的领域,比如订单、结算、风控。
  • 统一语言:跟业务专家对齐术语,建一个概念表,保证代码命名和业务语言一致。
  • 建模并划分限界上下文:画上下文映射图,明确依赖关系。
  • 选一个核心域试点:挑一个模块改成 DDD 四层架构,写用例清单,逐步把业务逻辑从 Service 转移到领域层。
  • 持续沉淀:每次迭代重构一部分贫血逻辑,维护好聚合、事件和防腐层边界。
  • 引入领域事件与异步机制:跨聚合协作变多时,再用事件驱动解耦。
  • 最后考虑微服务拆分:等单体应用启动慢到不行、协作效率太低时,再按业务能力拆。

12. 结语

说白了,DDD 不是银弹,它的价值在于帮我们管理复杂度。Spring Boot 自带的依赖注入、事务管理、事件发布确实降低了 DDD 的落地成本,但真正的关键永远是建模。你得理解业务,把规则放到领域模型里,保持聚合一致性,通过领域事件打破硬编码依赖,让系统架构随着业务演进自然生长。

别小看这个转变,代码还是那套代码,但你对业务的理解会完全不一样。这篇就算给大家一个参考,希望能少走点弯路。

官网:www.farerboy.com
相关推荐
自强的小白4 小时前
MYbatis-plus(自定义sql)
java·sql·mybatis
古法安卓4 小时前
Android-Ext4文件系统问题排查
android·java·android studio
Escalating_xu4 小时前
【C 语言】深入理解指针(3):字符指针、数组指针、二维数组传参、函数指针与转移表
java·c语言·开发语言
Dawson Zhu4 小时前
大模型推理的三维本质:原理解析与工程实践
人工智能·语言模型·架构·aigc·agi
不正经的码狗4 小时前
Java 开发环境搭建与 Eclipse 使用速通教程
java·开发语言·eclipse
用户8181870627464 小时前
第37章 Java应用在K8s里的经典坑:容器内存/CPU limit与JVM参数不匹配导致的OOMKilled
java·后端
马剑威(威哥爱编程)4 小时前
【AI全栈后端12-03】Spring Boot 用多模型路由把智能客服成本降下来
java·人工智能·spring boot
云计算-Security4 小时前
AI 赋能运维:UniRack 主机纳管平台的架构与实践
运维·人工智能·架构
霸道流氓气质4 小时前
AI模型幻觉检测与抑制完全指南:从规则引擎到RAG对比的Java生产级实战
java·开发语言·人工智能