DDD 实践入门
DDD 实践入门
领域驱动设计(Domain-Driven Design, DDD)由 Eric Evans 提出,主张把复杂业务的核心——领域模型——放到架构的中心,用「统一语言」连接业务与技术。本文梳理 DDD 的核心概念与落地方法。
为什么需要 DDD
业务系统的一大痛点是「业务复杂度」:需求频繁变化、规则彼此纠缠、术语在业务与开发之间不一致。传统的「贫血模型 + 一堆 Service」会把业务逻辑散落到各处,随着复杂度上升,代码越来越难维护。
DDD 的思路是:把复杂的业务逻辑收敛到一个清晰的领域模型里,让领域模型成为业务规则的唯一表达,并用一套业务与技术都能理解的术语来沟通。它分为两部分:
- 战略设计:划分系统边界,确定限界上下文与统一语言。
- 战术设计:在边界内搭建实体、值对象、聚合、领域服务、仓储与领域事件等构件。
贫血模型与充血模型
理解 DDD,先要理解两种建模风格的差别。贫血模型只把对象当作数据的容器:一列属性加 getter/setter,业务逻辑全部堆在 Service 里。它简单直接,但在复杂业务下会让规则散落各处,同一个业务概念被拆到多个 Service,改一处容易漏一处。
充血模型则把与数据紧密相关的业务规则放回对象内部,让对象既持有数据、也表达行为。DDD 的实体、值对象、聚合都是充血模型:例如「订单」自己负责判断「只有创建态才能支付」,而不是让外部的 Service 去检查。充血模型让业务规则有唯一归属,也更贴近领域专家的语言。
战略设计
核心域、支撑域与通用域
并非所有子领域都值得同等投入。DDD 把领域划分为三类:
- 核心域(Core Domain):决定业务竞争力的部分,是投入 DDD 的重点。
- 支撑域(Supporting Domain):为核心域服务、但本身不构成竞争力的业务,适度建模即可。
- 通用域(Generic Domain):行业通用能力(如认证、短信),优先采购或复用现成方案,不必自行精雕细琢。
把精力集中在核心域,是 DDD 落地时最容易忽视、却最关键的取舍。
统一语言(Ubiquitous Language)
统一语言指在一个限界上下文内,业务人员与开发人员共用同一套术语,并在代码中一字不差地使用。例如业务说「订单」,代码里就不要再一会儿 Order、一会儿 Trade。统一语言不是一份静态文档,而是随模型演进、持续维护的共同词汇表。
限界上下文(Bounded Context)
限界上下文是一个模型适用的边界。同一个概念在不同上下文中含义可能不同:电商里的「商品」在「商品目录上下文」关注描述与价格,在「库存上下文」关注数量与仓位。DDD 强调每个上下文内的模型自洽,跨上下文通过定义良好的接口协作。
上下文映射(Context Map)
上下文映射描述多个限界上下文之间的关系与集成方式,常见模式包括:
- 共享内核(Shared Kernel):两个上下文共享一小部分模型。
- 客户/供应商(Customer/Supplier):下游依赖上游,但可以向上游提出需求。
- 防腐层(Anticorruption Layer, ACL):在下游加一层适配,隔离上游模型对本上下文的侵入。
- 发布语言(Published Language):用统一的、文档化的数据格式交换信息。
战术设计
实体(Entity)
实体是具有唯一标识、生命周期内标识不变的对象。例如「订单」有订单号、「用户」有用户 ID,即使属性变化,只要标识不变就仍是同一个实体。实体之间通过标识判断相等性,而不是通过属性。
值对象(Value Object)
值对象是没有标识、由属性值定义、通常不可变的对象,例如金额、颜色、地址、日期区间。判断两个值对象相等看属性是否相同。值对象应当不可变——需要「修改」时直接创建新实例,这天然规避了共享可变状态带来的并发问题。
聚合与聚合根(Aggregate / Aggregate Root)
聚合是一组具有一致性边界的实体与值对象的集合,聚合根是聚合对外访问的唯一入口。核心约束:
- 外部只能通过聚合根访问聚合内的对象;
- 一个事务只修改一个聚合,聚合内的一致性由事务保证,聚合之间最终一致。
聚合是 DDD 中最容易出错的构件:聚合过大会导致并发冲突与性能问题,聚合过小又会丢失一致性保证,需要结合业务不变量仔细划分。
领域服务(Domain Service)
当某个业务操作不属于任何实体或值对象(例如「转账」涉及两个账户),可以把它放到领域服务中。领域服务表达的是领域概念,而不是「用来堆放业务逻辑的 Service 类」——后者恰恰是 DDD 要避免的贫血模型。
仓储(Repository)
仓储为聚合提供持久化的抽象,让调用方像操作内存集合一样操作聚合:对外暴露 findById、save 等方法,把数据库细节封装在实现里。注意:仓储以聚合为单位,不要为聚合内的每个实体都建一个仓储。
领域事件(Domain Event)
领域事件表示领域中已经发生的事实,如「订单已创建」「支付已完成」。它用于解耦聚合之间的协作:聚合产生事件,其他聚合订阅并响应,从而实现最终一致。领域事件用过去式命名(OrderPaidEvent),语义是「已发生」而非「请求」。
工厂(Factory)
当聚合或实体的创建过程复杂时,可以用工厂封装创建逻辑,保证创建出来的对象满足领域不变量。工厂与仓储的区别是:工厂负责「新建并装配」,仓储负责「持久化与重建」。
代码落地示例
以一个简化订单为例,展示聚合根与值对象:
// 值对象:金额,不可变
public final class Money {
private final long cents; // 以分为单位,避免浮点误差
public Money(long cents) {
if (cents < 0) throw new IllegalArgumentException("金额不能为负");
this.cents = cents;
}
public Money add(Money other) {
return new Money(this.cents + other.cents);
}
// 省略 equals / hashCode
}
// 聚合根:订单
public class Order {
private final OrderId id;
private Money total;
private OrderStatus status;
public Order(OrderId id) {
this.id = id;
this.total = new Money(0);
this.status = OrderStatus.CREATED;
}
// 业务方法:表达领域规则,而不是简单的 setter
public void addItem(Money price) {
if (status != OrderStatus.CREATED) {
throw new IllegalStateException("只有创建态订单可以加项");
}
this.total = this.total.add(price);
}
public void pay() {
if (status != OrderStatus.CREATED) {
throw new IllegalStateException("订单状态不允许支付");
}
this.status = OrderStatus.PAID;
// 记录领域事件:订单已支付
DomainEvents.publish(new OrderPaidEvent(this.id));
}
}关键点:业务规则写在聚合根的方法里(充血模型),调用方通过方法表达意图,而不是直接读写属性。
实践要点与常见坑
- 先统一语言,再写代码:术语不一致,模型一定会走形。
- 聚合不要太大:一个聚合尽量只包含必须强一致的少量对象,避免高并发下的锁冲突。
- 事务边界落在聚合内:跨聚合用领域事件实现最终一致,而不是分布式大事务。
- 贫血模型不是 DDD:把 getter/setter 堆满、逻辑全在 Service,等于没用 DDD。
- DDD 与微服务拆分互为印证:先划清领域边界,再决定服务边界,而不是为拆而拆。
- 不要全盘 DDD:只有核心域的复杂业务才值得投入,简单 CRUD 用 DDD 反而是过度设计。
何时不该用 DDD
DDD 的代价是抽象成本与学习成本,它并不适合所有项目。以下场景通常不必引入:
- 业务逻辑简单、以增删改查为主的管理系统;
- 需求高度稳定、几乎没有复杂规则的工单类系统;
- 团队缺乏与业务方深入沟通的条件、无法建立统一语言时。
判断标准很简单:只有核心域的持续复杂度值得用 DDD 去对冲。
小结
- DDD 用统一语言连接业务与技术,分为战略设计与战术设计两部分。
- 战略设计划边界:统一语言、限界上下文、上下文映射。
- 战术设计搭模型:实体、值对象、聚合、领域服务、仓储、领域事件。
- 落地时事务落在聚合内,跨聚合用领域事件最终一致,聚合不宜过大。
- 与微服务拆分互相印证,避免为模式而模式的过度设计。
参见 开发流程总览、测试驱动开发(TDD)。