前言
如果你写过几年业务代码,一定经历过这种痛苦:需求越来越复杂,代码越来越臃肿,Service 层动辄几千行,一个改动能牵连半个项目。你开始怀疑——是不是从一开始,我们的代码组织方式就有问题?
DDD(Domain-Driven Design,领域驱动设计)就是在这种”代码越来越像屎山”的背景下,给出的一套解法。
这篇文章不是要给你灌输教条,而是务实地聊聊:DDD 到底是什么、它解决了什么问题、哪些东西值得你学、以及怎么落地。
DDD 是什么
2003 年,Eric Evans 出版了《Domain-Driven Design: Tackling Complexity in the Heart of Software》这本书,正式提出了 DDD 的概念。
一句话总结 DDD 的核心思想:以业务领域为中心驱动软件设计,而不是以技术框架或数据库为中心。
这听起来像是一句正确的废话,但你想想我们平时是怎么写代码的:拿到需求,先想建什么表、用什么框架、分几层——很少会先坐下来,认真梳理业务领域的核心概念和它们之间的关系。
DDD 认为,软件复杂性的根源在于业务本身的复杂性,而不是技术。你试图用技术的”简洁”去掩盖业务的”复杂”,结果就是代码和业务越来越脱节,最终变成一座谁也不敢动的屎山。
DDD 提供了一套从战略到战术的方法论,帮助你把业务领域的复杂性”管理”起来,而不是”逃避”。
为什么要用 DDD
先说痛点。如果你中了两条以上,DDD 值得认真了解一下:
- 业务逻辑散落各处:同一个业务规则,在 Controller 里写一份,在 Service 里写一份,在 SQL 里又写一份,到底以哪个为准?
- 模型贫血:实体类只有一堆 getter/setter,所有业务逻辑都在 Service 里,实体变成了”数据搬运工”。
- 沟通鸿沟:产品说的”订单”和开发写的
Order类,含义完全不同,各说各话。 - 牵一发动全身:改一个功能,影响十个模块,没人敢碰。
- 新人上手难:代码结构不能反映业务结构,新人靠读代码理解业务,效率极低。
DDD 的核心价值在于:
- 让代码结构反映业务结构。核心业务逻辑封装在领域层,不依赖任何基础设施,稳定且可测试。
- 统一语言(Ubiquitous Language)。产品、开发、测试说同一种”语言”,消除沟通歧义。
- 控制复杂性的传播。通过限界上下文划定边界,一个上下文内的复杂性不会泄漏到外部。
战略设计:先想清楚全局
战略设计是 DDD 的”宏观架构”部分,解决的是”系统怎么拆、边界在哪”的问题。
子域划分
DDD 认为,一个复杂的业务系统可以拆分为多个子域(Subdomain),按重要性分为三类:
核心子域(Core Domain):这是你的业务核心竞争力,是区别于竞品的关键。比如电商系统的”订单履约”、物流系统的”路径规划”。核心子域值得投入最优秀的团队、最精细的设计。
支撑子域(Supporting Subdomain):不是核心竞争力,但支撑核心业务运转。比如电商的”商品管理”、“库存管理”。它们有业务特殊性,但不需要像核心子域那样极致优化。
通用子域(Generic Subdomain):通用能力,没有业务特殊性。比如”用户认证”、“消息通知”、“日志记录”。这些能买就买,能用开源就用开源,不要自己造轮子。
我的建议:把 80% 的精力放在核心子域上。通用子域自己写,是许多团队最容易犯的错。
限界上下文(Bounded Context)
这是 DDD 中最重要的概念,没有之一。
限界上下文是一个语义边界。在同一个限界上下文内,每个术语都有明确且唯一的含义。不同的上下文中,同一个术语可以有不同的含义。
举个例子:
- 商品上下文中,“商品”有 SKU、价格、库存、详情页等属性。
- 订单上下文中,“商品”只需要知道商品 ID、名称、单价、数量。
- 物流上下文中,“商品”关心的是重量、体积、是否需要冷链。
这三个上下文中的”商品”不是同一个东西!如果不划定边界,你就会搞出一个”万能商品类”,包含几十个字段,谁看了都头疼。
限界上下文的本质是:承认一个概念在不同业务视角下有不同的含义,并且用明确的边界把它们隔离开。
上下文映射(Context Map)
上下文有了,它们之间怎么交互?这就是上下文映射要解决的问题。
常见的映射模式:
| 模式 | 含义 | 适用场景 |
|---|---|---|
| 共享内核(Shared Kernel) | 两个上下文共享一小部分模型 | 两个团队紧密协作时 |
| 客户-供应商(Customer-Supplier) | 上游提供,下游消费,有协商 | 团队有依赖关系时 |
| 遵从者(Conformist) | 下游完全遵循上游的模型,没有协商权 | 对接外部系统、遗留系统时 |
| 防腐层(ACL) | 在下游走加一层翻译,隔离上游模型 | 对接不可控的外部系统时 |
| 开放主机服务(OHS) | 上游提供标准化的 API 协议 | 多个下游需要对接时 |
| 发布语言(Published Language) | 用标准格式(JSON Schema、Protobuf)定义 API | 配合 OHS 使用 |
其中防腐层(Anti-Corruption Layer) 是实战中用得最多的。当你不得不依赖一个设计糟糕的外部系统时,不要直接让它的模型侵入你的代码,而是在中间加一层翻译。
战术设计:落地到代码
战略设计想清楚了”怎么拆”,战术设计解决的是”每个上下文内部怎么写”。
实体(Entity)
实体是有唯一标识的领域对象,标识它的身份,而不是它的属性。
public class Order {
private OrderId id; // 唯一标识
private CustomerId customerId;
private Money totalAmount;
private OrderStatus status;
// 业务行为封装在实体内部
public void confirm() {
if (this.status != OrderStatus.PENDING) {
throw new OrderStateException("只有待确认的订单才能确认");
}
this.status = OrderStatus.CONFIRMED;
// 注册领域事件
this.registerEvent(new OrderConfirmedEvent(this.id));
}
}
注意,业务逻辑(confirm() 方法)封装在实体内部,而不是扔到一个 OrderService 里。这是 DDD 和传统三层架构最大的区别。
值对象(Value Object)
值对象没有唯一标识,通过属性值来相等性判断。它描述的是”什么”,而不是”哪个”。
public class Money {
private final BigDecimal amount;
private final Currency currency;
// 不可变对象
public Money add(Money other) {
assertSameCurrency(other);
return new Money(this.amount.add(other.amount), this.currency);
}
}
值对象的核心特征:
- 不可变:创建后不能修改,只能替换。
- 通过值相等:
Money(100, CNY)等于另一个Money(100, CNY)。 - 可以描述业务概念:把”金额”从原始类型
BigDecimal提升为Money,赋予它业务含义和行为。
很多团队只写实体不写值对象,结果就是大量业务逻辑散落在 Service 里。值对象是 DDD 中最被低估的建模工具。
聚合(Aggregate)与聚合根
聚合是一组紧密相关的实体和值对象的集合,对外通过一个聚合根(Aggregate Root) 来访问。
聚合的核心规则:
- 外部只能通过聚合根引用聚合。不能绕过聚合根直接操作内部对象。
- 聚合内部保持一致性。聚合根负责在每次操作中保证业务不变量(Invariant)。
- 聚合之间通过 ID 引用,不持有对方的对象引用。
- 一个事务只修改一个聚合。跨聚合的一致性通过领域事件实现最终一致。
聚合的设计原则是小——边界越小,并发冲突越少,性能越好。判断一个对象是否应该在同一个聚合内,就看它们之间是否有强一致性的业务约束。
领域服务与应用服务
领域服务(Domain Service):当某个业务操作不属于任何一个实体或值对象时,把它放在领域服务中。领域服务属于领域层,包含真正的业务逻辑。
// 转账操作涉及两个账户,不属于单个 Account 实体
public class TransferService {
public void transfer(Account from, Account to, Money amount) {
from.debit(amount);
to.credit(amount);
}
}
应用服务(Application Service):薄薄的协调层,负责获取输入、调用领域逻辑、发送事件、持久化。不包含业务逻辑。
public class TransferAppService {
@Transactional
public void transfer(TransferCommand cmd) {
Account from = accountRepository.findById(cmd.getFromId());
Account to = accountRepository.findById(cmd.getToId());
transferService.transfer(from, to, new Money(cmd.getAmount()));
accountRepository.save(from);
accountRepository.save(to);
}
}
仓储(Repository)
仓储为聚合提供持久化抽象,屏蔽底层存储细节。
public interface OrderRepository {
Order findById(OrderId id);
void save(Order order);
void remove(Order order);
}
几个关键点:
- 每个聚合一个 Repository,不为内部实体单独建 Repository。
- Repository 的接口定义在领域层,实现在基础设施层。这就是依赖倒置。
- Repository 返回的是聚合的完整对象图,而不是让调用方自己去拼装。
领域事件(Domain Event)
领域事件表示”领域中发生了一件重要的事”。它是聚合之间通信的主要方式。
// 领域事件定义在领域层
public class OrderCreatedEvent {
private final OrderId orderId;
private final CustomerId customerId;
private final Money totalAmount;
private final LocalDateTime occurredAt;
}
// 在实体中注册事件
public class Order {
private List<DomainEvent> events = new ArrayList<>();
public void create(CustomerId customerId, List<OrderItem> items) {
// ... 业务逻辑
this.events.add(new OrderCreatedEvent(this.id, customerId, totalAmount, now()));
}
}
领域事件的价值:
- 解耦聚合:聚合之间不直接调用,而是通过事件异步通信。
- 表达业务语义:
OrderCreatedEvent比orderMapper.insert(order)有意义得多。 - 支持扩展:新增业务逻辑(如”下单后发积分”),只需新增一个事件监听器,不改原有代码。
DDD 的适用场景
DDD 不是银弹,它有明确的适用边界。
适合用 DDD 的场景:
- 业务逻辑复杂,规则多变。比如电商、金融、医疗、物流。
- 业务模型需要长期演进,不是一次性项目。
- 团队规模较大,需要清晰的边界划分来支持并行开发。
- 产品和开发之间经常因为”这个字段什么意思”扯皮。
不适合用 DDD 的场景:
- 简单的 CRUD 应用。如果你的系统就是增删改查,用 DDD 是过度设计。
- 以数据为中心的系统。比如报表系统、数据分析平台,业务逻辑很少。
- 小项目、短生命周期项目。DDD 的前期投入不小,项目太小不划算。
- 团队对 DDD 完全没有了解且没有学习意愿。强推 DDD 只会制造混乱。
一个务实的判断标准:如果你的系统用三层架构 + Service 就能搞定,别折腾 DDD。等到复杂度真的失控了再引入也不迟。
DDD 与微服务
DDD 和微服务是天作之合。准确地说,DDD 的限界上下文是微服务拆分的最佳指导原则。
很多团队拆微服务的方式是”按技术层拆”:一个前端服务、一个 UserService、一个 OrderService、一个 ProductService……拆完之后发现,一个业务操作要在五六个服务之间调用,分布式事务搞到崩溃。
正确的做法是按限界上下文拆:
- 先用 DDD 战略设计识别出所有的限界上下文。
- 每个限界上下文对应一个微服务(或者几个紧密相关的上下文合并为一个服务)。
- 上下文之间通过 API 或事件通信,内部实现完全自治。
这样做的好处:
- 每个服务有自己的数据模型。订单服务不需要关心商品有多少个字段,它只知道自己需要的信息。
- 团队可以独立开发和部署。每个限界上下文由一个团队负责,边界清晰。
- 数据一致性边界明确。同一个聚合内强一致,不同聚合(不同服务)之间最终一致。
没有 DDD 指导的微服务拆分,就像没有地图的旅行——你走得很快,但方向可能是错的。
从 0 落地 DDD 的实践建议
说了这么多理论,怎么落地?以下是我总结的渐进式实践路径:
第一步:事件风暴(Event Storming)
别一上来就写代码。把产品、开发、测试拉到一起,用事件风暴的方式梳理业务流程:
- 用橙色便利贴写出业务中发生的领域事件(过去时态,如”订单已创建”)。
- 用蓝色便利贴写出触发事件的命令。
- 用黄色便利贴写出涉及的聚合。
- 用绿色便利贴写出需要的读模型。
这个过程会帮你快速识别出限界上下文、聚合、领域事件这些核心概念。
第二步:从值对象开始
不要一上来就搞完整的 DDD 分层架构,太重了。先从引入值对象开始:
- 把
String name变成UserName类 - 把
BigDecimal amount变成Money类 - 把
String phone变成PhoneNumber类
这一步几乎零成本,但立刻让代码的业务表达力提升一个档次。
第三步:让实体充血
把 Service 里的业务逻辑逐步迁移回实体和值对象中。实体不应该只是数据容器,它应该有自己的行为。
这个过程需要耐心,因为你要对抗”所有逻辑都在 Service 里”的肌肉记忆。
第四步:引入仓储模式
把数据访问从 Service 中抽离出来,定义 Repository 接口。接口在领域层,实现在基础设施层。
这一步做完,你的领域层就不再依赖数据库了。
第五步:引入领域事件
当聚合之间需要通信时,用领域事件替代直接调用。这是实现聚合解耦的关键。
第六步:划定限界上下文
当系统足够复杂时,开始用限界上下文来划分模块边界。不同的上下文可以有各自的数据模型,通过上下文映射模式来通信。
不需要一步到位。DDD 是一个渐进的过程,每一步都有独立的价值。
结语
DDD 不是一套必须严格遵守的规范,而是一种思考方式——它教你从业务领域的角度去组织代码,而不是从技术的角度。
最重要的不是你会不会画 UML 图,而是你在写代码之前,有没有认真想过:这段业务的核心概念是什么?它们之间的关系是什么?边界在哪?
如果你正在被复杂业务代码折磨,不妨试试 DDD 的思路。不需要全盘照搬,哪怕只是引入值对象、让实体充血、用领域事件解耦,都能让你的代码质量提升一大截。
最后推荐几本入门读物:
- Eric Evans《Domain-Driven Design》(DDD 圣经,偏理论)
- Vaughn Vernon《Implementing Domain-Driven Design》(实战导向,更推荐)
- Vaughn Vernon《Domain-Driven Design Distilled》(DDD 精华浓缩版,适合快速入门)
欢迎在评论区留下您的见解~