← 返回博客
2026-09-16 08:00:02

领域驱动设计:从战术设计到限界上下文

领域驱动设计:从战术设计到限界上下文

一个电商中台团队遇到过这样的事。订单服务里有个 Order 类,两年时间从 12 个字段涨到 60 多个,支付、履约、售后、风控四个团队都在往里加字段。改一个 status 枚举要拉四个团队评审,发一次版等三天。问题不在于代码写得差,而在于所有人共用了同一个“订单”概念——而支付眼里的订单、仓储眼里的订单、客服眼里的订单,本来就不是一回事。

这篇文章顺着这条线走:先看战术设计能解决什么,再看它为什么解决不了跨团队的概念冲突,最后落到限界上下文怎么划、划错会怎样。

战术设计:先让单个模型自洽

战术设计管的是一个上下文内部的建模质量。实体、值对象、聚合、领域事件、仓储——这些工具解决的是“代码里的模型能不能表达业务规则”。

回到订单。最初 Order 被设计成一个贫血模型:一堆 getter/setter,业务规则散落在 Service 里。改成聚合根之后,关键约束被收进对象内部:

public class Order {
    private OrderId id;
    private OrderStatus status;
    private List<OrderItem> items;
    private Money totalAmount;

    public void pay(PaymentId paymentId) {
        if (status != OrderStatus.PENDING_PAYMENT) {
            throw new IllegalStateException("订单当前状态不可支付: " + status);
        }
        this.status = OrderStatus.PAID;
        registerEvent(new OrderPaidEvent(this.id, paymentId, this.totalAmount));
    }

    public void addItem(Product product, int quantity) {
        if (status != OrderStatus.PENDING_PAYMENT) {
            throw new IllegalStateException("已支付订单不可修改商品");
        }
        items.add(new OrderItem(product, quantity));
        this.totalAmount = recalculate();
    }
}

这段代码的关键点有两个。一是状态流转规则写在聚合内部,任何调用方都绕不过去,不会出现“某个 Service 忘了校验状态”的漏洞。二是 OrderPaidEvent 在状态变更的同一个方法里注册,保证业务动作和事件不会脱节。

容易踩坑的地方在聚合边界。items 要不要做成独立聚合?如果把 OrderItem 拆成独立聚合根,每次加购都要跨聚合事务,一致性会变复杂。这里选择把 items 留在 Order 聚合内,代价是订单聚合体积偏大,高并发修改同一订单时会成为热点。所以批量操作场景要配合乐观锁重试,而不是硬扛。

战术设计做到这一步,单个服务的代码是干净的。但跨团队的问题一个都没解决。

为什么战术设计救不了跨团队

支付团队需要订单的金额和状态,履约团队需要收货地址和商品明细,风控团队需要用户行为和设备信息。三拨人共用一个 Order 类,结果就是上一节说的 60 个字段。

更麻烦的是语义冲突。支付语境里的“订单完成”指钱到账,履约语境里的“订单完成”指货签收。同一个词 completed,两个团队理解不同。硬塞进一个枚举,就得写 PAID_AND_DELIVERED 这种四不像的状态值。

这时候加多少实体、多少值对象都没用。问题不在模型质量,在于概念的适用范围没有被划清。这就是限界上下文要处理的事。

限界上下文:给每个概念划出适用范围

限界上下文的本质是一句话:一个概念只在某个边界内有一致的含义,出了边界就要重新定义。

按这个原则,电商中台可以拆成几个上下文:

┌─────────────────┐      ┌─────────────────┐
│   交易上下文     │      │   履约上下文     │
│                 │      │                 │
│  Order(聚合根)   │─────▶│  Shipment(聚合根)│
│  - 金额/支付状态  │ 事件  │  - 收货地址      │
│  - 商品明细      │      │  - 物流单号      │
│  - 用户ID        │      │  - 签收状态      │
└─────────────────┘      └─────────────────┘
        │                        │
        │ 事件                    │ 事件
        ▼                        ▼
┌─────────────────┐      ┌─────────────────┐
│   风控上下文     │      │   客服上下文     │
│  RiskProfile    │      │  Ticket         │
│  - 设备指纹      │      │  - 关联订单快照  │
│  - 行为序列      │      │  - 沟通记录      │
└─────────────────┘      └─────────────────┘

每个上下文有自己的 Order 或订单快照。交易上下文的 Order 管支付状态,履约上下文只读交易发来的事件,维护自己的 Shipment,客服上下文拿到的是一份只读快照,用来展示和关联工单。

上下文之间只通过领域事件通信:

// 交易上下文发布
public class OrderPaidEvent {
    private String orderId;
    private String userId;
    private BigDecimal amount;
    private Instant paidAt;
}

事件里只放跨上下文真正需要的字段,不放整个 Order 对象。关键点在于事件是契约,一旦发布就要考虑兼容性——加字段可以,改字段名或删字段会让下游炸掉。所以事件类要有版本策略,比如 OrderPaidEventV2,而不是直接改老事件。

划错了会怎样

限界上下文划错有两个典型方向。

划得太细。 每个团队一个上下文,上下文之间调用链拉得很长。一个下单动作要跨交易、库存、风控、营销四个上下文,分布式事务和最终一致性成本陡增。判断标准是:如果两个概念总是一起变化、一起被同一个团队维护,就不该拆开。

划得太粗。 把交易和履约塞进一个上下文,短期省事,长期就是开头那个 60 字段的 Order 重演。判断标准是:如果同一个词在两个团队嘴里含义不同,就该考虑拆。

演进路线

落地不用一步到位。一个务实的路径分三步。

第一步,先做战术设计。 挑一个最痛的服务,把贫血模型改成聚合根,把业务规则收进对象。这一步不改团队边界,纯代码层面,风险最低。

第二步,识别语义冲突。 统计哪些字段是多个团队在改、哪些状态值有歧义。冲突最集中的地方就是第一个要拆的上下文边界。

第三步,按事件拆分。 选一个冲突最严重的边界做试点,把跨边界调用改成领域事件。先跑通一条链路,验证事件契约的稳定性,再推广到其他边界。

限界上下文不是一次设计出来的,是随着团队和业务变化不断调整的。开始时划错很正常,重要的是保持边界可调整——事件契约松耦合,上下文之间不共享数据库,这样改起来才不会伤筋动骨。

下一步建议:拿你手上最痛的那个服务,花半天时间列出所有被多个团队修改的字段,看看它们背后是不是藏着两个不同的概念。那份清单就是你的第一版上下文地图。

本文关键词:领域驱动设计、限界上下文、聚合根、领域事件、微服务