搞过几年后端,说实话大多数项目最后都变成了“CRUD 堆砌工程”,业务逻辑散落在 Service 里,改一个需求牵一发动全身。直到我完整地把 DDD(领域驱动设计)落地到一个真实的订单系统项目里,才真正体会到“代码跟着业务走”是什么感觉。这篇不是理论复述,是我从战略设计到战术建模、再到代码实现全流程的实操记录,包括我觉得容易困惑的地方和踩过的坑,希望对正在学习 DDD、或者准备在项目里引入 DDD 的同学有实际帮助。
1. 项目整体设计与思路拆解
1.1 传统开发模式到底哪里出了问题
在引入 DDD 之前,我们团队和大多数团队一样,用的是经典的“三层架构”:Controller、Service、DAO。表面上看职责清晰,但随着业务迭代,几个问题会越来越明显:
- Service 层越来越胖。一个下单接口的 Service 方法动辄两三百行,里面混杂了校验、算价、库存扣减、优惠计算、消息发送,后续再加需求,几乎只能靠 if else 堆。
- 业务规则散落各处。比如“订单金额必须大于 0”“优惠券不能叠加使用”这类规则,有的写在 Controller 里,有的写在 Service 里,还有的写在 SQL 里,谁也说不清标准答案在哪。
- 技术细节污染业务逻辑。Service 里直接操作 redis、mq、数据库事务,导致业务逻辑和技术实现深度耦合,换一个中间件就要改核心代码。
我印象最深的一次:客户要求“下单时同一商品数量不能超过 5 件”,本来是个几分钟的改动,结果因为不知道这个规则散落在三个地方,漏改了一处,线上出了资损事故。没有统一的模型,就没有统一约束的落脚点。
1.2 DDD 的设计思路:先划边界,再谈代码
DDD 的核心思想其实很简单:把业务当作系统的心脏,让技术实现围绕业务模型来组织,而不是让业务去迁就技术结构。
DDD 的设计过程分为两个阶段:
- 战略设计:不写代码,纯业务视角,划分“限界上下文”,确定每个业务模块的边界和交互关系。这个阶段解决的是“业务怎么拆”的问题。
- 战术设计:在边界内建模,设计实体、值对象、聚合、领域服务、领域事件等要素,把业务规则落到代码模型。这个阶段解决的是“每个小块内部怎么写”的问题。
很多人一上来就写代码,然后发现自己只是把原来的 Service 换了个名字,改叫“Domain Service”,这叫“换汤不换药”,根本没有领会 DDD 带来的价值。DDD 的武功心法在于“建模”和“对业务的深度理解”,而不是表单和代码的迁移。
1.3 事件风暴:我们是怎么给业务划界的
战略设计阶段,我们用了事件风暴(EventStorming)来做需求拆解。具体做法是:
- 找一间会议室,邀请业务方、产品、开发、测试一起参加。
- 准备大量便签纸,不同颜色代表不同类型:橙色代表领域事件、蓝色代表命令(触发事件的指令)、绿色代表读模型、黄色代表聚合。
- 从“开始到结束”为主线,把业务过程中会发生的事件按时间轴贴出来。比如:订单已创建、库存已锁定、支付已完成、订单已发货。
这一步非常有价值。讨论过程中,业务方和开发对同一个环节的认知不一致的问题被暴露出来。例如业务说“用户下单成功”,但其实后台还要拆成多个订单(平台自营和商家分别开单),如果不做事件风暴,开发永远只会在代码里写一个Order,然后疯狂加字段来适配各种场景。
最后事件自然形成几个“高内聚”的组,每个组就是一个限界上下文。我们最终划分为:商品上下文、订单上下文、库存上下文、支付上下文、营销上下文。每个上下文之间通过明确的接口交互,内部各自演化,互不干扰。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心细节解析:战术建模的几个关键决策
2.1 实体和值对象,分清楚才不会被带偏
这是建模过程中第一个让人纠结的问题。实体(Entity)有唯一标识,有生命周期,状态会发生变化;值对象(Value Object)没有唯一标识,一旦创建出来就不应该被修改,它是描述性的。
举我们订单域的实例:
- 订单(Order)是实体。它有一个全局唯一的订单号,会经历待支付、已支付、已发货等状态变化,每个状态变化都是它生命周期的延续。
- 金额(Money)是值对象。它包含币种和金额数值,比如“人民币 100 元”。如果订单金额从 100 变成 200,不是 Money 变了,而是 Order 更换了一个 Money 实例。
一个实用的判断标准:当你想修改这个对象的某个字段时,如果觉得“不行,改了之后这个对象就不是原来的它了”,那就要考虑实体;如果改了之后只是变成了另一个值,那它通常是值对象。
实际操作中,我们经常把很多字段类都设计成值对象,比如地址、手机号、商品快照,这样做的好处是它们天然不可变,线程安全,也不容易被别处误改。
2.2 聚合与聚合根:边界内的完整性由谁说了算
聚合(Aggregate)是一组相关对象的集合,它有一个聚合根(Aggregate Root)作为对外访问的唯一入口。外部对象不能直接操作聚合内的子对象,必须通过聚合根来间接操作。
再说订单的例子。订单和订单项、配送地址构成了一个聚合,聚合根是订单。当我们要修改订单里的某个商品数量时,不能直接拿到订单项然后setQuantity(5),而是要通过order.modifyItemQuantity(itemId, 5)。
这样设计的原因是一致性边界:订单下的所有项在数据库层面需要有一致性约束,比如总金额必须等于所有订单项金额之和。如果允许外部直接操作订单项,就可能存在绕过规则的情况。我建议建一个聚合时先问自己:这个聚合内的事务边界在哪?如果两个对象需要保持同生共死,那大概率应该放在一个聚合里;如果彼此独立变化,就该拆成不同的聚合。
2.3 领域服务、应用服务和基础设施服务,各司其职
在落地 DDD 代码结构时,最常见的困惑就是:到底哪些逻辑应该放在领域服务里,哪些放在应用服务里?
我的经验规则是:
- 应用服务(Application Service):负责用例编排、事务管理、权限校验等前端交互层面的逻辑。它像一个导演,说“先做什么、再做什么”,但不自己做业务判断。比如“下单”这个用例:调用订单聚合创建订单,调用库存服务锁定库存,调用支付服务发起支付。
- 领域服务(Domain Service):处理不属于任何单一实体或值对象的业务逻辑。比如“计算两个订单合并后的总价”这种逻辑,它横跨多个聚合,放在某个实体会破坏聚合的内聚性,放到应用服务又太重,这时候就适合领域服务。
- 基础设施服务(Infrastructure Service):对接外部依赖,比如发送短信、对接第三方支付、存取数据库。它们通过接口定义,让领域层不直接依赖具体的中间件实现。
这里有一个关键点:依赖方向是向内的。领域层不应该依赖基础设施层,而应该反过来,由基础设施层去实现领域层定义的接口。这是 DDD 和传统分层的本质区别之一,也是最容易被忽略的约束。
3. 实操过程:一个订单系统的 DDD 完整落地
3.1 工程模块怎么划分
为了让 DDD 的边界在编码层面落地,我们用了多模块的 Maven 工程结构,而不是单一包名的分层。核心模块如下:
text复制order-center
├── order-interfaces # 接入层:Controller、DTO
├── order-application # 应用层:应用服务、用例编排、事务
├── order-domain # 领域层:实体、值对象、聚合、领域服务、仓储接口
├── order-infrastructure # 基础设施层:Repository 实现、MQ、缓存、外部客户端
└── order-common # 通用工具、常量
这个结构的核心约束是:order-interfaces 和 order-infrastructure 都依赖 order-application,order-application 依赖 order-domain,而 order-domain 不依赖任何外部模块。可能有人觉得这样拆分太麻烦,但实际维护阶段你会感谢这种隔离——业务规则永远在领域层,不会随着技术选型的调整而被动摇。
3.2 核心领域模型的代码落地
直接来看我们订单聚合根的代码片段,这里浓缩了实体、值对象和领域行为的实现思路:
java复制public class Order {
private final OrderId orderId; // 值对象,订单号
private final List<OrderItem> items; // 订单项列表
private final CustomerId customerId; // 值对象,客户ID
private Money totalAmount; // 值对象,总金额
private OrderStatus status; // 枚举,订单状态
// 创建订单
private Order(OrderId orderId, CustomerId customerId, List<OrderItem> items) {
this.orderId = orderId;
this.customerId = customerId;
this.items = items;
this.totalAmount = calculateTotalAmount(items);
this.status = OrderStatus.PENDING_PAYMENT;
}
// 工厂方法:创建订单,业务规则在这里强约束
public static Order create(CustomerId customerId, List<OrderItem> items) {
if (items == null || items.isEmpty()) {
throw new BusinessException("订单必须包含至少一个商品");
}
if (items.size() > 100) {
throw new BusinessException("单笔订单商品种类不能超过100种");
}
OrderId orderId = OrderId.generate();
return new Order(orderId, customerId, items);
}
// 修改商品数量(不是直接set,而是行为表达)
public void modifyItemQuantity(String itemId, int newQuantity) {
if (newQuantity <= 0) {
throw new BusinessException("商品数量必须大于0");
}
OrderItem item = findItem(itemId);
item.changeQuantity(newQuantity);
this.totalAmount = calculateTotalAmount(this.items);
}
private Money calculateTotalAmount(List<OrderItem> items) {
return items.stream()
.map(OrderItem::getSubtotal)
.reduce(Money.of(0, CurrencyUnit.CNY), Money::add);
}
}
注意create方法里对非空、数量上限的校验,这些业务规则属于领域层,外部调用方(应用服务)无法绕过。过去我们会在 Controller 里分散这些校验,现在它们被牢牢焊死在模型内,想破坏规则就只有改代码本身。
再看值对象的设计。以下是一个简单的 Money 值对象:
java复制public class Money {
private final BigDecimal amount;
private final CurrencyUnit currency;
private Money(BigDecimal amount, CurrencyUnit currency) {
this.amount = amount;
this.currency = currency;
}
public static Money of(BigDecimal amount, CurrencyUnit currency) {
// 金额不能为负数,不能有超过两位的小数
if (amount == null || amount.compareTo(BigDecimal.ZERO) < 0) {
throw new BusinessException("金额非法");
}
return new Money(amount, currency);
}
public Money add(Money other) {
if (!this.currency.equals(other.currency)) {
throw new BusinessException("币种不一致,无法相加");
}
return new Money(this.amount.add(other.amount), this.currency);
}
public Money multiply(BigDecimal multiplier) {
return new Money(this.amount.multiply(multiplier), this.currency);
}
// 不提供 setter,没有修改入口
public BigDecimal getAmount() { return amount; }
public CurrencyUnit getCurrency() { return currency; }
}
值对象的不可变性很重要。我们在重构时曾经为了省事,给 Money 加了 setter,结果很快有人在业务代码里直接改金额,绕过了金额合法性校验,造成了金额为负的脏数据。值对象一旦失去不可变性,就跑偏了。
3.3 仓储接口与实现隔离
领域层定义仓储接口,基础设施层实现具体的数据存储操作。这一步让上层关心的永远是“怎么用数据”,而不是“数据存在哪里”。
java复制// 领域层定义接口
public interface OrderRepository {
Order findById(OrderId orderId);
void save(Order order);
}
java复制// 基础设施层实现
@Repository
public class OrderRepositoryImpl implements OrderRepository {
private final OrderDao orderDao;
private final OrderItemDao orderItemDao;
@Override
public Order findById(OrderId orderId) {
OrderPO orderPO = orderDao.selectById(orderId.getValue());
List<OrderItemPO> itemPOs = orderItemDao.selectByOrderId(orderId.getValue());
return OrderPOConverter.toDomain(orderPO, itemPOs);
}
@Override
@Transactional
public void save(Order order) {
OrderPO orderPO = OrderPOConverter.toPO(order);
orderDao.insert(orderPO);
List<OrderItemPO> itemPOs = OrderPOConverter.toItemPOList(order.getItems());
orderItemDao.batchInsert(itemPOs);
}
}
数据持久化使用 PO(持久化对象)和领域对象分离的策略,中间用 converter 做一层转换。这样做的好处是,数据库表结构即使调整,也不会影响领域模型的演进。
需要注意:仓储接口尽量按照“聚合”粒度来设计,不要为了图方便把一个聚合拆成多个仓储方法。外部需要操作聚合时,通过仓储加载整个聚合,再调用聚合根方法完成业务变更,最后整体保存。这样聚合内的一致性边界才能在持久化层得到保证。
3.4 应用服务编排用例
应用服务不需要包含业务规则,它只负责“调度”和“事务管理”。下单用例的编排如下:
java复制@Service
public class OrderApplicationService {
private final OrderRepository orderRepository;
private final InventoryGateway inventoryGateway; // 库存系统网关
private final PaymentGateway paymentGateway; // 支付系统网关
@Transactional
public OrderDTO placeOrder(PlaceOrderCommand command) {
// 1. 构建领域模型
List<OrderItem> items = command.getItems().stream()
.map(item -> OrderItem.create(item.getProductId(), item.getQuantity()))
.toList();
CustomerId customerId = new CustomerId(command.getCustomerId());
// 2. 调用领域层:创建订单
Order order = Order.create(customerId, items);
// 3. 调用外部限界上下文(库存)
Boolean locked = inventoryGateway.lockStock(command.getItems());
if (!locked) {
throw new BusinessException("库存不足");
}
order.markStockLocked();
// 4. 持久化
orderRepository.save(order);
// 5. 发起支付
paymentGateway.initiatePayment(order.getOrderId(), order.getTotalAmount());
return OrderDTO.from(order);
}
}
这里有个细节:应用服务中order.markStockLocked()这个动作容易被忽略。它是在领域模型中反映外部系统操作结果的状态变更,而不是直接把“库存锁定”这件事写在应用服务里。这样如果未来“锁定库存”这个动作改为提前锁定或延后锁定,只需要调整领域模型的状态机,不需要改 controller。
4. 常见问题与排查技巧实录
4.1 贫血模型和失血模型的问题
很多号称在用 DDD 的团队,写出来的实体类代码是这样的:
java复制public class Order {
private Long id;
private BigDecimal amount;
private String status;
// 一堆 getter/setter,空荡荡的
}
然后业务规则全放在 Service 里。这其实是贫血模型,本质上是披着 DDD 外衣的传统 CRUD,除了包名结构变了,逻辑并没有改变。贫血模型的根本问题在于“业务规则缺少所有权人”,谁都可以改 Order 的状态,最终就会产生逻辑失控和重复代码。
解决这个问题没有捷径,必须把相关的行为往实体里面“收”。判断标准很简单:如果外部代码通过 getter 拿到数据、判断逻辑、再通过 setter 改状态,说明这部分逻辑应该被挪到实体内部,变成一个行为方法。 这一过程我们花了两三周才基本改造完,但收益是后续需求迭代几乎没再出现过“改一个字段漏一个校验”的情况。
4.2 事务边界怎么定
在 DDD 里,一个聚合对应一个事务边界,这是核心原则。一个事务里不应该修改多个聚合,因为不同聚合往往是独立一致性的,如果强行在一个事务里同时修改它们,会导致锁范围扩大、性能下降,也会让聚合之间的耦合变强。
我们踩过一个很典型的坑:在“创建订单”和“更新库存”两个聚合之间开了同一个数据库事务,当库存更新失败时,订单也要回滚。后来库存系统改成了独立微服务,这个逻辑瞬间就崩了。正确的做法应该是:每个聚合独立管理自己的事务,聚合之间通过领域事件或者最终一致性的方式来协调。比如订单创建成功后发布一个“订单已创建”事件,库存服务消费该事件再做扣减。
4.3 从老系统改造时怎么落地
如果你的项目是已运行多年的老系统,不要幻想“推倒重来”。成本高不说,业务风险也极大。比较稳妥的做法是“绞杀者模式”:
- 从最核心、最混乱的一个业务模块入手,比如订单中心。
- 先做事件风暴梳理清楚边界,确定限界上下文的划分。
- 在老的 Controller 和 DAO 之间插入新的领域层,把原有逻辑逐步挪到领域模型中。
- 每完成一个用例,跑一次全面的回归测试,确认行为没有变化再继续下一个。
这个过程节奏必须慢。我们花了三个迭代才把订单模块完全改造完,但第二个迭代之后,业务方提新需求的速度反而明显加快了——因为新增规则往往只需要改一个聚合根方法,不需要再动五六个 Service。
4.4 DDD 常见问题速查表
| 问题现象 | 可能原因 | 解决建议 |
|---|---|---|
| 实体类里全是 setter,没有业务方法 | 贫血模型,没有把行为收进实体 | 将散落在 Service 的规则下沉到实体方法中 |
| 应用服务代码越来越多 | 业务规则放错了层,应用服务承担了领域职责 | 审视逻辑归属,把业务判断迁移到领域服务或实体 |
| 不同聚合被反复同时修改 | 聚合边界划分不合理或事务边界过大 | 重判聚合边界,使用领域事件解耦 |
| 实体和数据库表一一对应 | 领域模型被持久化模型绑架 | 分离领域对象和持久化对象,通过仓储转换 |
汇总这些经验时,我发现一个共性:DDD 的难点是三分技术、七分业务。技术层面的建模套路是可以快速学习的,真正花时间的是理解业务本身,和团队达成一致的语言和边界。如果你的团队对业务理解程度不同,先不要急着写代码,多花时间在事件风暴和模型讨论上,带来的回报会在后续迭代中体现出来。
如果还要说一条最重要的实操建议,那就是:从一个小而清晰的模块开始,先把一个用例完整走通,感受一下领域模型和应用服务之间的分工之后,再逐步铺开。跳过建模环节直接硬套 DDD 的代码结构,还不如不引入。
