一个订单系统迭代到第三年,代码里出现了四个不同含义的”订单”。交易域的订单是支付前的待付款单据,履约域的是待发货清单,结算域的是待开票账目,客服域的是投诉凭证。四者都叫 Order,却因为同名被反复复用、强行兼容。这不是命名问题,是模型边界缺失。领域驱动设计处理的核心正在于此:在复杂业务域里切出清晰的语言边界与一致性边界。
语言先于代码
DDD 强调先建立统一语言,再写第一行代码。这不是仪式感,而是减少翻译损耗。
代码里的每个名词最终都会落到数据库字段、接口参数和需求文档上。如果开发说的”用户”包含已注销账号,产品说的”用户”只算活跃账号,短期内没人察觉异常,直到某个报表数字对不上,排查要花几天去对齐”到底谁算用户”。统一语言要求这套词汇在所有场合指向同一事物,并落到可检索的术语表,而不是停留在口头:
| 词条 | 标识符 | 上下文 | 判定规则 | 反例 |
|---|---|---|---|---|
| 下单用户 | Buyer |
交易 | 提交过订单的个人 | 仅浏览未下单 |
| 收货联系人 | Consignee |
履约 | 订单上的收货信息快照 | 账号资料里的地址 |
| 开票主体 | InvoiceEntity |
结算 | 具备开票资料的主体 | 下单的个人消费者 |
反例列是关键。只写定义不写反例,术语表退化成同义词词典,边界依旧模糊。之后类名、方法名、事件名都应能从表中找到对应项;出现表里没有的词,说明那块业务没被建模。
这笔投入的前提是业务确实复杂。判断信号是:需求评审时业务方是否频繁纠正开发对概念的理解。
限界上下文怎么切
限界上下文是统一语言的适用边界。出了边界,”订单”可以换成完全不同的含义而不产生冲突。划分依据通常三条同时看:
业务能力。按对外提供的能力切分,而不是按数据表切分。交易、履约、结算是三种能力,各有独立的业务目标和成功标准。某个划分单元如果没有独立业务目标,它大概率只是另一个上下文里的模块。
语言边界。同一词汇在不同场景含义冲突,是边界存在的最强信号。上文的”四个订单”背后就是四个上下文。
团队边界。上下文边界最好与团队边界对齐。跨团队的上下文会被两个团队各自修改,语义漂移只是时间问题。
三条依据冲突时,语言边界优先级最高——团队结构可以调整,业务概念的分歧难以弥合。颗粒度没有标准答案。经验上一个上下文落在单个团队能完全掌握的范围内、职责能用一句话说清楚,比较合适。切太粗退化成模糊分层,切太细则一次需求跨十几个上下文。反向信号也常用:某次改动要同时修五个上下文,多半是切碎了。
上下文之间如何协作
边界画完后要定义边界之间怎么说话,这就是上下文映射。
共享内核指两个上下文共用一小块模型。没有转换成本,但共用部分的改动需要双方同步。它适合无法分割的基础概念,例如金额类型、币种。一旦扩大到业务概念,就退化成分布式单体。
防腐层是在自己边界外加一层转换,把外部模型翻译成内部模型。上游改字段名、换语义,改动止步于这一层。代价是每次集成都要写转换代码,而这层逻辑自身也会腐化。依赖不可控上游(例如外部服务商)时,它几乎是必需品。
// 防腐层:把外部风控模型翻译为内部下单决策模型
public class RiskControlAdapter implements RiskGateway {
private final ExternalRiskClient client;
@Override
public OrderDecision evaluate(OrderDraft draft) {
RiskResponse resp = client.query(new RiskRequest(
draft.getBuyerId(), draft.getTotalAmount(), draft.getItems()));
return switch (resp.getStatus()) {
case "PASS", "NONE" -> OrderDecision.allow();
case "REVIEW" -> OrderDecision.manualReview(resp.getReason());
case "BLOCK" -> OrderDecision.reject(resp.getReason());
default -> OrderDecision.manualReview("未知风控状态");
};
}
}
关键不在 switch,而在于外部状态码新增取值时,内部领域层完全不需要知道。这就是边界的价值。
客户-供应商描述团队的协作形态:上游照顾下游需求,破坏契约前走协商流程。此外还有遵奉者(下游无条件跟随上游模型)和各行其道(无需集成)。选择时权衡两个维度:对上游的控制力、上游的变更频率。两者都不利时,防腐层加契约测试最稳。
聚合根是一致性边界
上下文内部还要继续整理。聚合是”必须一起保持一致的一组对象”,聚合根是这组对象对外的唯一入口。
判定依据是不变式——那些”任何时候都必须成立”的规则。订单总额必须等于明细金额之和,所以订单头与明细同属一个聚合;商品价格可以被另一个团队随时修改,与订单没有必须在同一时刻成立的规则,所以商品是独立聚合,订单只持有商品 ID 和下单时的价格快照。由此得到一条常用准则:事务边界等于聚合边界,一个事务只改一个聚合实例,跨聚合的一致性交给领域事件。
public class Order { // 聚合根
private OrderId id;
private final List<OrderLine> lines;
private OrderStatus status;
private long totalAmount;
public void addLine(ProductSnapshot product, int quantity) {
ensure(status == OrderStatus.DRAFT, "只有草稿态可以加明细");
ensure(quantity > 0 && quantity <= MAX_QTY, "数量超出限制");
ensure(product.isOnSale(), "商品已下架");
lines.add(new OrderLine(product.getSkuId(), product.getUnitPrice(), quantity));
recalculate();
registerEvent(new OrderLineAdded(id, product.getSkuId(), quantity));
}
public void submit() {
ensure(status == OrderStatus.DRAFT, "订单状态非法");
ensure(!lines.isEmpty(), "空订单不能提交");
ensure(totalAmount == lines.stream().mapToLong(OrderLine::subtotal).sum(),
"总额与明细不一致");
this.status = OrderStatus.PENDING_PAYMENT;
registerEvent(new OrderSubmitted(id, totalAmount, Instant.now()));
}
private void recalculate() {
this.totalAmount = lines.stream().mapToLong(OrderLine::subtotal).sum();
}
}
仓储也只按聚合根提供接口,findById 和 save 足以覆盖整个聚合的持久化。一旦开了 updateOrderStatus 这类口子,绕过聚合根改状态的路径就出现了,保障随之失效。
边界画错的代价很明显。聚合过大:改任意明细都要锁整张订单,高并发下冲突率上升。聚合过小:需要事务保护的不变式跨了聚合,只能用最终一致弥补,业务却容忍不了中间状态。调整信号也对应:并发冲突频率升高说明太大,跨服务补偿频繁说明太碎。
贫血模型的真实问题
贫血模型指实体只有字段和 getter/setter,逻辑全在 Service 里。批评有很多,真正站得住的是两条。
逻辑散落。同一规则被多处实现。”只有草稿态订单能加明细”可能同时出现在 OrderService.addLine、BatchImportService.importOrder、AdminFixService.repairOrder。漏改一处,就产生只在特定入口出现、难以复现的问题。
不变式无处保障。setter 让对象可以被改成任意非法状态:setStatus(CANCELLED) 之后 setTotalAmount(-100),编译通过、写入成功,直到下游读到脏数据才暴露。防御只能靠每个调用点重复校验,而重复一定会漏。
替代方案是把状态变更收敛进方法、把校验放在转换入口。这不意味着 Service 消失:只涉及本对象内部状态的规则放聚合内,涉及多个聚合或外部系统的流程放应用层。
贫血模型并非一无是处。以数据搬运为主的场景(报表导出、同步任务、简单配置管理)用事务脚本反而更直接。把它用在复杂的状态流转上才是问题。
什么时候不该用
引入 DDD 的成本是实在的:多一层建模对话、多一层抽象、多一批需要团队共同理解的概念。CRUD 为主的系统里,这套开销换不来收益。
值得投入的信号:规则里有大量例外分支;同一概念在不同部门有不同定义;状态流转出现嵌套或回溯;团队规模超过单个小队,需要明确所有权。
劝退的信号同样清楚:单表维护的后台、只有几个月生命周期的活动页、没有业务方参与的纯技术中间件。这些场景引入限界上下文和聚合根,只会让简单需求在一堆转换层里绕远路。
复杂度不可消除,只能在”理解业务的复杂度”和”代码的偶然复杂度”之间交换。DDD 做的正是这笔交换,前提是复杂度确实存在。
评论(1)
结构很清楚,适合当 checklist 用。
请 登录 后参与评论。