DDD(Domain-Driven Design,领域驱动设计)由 Eric Evans 在 2003 年出版的《领域驱动设计》一书中首次提出,至今已经走过 20 多年。它最初是一种面向对象的建模方法,如今已发展成为一套针对大型复杂系统的软件分析与设计方法论。本文梳理 DDD 的两大核心板块——战略设计与战术设计,以及它们如何协同工作来驯服软件复杂度。
为什么需要 DDD
软件失败的很多案例,根源不在于技术选型,而在于业务理解偏差:产品说的是一套,开发理解的是另一套,代码实现的又是第三套。随着系统规模膨胀,模型越来越模糊,最终演变成"大泥球"(Big Ball of Mud)。
DDD 的核心主张是:软件系统的复杂度应该用系统化的方法来控制。它把方法论分为两大部分:
- 战略设计:从宏观视角划分问题域,建立通用语言,划定模型边界
- 战术设计:在边界之内,用具体的建模模式把领域模型落地为代码
战略设计
领域与子域
领域就是软件系统要解决的业务问题。面对一个大的领域,第一件事是把它拆分成不同的子域,分而治之:
| 子域类型 | 特点 | 策略 |
|---|---|---|
| 核心子域 | 组织的核心竞争力所在,需求因市场竞争频繁变化 | 必须自主开发,不能采购或套用 |
| 支撑子域 | 为支撑核心领域而开发,变化较少,有行业属性 | 可以自研,也可以外包 |
| 通用子域 | 很难但已经被解决的问题,没有业务属性 | 直接采购,如身份管理、邮件系统、内容存储 |
划分子域的意义在于把研发资源聚焦到核心子域上——那是真正产生业务差异化的地方。
通用语言:对齐业务与技术
设计有效的软件方案,要求团队掌握业务领域知识;而知识要发挥功效,需要有效沟通;沟通的基础就是通用语言(Ubiquitous Language,也叫统一语言)。
通用语言首先是业务语言,能用它描述业务知识。由于业务知识本身存在理解上的复杂性,我们需要对业务知识做抽象,省略不必要的细节,形成领域模型。领域模型成为通用语言的核心,它反映了领域深层含义的术语以及它们之间的关系:
graph LR
A["领域知识"] --> B["领域模型"]
B --> C["沟通基础"]
B --> D["指导架构设计"]
B --> E["指导编码实现"]
领域模型一旦确立,业务架构与软件架构就形成了绑定关系:软件架构面向业务领域,能够快速响应业务变化。
领域模型的开发步骤
领域模型指导代码设计,所以在写代码之前必须先验证模型。完整的开发步骤是:
- 设计概念模型:仅包含概念及概念之间的关系,可通过动名词法或事件风暴得出
- 设计逻辑模型:为概念补充属性和行为
- 验证逻辑模型:基于业务场景,以协作图的形式验证模型之间的协作关系
- 实现物理模型:将领域模型实现为代码,并考虑模型的持久化存储
- 提取新概念:在分析过程中通过名词、动词的关系持续产生新概念
举个例子:分析"顾客购买商品"和"顾客收藏商品"这两个业务描述,"购买"这个动词会产生"订单"概念,"收藏"会产生"收藏"概念——名词和动词分析是发现领域概念的基本功。
事件风暴(Event Storming)则是另一种高效的建模方法:它是基于领域事件的头脑风暴,通过讨论领域中已经发生的事件(如"订单已创建"、"支付已完成")来发现业务流程、发掘领域模型。
限界上下文:模型的语义边界
限界上下文(Bounded Context)是 DDD 战略设计中最重要的概念。为什么需要它?
1. 消除通用语言的二义性。 同样的名词在不同上下文中有不同含义。例如 Account 在用户上下文中代表用户主体,在支付上下文中代表银行账号。通用语言只有在限定的上下文中才有准确含义。
2. 业务关注点不同。 在电商系统中,顾客浏览商品目录时,关注的是购买记录、忠诚度、会员等级、产品偏好;而顾客下单时,关注的是姓名、订单总价、收货地址。同一个"顾客",两种场景下的模型完全不同。
3. 控制模型本身的复杂度。 供应链系统中,商品在采购、订单、运输、库存四个视角下有完全不同的关注点。把所有视角塞进一个 Product 模型,只会得到一个无法维护的怪物。
4. 防止大泥球。 不停向一个模型注入更多概念,描述语言的模型就会变得模糊不清,最终变成 Big Ball of Mud。
如何划分限界上下文
划分可以从三个层面考虑:
逻辑层面:遵循单一职责原则(SRP)——一个上下文应该只有一个引起它变化的原因。采购、订单、库存、运输各自因不同的业务场景变化,就应该拆到不同的限界上下文中。按照问题域和业务流程自顶向下划分是最常用的方法。
团队协作层面:康威定律指出,组织的沟通结构会决定系统的结构。实践中推荐一个团队负责一个限界上下文;如果团队过大导致沟通效率低,就先拆团队,再拆上下文。Amazon 的"两个披萨团队"(Two Pizza Team)原则——团队规模控制在两个披萨能喂饱的人数——是很好的参考。
技术层面(质量属性取舍):非功能需求也会驱动上下文拆分。例如:
- 新业务"秒杀"有可预期的高并发,技术手段与普通订单完全不同,就专门拆出一个"秒杀"限界上下文
- 数据分析有"实时性"要求,且不能影响在线业务,就拆出基于不同技术栈的"分析"上下文(OLTP 与 OLAP 分离)
限界上下文的集成:团队协作模式
上下文之间不是孤岛,需要集成。团队层面的协作模式有四种:
| 协作模式 | 说明 |
|---|---|
| 合作关系 | 两个团队紧密绑定,一起成功一起失败。这是紧耦合的坏味道,往往意味着上下文划分出了问题 |
| 共享内核 | 多个团队共享一个小规模但通用的模型,改动需要共同协商 |
| 客户-供应商 | 供应商在上游、客户在下游,由供应商主导,客户需与供应商共同制定规划 |
| 跟随者 | 上游不会为下游做修改(或上游模型非常稳定),下游只能遵从,例如集成亚马逊电商的开放模型 |
限界上下文的集成:通信模式
技术层面的集成模式主要有三个:
- 开放主机服务(OHS, Open Host Service):上下文以 URI 方式提供大量 REST 资源,对外暴露标准化的服务协议
- 防腐层(ACL, Anti-Corruption Layer):最具防御性的集成模式,下游通过翻译层隔离上游模型变化带来的影响,防止上游模型"腐蚀"自己的领域模型
- 发布语言(Published Language):通常与 OHS 配合使用,将上游模型做简化、脱敏后发布,作为上下文之间的公共语言
OHS 和 ACL 与 API Gateway 是绝配:上游根据发布语言把 API 发布成不同版本,下游通过网关的转换功能隔离上游变化的影响。
跨上下文的分布式事务则常用 Saga 模式,它有两种实现:
- 编配(Choreography):基于事件-订阅模式,各上下文监听事件并自发响应,如使用消息/EventBridge 类的发布订阅服务
- 编排(Orchestration):由一个集中的编排器统一调度业务流程,如使用 Step Function 类的工作流服务
最后,用一张限界上下文映射图(Context Map)展现系统全景,标注各上下文之间的关系与集成模式:
graph LR
subgraph SG["电商系统"]
A["订单上下文"] -->|"客户-供应商"| B["商品上下文"]
A -->|"ACL 防腐层"| C["支付上下文"]
A -->|"发布事件"| D["物流上下文"]
E["秒杀上下文"] -->|"共享内核"| A
end
注意限界上下文与微服务的关系:限界上下文描述的是逻辑边界划分,微服务描述的是物理部署单元。限界上下文是划分微服务的逻辑依据,但两者不是一一对应的必然关系。
战术设计
战略设计解决了"边界在哪"的问题,战术设计解决的是"边界之内怎么写代码"。
三种架构模式
分层架构:经典的关注点分离手段,把系统分为接口层、应用层、领域层、基础设施层。限界上下文之内不仅仅是领域模型,还有输入输出、持久化等非领域关注点,分层就是用来隔离它们的。
端口-适配器架构(六边形架构):分为内部区域和外部区域。外部是不同的客户端输入和持久化、消息等输出;内部是纯粹的领域逻辑。外部通过"端口"与内部交互,通过"适配器"适配各种具体技术。
整洁架构(Clean Architecture):规定源代码只能向内依赖,最内层是领域模型,内层对外层一无所知。因为领域模型不依赖任何外部层,所以它能够直接进行单元测试:
graph TD
A["接口层 UI / API"] --> B["应用层"]
B --> C["领域层(核心)"]
D["基础设施层"] --> C
style C fill:#f96,stroke:#333
三种架构殊途同归:业务逻辑集中在领域层,其他层都依赖它。
领域模型的构件
领域模型旨在处理复杂业务逻辑,它关心业务问题(业务状态变化、业务规则),不关心 CRUD 这类技术问题。它是包含行为和数据的领域对象模型——缺乏行为的模型会退化为活动记录(Active Record),也就是常说的"贫血模型"。
领域模型的核心构件:
- 实体(Entity):有唯一身份标识,拥有生命周期,具有可变性
- 值对象(Value Object):用于描述实体,具有不可变性。例如 Money 对象包含金额和货币符号两个属性
- 聚合根(Aggregate Root):实体中的根实体,控制着聚合内所有其他元素的访问入口
- 领域事件(Domain Event):领域活动中发生的事情,以领域对象形式建模,例如 OrderStarted
- 领域服务(Domain Service):实体、值对象无法承担的职责落到领域服务,抽象的是业务行为,如 OrderValidator、OrderCreatingService
- 资料库(Repository):定义实体的生命周期管理接口,也负责生成实体唯一 ID
各层的职责划分:
| 层 | 职责 |
|---|---|
| 接口层 | UI 及 API,对输入做验证 |
| 应用层 | 非常薄,只做领域服务的编排,包含事务控制、权限控制等非领域逻辑,甚至不包括验证 |
| 领域层 | 全部业务逻辑,实体和值对象都要有丰富的行为 |
| 基础设施层 | Repository 的实现、消息发送、外部服务调用等 |
另外,DTO(Data Transfer Object) 存在于应用层,它可以叫 Request/Response、Command/Result、VO 等名字。它接收 API 层的输入,将其转化为实体或值对象后输入给领域层——领域对象不应该直接暴露给外部。
代码示例:贫血模型 vs 充血模型
用一个订单聚合来对比两种写法。先看典型的贫血模型:
// 贫血模型:实体只有 getter/setter,业务逻辑散落在 Service 里
public class Order {
private Long id;
private List<OrderItem> items;
private BigDecimal totalAmount;
private String status;
// 一堆 getter/setter...
}
public class OrderService {
public void addItem(Order order, Product product, int count) {
// 业务规则全堆在这里:计算金额、判断状态...
if (!"CREATED".equals(order.getStatus())) {
throw new IllegalStateException("订单已提交,不能修改");
}
OrderItem item = new OrderItem();
item.setPrice(product.getPrice());
item.setCount(count);
order.getItems().add(item);
BigDecimal total = order.getItems().stream()
.map(i -> i.getPrice().multiply(new BigDecimal(i.getCount())))
.reduce(BigDecimal.ZERO, BigDecimal::add);
order.setTotalAmount(total);
}
}
充血模型把行为还给领域对象:
// 值对象:不可变
public record Money(BigDecimal amount, String currency) {
public Money add(Money other) {
if (!this.currency.equals(other.currency)) {
throw new IllegalArgumentException("货币类型不一致");
}
return new Money(this.amount.add(other.amount), this.currency);
}
}
// 聚合根:唯一入口,行为丰富
public class Order {
private final OrderId id;
private final List<OrderItem> items = new ArrayList<>();
private OrderStatus status = OrderStatus.CREATED;
// 业务行为内聚在聚合根中
public void addItem(Product product, int count) {
if (this.status != OrderStatus.CREATED) {
throw new IllegalStateException("订单已提交,不能修改");
}
items.add(new OrderItem(product.id(), product.price(), count));
}
public Money totalAmount() {
return items.stream()
.map(OrderItem::subtotal)
.reduce(new Money(BigDecimal.ZERO, "CNY"), Money::add);
}
public void submit() {
if (items.isEmpty()) {
throw new IllegalStateException("空订单不能提交");
}
this.status = OrderStatus.SUBMITTED;
// 产生领域事件
DomainEvents.raise(new OrderSubmitted(this.id));
}
}
好处很直接:业务规则只有一份,就在领域对象身上,单元测试不需要启动 Spring、不需要 mock 数据库,直接 new 一个 Order 就能测。
CQRS:读写分离
跨上下文查询的困境
把模型拆到不同的限界上下文之后,一个现实的问题浮出水面:查询怎么办?拆分前一句 SQL JOIN 就能搞定的事,现在数据散落在不同的上下文中。比如"根据商品名称查订单"——商品在商品上下文,订单在订单上下文,订单表里只有商品 ID。
常见的做法有三种,各有明显的优劣:
| 做法 | 说明 | 优点 | 缺点 |
|---|---|---|---|
| 同步调用 | 订单上下文先调商品上下文,按名称查出商品 ID,再回头查订单 | 实现直接,数据始终一致 | 运行时强耦合:商品服务抖动直接拖垮订单查询;两次调用延迟叠加;跨服务翻页、排序几乎没法做 |
| 数据冗余 | 下单时把商品名称快照存进订单表,查询时自给自足;或者大家都依赖一个共同的数据上下文 | 无运行时依赖,查询性能好 | 冗余字段与源数据可能不一致——商品改名后老订单显示什么,要先想清楚这是 feature 还是 bug;快照字段多了之后维护成本上升 |
| CQRS 投影 | 监听各领域事件,把多个上下文的数据预先聚合成一张"面向查询"的读模型 | 查询自由度高,上下文间彻底解耦,读写可独立扩展 | 引入最终一致性;多一套读模型和同步链路,系统复杂度上升 |
实际项目里这三种手段往往是混用的:强一致要求的走同步调用,天然带快照语义的字段(如订单上的商品名、成交单价)走数据冗余,复杂列表页和搜索场景走 CQRS。
为什么要把读和写分开
CQRS(Command Query Responsibility Segregation,命令查询职责分离)给出的答案更彻底:干脆把写模型和读模型分成两套。为什么值得这么做?因为读写双方的需求本质上是冲突的:
- 写模型为业务规则服务。聚合设计的一切——聚合根唯一入口、不变量保护、细粒度的实体关系——都是为了保证业务一致性。它擅长"精确地修改状态",而不是"灵活地查询数据"。
- 查询为展示服务。列表页要的是跨聚合、跨上下文的扁平数据,外加翻页、排序、过滤、全文搜索。用领域模型做查询,就得加载一堆聚合只为取其中几个字段,既笨拙又慢。
让一个模型同时讨好这两个方向,结果往往是两头不讨好:查询代码被聚合边界捆住手脚,领域模型又被各种查询专用的 getter 撑得臃肿。读写分离之后,写模型保持纯粹,读模型可以完全面向查询场景优化,甚至使用与写侧完全不同的存储引擎。
CQRS 的落地形态
读写分离是一个光谱,由简到繁有几种形态:
- 同库不同模型:读写共用一个数据库,读侧绕过领域模型直接写 SQL 或查视图。这是性价比最高的起点,很多系统到这一步就够了。
- 同库 + 物化视图:用数据库的视图、物化视图做投影(Projection),把写模型预先映射成读模型,刷新策略交给数据库保证。这也是最朴素的投影方式。
- 异构读写库:写库是关系型数据库(如 RDS),读库是搜索引擎(如 OpenSearch)、缓存或列式存储,通过领域事件驱动投影更新。不同存储引擎各取所长,这是 CQRS 的完全体。
完全体的典型架构:
graph LR
A["命令侧<br/>聚合 + 领域服务"] -->|"写"| B["写库<br/>RDS"]
B -->|"领域事件"| C["消息总线"]
C -->|"投影"| D["读库<br/>OpenSearch"]
E["查询侧<br/>Query Service"] -->|"读"| D
代价与适用边界
CQRS 不是免费的午餐,引入前要想清楚三笔账:
- 最终一致性:写入后读模型有几毫秒到几秒的滞后,交互上要为此设计(比如下单成功后跳详情页而不是列表页)。
- 运维复杂度:多一套存储、多一条同步链路,就要多一份监控和故障预案。
- 过度设计风险:以 CRUD 为主的简单上下文,同库不同模型甚至直接查领域模型就足够了;完整的异构 CQRS,只在读写负载悬殊、查询场景复杂的核心上下文里才值得上。
事件驱动架构(EDA)
事件驱动架构是基于生产者-消费者模式构建的架构模式,分两个层次:
- 进程内(同一限界上下文内)的事件驱动
- 进程间(不同限界上下文间)的事件驱动
领域事件让业务逻辑与技术细节解耦,提高代码的可维护性和可扩展性。实践中有两个要点:
- 领域事件监听器放在应用层:它只负责接收事件、将事件转化成领域对象、调用领域服务、管理事务
- 事件的发送方与接收方在同一个事务中(进程内场景),保证一致性
领域事件也是 CQRS 和 EDA 的基础——它让领域对象之间、限界上下文之间的交互更加松耦合。
总结
回过头看,DDD 本质上是一门控制复杂度的学问,它针对复杂度的五种来源各给出了一件武器:
| 复杂度来源 | DDD 的对策 |
|---|---|
| 知识领域本身的复杂度 | 用领域模型抽象业务知识 |
| 业务问题规模的复杂度 | 用子域划分分而治之 |
| 解决方案规模的复杂度 | 用限界上下文划定边界 |
| 变化带来的复杂度 | 用限界上下文控制业务变化的影响,用分层设计隔离业务与非业务问题 |
| 团队规模的复杂度 | 用团队协作模式(客户-供应商、跟随者等)理顺协作关系 |
最后推荐两本经典书籍:Eric Evans 的《领域驱动设计:软件核心复杂性应对之道》(原著),以及 Vaughn Vernon 的《实现领域驱动设计》(更偏工程落地,读起来也更友好)。如果是团队初次实践,建议从一个小型核心子域开始试点,先跑通"事件风暴建模 → 限界上下文划分 → 聚合设计"的完整闭环,再逐步推广。