领域驱动设计 DDD 实战:用限界上下文与聚合拆解复杂业务

领域驱动设计(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 的落地顺序很清晰:先用通用语言对齐认知,再用限界上下文切分系统,最后用聚合根锁住一致性边界。它不需要一步到位——从一个最痛的上下文开始,把聚合写对,比画一整套全景图更有用。当你的代码开始「长得像业务」,维护成本自然会降下来。

上一篇 智能体评测基准:用 SWE-bench 与 AgentBench 量化 Agent 能力