Skip to content

领域驱动设计 DDD ​

Java 架构师 · 高并发与架构

DDD 是一套以"业务领域"为中心的建模方法论,它用统一的语言与显式边界把复杂业务结构化。它既是战略设计(划边界、理协作),也是战术设计(落地模型)。对架构师而言,DDD 最重要的价值是成为微服务拆分的理论基石——限界上下文直接映射服务边界,聚合根直接映射事务边界。本章从统一语言、限界上下文,讲到实体/值对象/聚合根、领域事件,再到 CQRS、事件溯源与分层架构落地,并剖析"贫血模型"等反模式。

一、统一语言(Ubiquitous Language):沟通的地基 ​

开发、产品、领域专家必须使用同一套术语。如果产品说"下单",开发数据库叫 create_order,代码里叫 saveBill,沟通就必然失真。"统一语言"要沉淀为术语表、文档、甚至类名——代码即文档。它是 DDD 一切的前提:没有统一语言,限界上下文就划不准。

二、战略设计:限界上下文(Bounded Context) ​

限界上下文是领域的显式边界,上下文内模型自洽、术语一致;上下文之间允许概念不同(同一个"Account",在"交易上下文"是资金账户,在"营销上下文"是积分账户)。它是微服务划分的首要依据:一个限界上下文 ≈ 一个微服务。

上下文映射(Context Mapping) 理清服务间如何协作:

  • 合作关系(Partnership):两上下文互相依赖、同步演进。
  • 客户-供应(Customer/Supplier):上游提供接口,下游消费,需定契约。
  • 防腐层(ACL, Anti-Corruption Layer):用适配层隔离外部/遗留系统,保护核心域不被脏模型污染——对接第三方支付、老 ERP 时必备。
  • 开放主机(OHS)+ 发布语言(PL):对外暴露稳定的 API 与共享模型。

三、战术设计:实体 / 值对象 / 聚合根 ​

3.1 实体(Entity)vs 值对象(Value Object) ​

  • 实体:有身份标识(ID),ID 不变则"是同一个东西"(如 Order 订单,订单号不变)。生命周期长,有状态变迁。
  • 值对象:只关心属性集合,无身份(如 Money{amount, currency}、Address)。不可变(immutable),两个值对象属性相同即相等。用值对象能减少 Entity 数量、降低复杂度。

3.2 聚合(Aggregate)与聚合根(Aggregate Root) ​

聚合是一致性边界:一组相关对象(实体+值对象)作为一个整体被访问、被持久化。聚合根是聚合对外的唯一入口,保证聚合内部的不变量(Invariant)永远成立。

规则:

  • 一个事务只改一个聚合:跨聚合的修改会破坏边界、引入分布式事务。
  • 聚合根外部只能通过 ID 引用其他聚合,不能直接持有对象引用(避免聚合间耦合)。
  • 聚合要小:一个聚合包进半个系统 = 事务锁重、难扩展。

3.3 示例:订单聚合 ​

java
class Order {                       // 聚合根
    OrderId id;                    // 身份标识
    List<OrderItem> items;         // OrderItem 是实体/值对象
    OrderStatus status;
    Money total;

    void addItem(Product p, int n) {
        if (status != CREATED) throw new IllegalStateException("已提交订单不可改"); // 不变量
        items.add(new OrderItem(p.id, p.price, n));
        total = recalc();          // 聚合内维护一致性
    }
    void pay() { if (items.isEmpty()) throw ...; status = PAID; }
}
// 跨聚合: Order 支付 → 发 OrderPaid 领域事件 → 库存聚合扣减

四、领域事件(Domain Event):跨聚合最终一致 ​

跨聚合不能直接同步改(破坏边界)。正确做法:聚合内状态变更后发布领域事件,其他聚合/上下文订阅并响应(最终一致)。

领域事件解耦了"主流程"与"副作用",也是 CQRS/事件溯源的基础。事件要带足够上下文(聚合 ID、变更摘要),且消费端必须幂等(事件可能重复投递)。

五、CQRS:读写模型分离 ​

传统 CRUD 一个模型既管写又管读,复杂查询(多表 JOIN、DTO 拼装)会污染领域模型。CQRS(Command Query Responsibility Segregation) 把"写(Command)"与"读(Query)"分开:

  • 写侧:走领域模型 + 聚合,保证一致性。
  • 读侧:走专门的读模型(DTO/投影),可冗余存储、为查询优化(甚至用 ES/宽表)。

收益:读写可独立扩展、读模型为查询优化、写模型不被查询污染。代价:读模型与写模型的一致延迟(最终一致)、架构变复杂。不是所有系统都要 CQRS——简单 CRUD 用事务脚本即可。

六、事件溯源(Event Sourcing) ​

传统持久化存"当前状态";事件溯源存"状态变更的事件序列",当前状态 = 事件重放(replay)。好处:完整审计日志、可回放任意时间点状态、天然适配 CQRS(事件即事实)。代价:事件 schema 演进难、查询需物化读模型、学习曲线陡。适合强审计、需回放的领域(金融交易、工单)。

七、分层架构落地(依赖倒置) ​

interface(接口层)  →  application(应用层: 用例编排, 无业务规则)
       ↓                  ↓
   domain(领域层: 实体/值对象/聚合根/领域服务/领域事件/仓储接口)  ← 核心, 不依赖外层
       ↑ (依赖倒置, 领域定义接口, 外层实现)
   infrastructure(基础设施层: DB/缓存/MQ 实现仓储)

关键原则:领域层是核心,不依赖任何框架(Spring 只是装配工具);基础设施实现领域层定义的仓储接口(依赖倒置 DIP),便于替换与测试。应用层编排用例、调用领域、发布事件,但不包含业务规则。

DDD 反模式

  • 贫血模型(Anemic Domain):只有 getter/setter 的"假 DDD",业务逻辑散在 Service,领域知识丢失、状态不变量无人维护。解法:把行为放回聚合根。
  • 聚合过大:一个聚合包进半个系统,事务锁重、难扩展。解法:拆小,保持不变量在边界内。
  • 跨聚合强一致:应用层同步改多个聚合 → 破坏边界、引入分布式事务。解法:改领域事件最终一致。
  • 过度设计:小项目硬套 DDD(CQRS+事件溯源全套),引入不必要复杂度。解法:先用事务脚本,痛点域再上 DDD。

八、DDD 与微服务:从战略到落地的映射 ​

DDD 概念微服务映射
限界上下文微服务边界
聚合根事务边界
领域事件服务间异步协作
防腐层对接外部系统的适配服务
上下文映射服务依赖拓扑

本节小结

DDD 的本质是用"领域"而非"技术"组织系统。战略上:用统一语言消除沟通损耗、用限界上下文划边界(=服务边界)、用上下文映射理协作(ACL 隔离外部系统)。战术上:实体有身份、值对象不可变、聚合根定义一致性边界(一个事务只改一个聚合)、跨聚合用领域事件最终一致。CQRS 分离读写模型、事件溯源存事实序列,按复杂度选用。落地分两层(接口/应用/领域/基础设施)且领域层不依赖框架(依赖倒置)。它是微服务拆分的理论依据,但应聚焦痛点域,避免贫血模型与过度设计——先战略后战术。

📚 官方文档与参考资料 ​

✏️ 练习与自测 ​

  1. 设计题:把"电商交易"划分为限界上下文,标注哪些用防腐层隔离(如对接第三方支付/物流),并画出上下文映射图。
  2. 建模题:设计一个"购物车"聚合,说明它的不变量(如数量非负、不能加已下架商品)与哪些操作必须在聚合内校验(而非在 Controller/Service)。
  3. 辨析题:为什么跨聚合不能在同一事务提交?给出用领域事件实现"订单支付→扣库存"最终一致的完整时序,并说明消费端如何幂等。
  4. 设计题:一个订单查询页需要 JOIN 8 张表拼 DTO,你如何用 CQRS 改造?画出读写模型分离后的架构,并说明读模型如何与写库保持一致。