领域驱动设计(DDD)不是一套框架,而是一种把业务复杂度装进代码的方法论。当订单、库存、会员的逻辑纠缠在一起,一个改动牵动全身时,DDD 用「通用语言 + 限界上下文 + 聚合」帮我们把大泥球切成高内聚、低耦合的领域模型。本文用一个电商订单系统,演示从战略设计到战术设计的完整落地路径,让你能直接照着写。
一、为什么需要 DDD:从「大泥球」说起
大多数业务系统早期都是一个 Service 里塞满 SQL 和 if-else。随着功能变多,订单状态、库存扣减、积分计算全混在一起,任何一次需求变更都要在几百行方法里小心翼翼地改。这种「大泥球」架构的痛点是:没有边界,所以没有一致性保障。
DDD 的核心主张很简单:让代码结构和业务结构对齐。业务怎么划分职责,代码就怎么划分模块;业务里哪些数据必须一起变,代码里就用一个「聚合」把它们锁在一起。它不追求酷炫,只追求可维护。
二、战略设计:先划清限界上下文
2.1 通用语言 Ubiquitous Language
通用语言是开发、产品、业务方共同使用的词汇表。比如「下单」到底包不包含「扣库存」?「已支付」和「已确认」是不是一回事?把这些定义写进文档、写进代码里的类名,歧义就会在需求阶段被消灭。一个判断标准:如果代码里的名词和产品话术对不上,说明通用语言没建立。
2.2 限界上下文与上下文映射
限界上下文(Bounded Context)是「模型生效的边界」。一个电商系统至少能拆出:订单上下文、库存上下文、会员上下文、支付上下文。每个上下文内部自洽,对外通过明确的接口协作。上下文之间的关系决定了集成难度,常见三种:
| 关系 | 适用场景 | 关键动作 |
|---|---|---|
| 合作关系(Partnership) | 两个上下文一起演进 | 同步排期、共享测试 |
| 防腐层(ACL) | 对接混乱的老系统 | 加一层适配器翻译模型 |
| 共享内核(Shared Kernel) | 少量必须一致的模型 | 抽出公共模块,谨慎改动 |
发现上下文边界最实用的办法是事件风暴(Event Storming):把业务方、开发、测试拉到一个房间,用便利贴贴出「发生了什么事件」「谁触发的」「聚合是什么」。当大家为某张便利贴该归哪个上下文争论时,边界就自然浮现了。不要闭门造车画边界,要让业务事实说话——你定义的上下文,必须经得起业务方点头。
划清上下文后,原本纠缠在一起的逻辑就各自归位。这也是微服务治理和绞杀者模式重构遗留系统能落地的前提——没有上下文边界,拆服务只是把大泥球切成小泥球。
三、战术设计:用聚合锁定一致性边界
3.1 实体与值对象
实体靠唯一标识区分,生命周期内标识不变;值对象没有标识,靠属性整体相等,且不可变。金额就是典型的值对象:100 元和另一笔 100 元没有区别,复制一份也无所谓。
// 值对象:金额不可变,靠属性相等
public record Money(long cents, Currency currency) {
public Money add(Money other) {
if (!currency.equals(other.currency)) throw new IllegalArgumentException("币种不一致");
return new Money(cents + other.cents, currency);
}
public static Money of(double yuan) {
return new Money(Math.round(yuan * 100), Currency.getInstance("CNY"));
}
}
// 实体:靠 orderId 区分,标识不变
public class Order {
private final String orderId;
private final String buyerId;
private Money total; // 值对象组合进实体
private OrderStatus status;
// 构造、行为见下方聚合根
}
3.2 聚合根与不变式
聚合是一组对象的 consistency 边界,聚合根是对外唯一的入口。所有修改必须经由聚合根,由它保证不变式(Invariant)永远成立。比如「订单取消后不能支付」「订单总额必须等于各项之和」这类规则,就写在聚合根里,而不是散落在各处 Service。
public class Order { // 聚合根
private final String orderId;
private final List<OrderItem> items = new ArrayList<>();
private Money total;
private OrderStatus status = OrderStatus.CREATED;
public void addItem(OrderItem item) {
if (status != OrderStatus.CREATED) {
throw new IllegalStateException("只有新建订单能加购");
}
items.add(item);
total = total.add(item.price()); // 不变式:总额随明细同步
}
public void pay() {
if (status != OrderStatus.CREATED) {
throw new IllegalStateException("当前状态不可支付");
}
status = OrderStatus.PAID;
}
public void cancel() {
if (status == OrderStatus.PAID) {
throw new IllegalStateException("已支付订单需走退款而非取消");
}
status = OrderStatus.CANCELLED;
}
}
3.3 领域服务与仓储
当某个行为不属于任何单个实体(比如「跨订单对账」),它才该放进领域服务。聚合的持久化交给仓储(Repository)——仓储只按聚合根整体加载和保存,不提供任意字段的散装更新。
public interface OrderRepository {
Order load(String orderId); // 按聚合根加载
void save(Order order); // 按聚合根保存
}
// 领域服务:仅放跨聚合的行为
public class ReconcileService {
private final OrderRepository orders;
public ReconcileService(OrderRepository orders) { this.orders = orders; }
public Money totalOf(List<String> orderIds) {
return orderIds.stream()
.map(orders::load)
.map(Order::total)
.reduce(Money.of(0), Money::add);
}
}
注意聚合不是越大越好。一个常见新手错误是把「用户」「订单」「地址」塞进同一个大聚合,结果每次改地址都要锁整棵订单树,并发一上来就大量等待、超时。正确的粒度是:只把必须强一致的数据放进一个聚合,其余用最终一致 + 领域事件衔接。聚合越小,并发越高,但跨聚合的一致性要靠事件补全——这恰恰是领域事件存在的意义,也是它和分布式事务的本质区别。
四、应用层如何编排
聚合、领域服务都不碰事务和远程调用,那是应用层的职责。应用服务负责开事务、调仓储、发领域事件,保持「薄」。这样领域逻辑可单测,应用逻辑可替换。
@Transactional
public String placeOrder(PlaceOrderCmd cmd) {
Order order = new Order(cmd.buyerId());
for (var line : cmd.lines()) {
order.addItem(new OrderItem(line.sku(), line.qty(), line.price()));
}
orderRepository.save(order);
domainEventBus.publish(new OrderCreated(order.orderId()));
return order.orderId();
}
五、落地避坑清单
| 反模式 | 后果 | 正确做法 |
|---|---|---|
| 贫血模型(只有 getter/setter) | 逻辑回流到 Service,回到大泥球 | 行为放进实体/聚合根 |
| 聚合过大 | 锁竞争、事务长 | 一个聚合只保核心一致性 |
| 跨上下文长事务 | 分布式死锁 | 最终一致 + 领域事件 |
| 仓储暴露 SQL | 聚合边界被绕过 | 只按聚合根读写 |
这些坑本质都指向一件事:边界没守住。DDD 的价值不在名词多,而在边界清。选型时也可参考技术选型决策矩阵,把「要不要上 DDD」当成一次有成本的架构决策,并用ADR 架构决策记录把理由留档,避免后人推翻重来。
六、小结
DDD 的落地顺序很清晰:先用通用语言对齐认知,再用限界上下文切分系统,最后用聚合根锁住一致性边界。它不需要一步到位——从一个最痛的上下文开始,把聚合写对,比画一整套全景图更有用。当你的代码开始「长得像业务」,维护成本自然会降下来。




