微服务的边界应当围绕业务规则和变化方式确定。一个下单请求同时经过订单、库存和支付,并不意味着它们必须放进一个服务;反过来,画出三个实体也不意味着应该立即部署三个服务。真正需要解释的是:哪些规则必须一起成立,哪些变化可以独立完成,跨边界失败后如何恢复。
DDD 提供两组互相配合的工具:战略设计明确模型的适用范围与团队协作关系,战术设计把业务规则落实到对象和操作中。本文以一个假设的电商下单流程为例,先确定边界,再说明订单内部建模,以及独立服务之间的可靠协作。
先区分业务问题、模型边界与部署边界
| 概念 | 回答的问题 | 电商示例 |
|---|---|---|
| 子域 | 业务中有哪些相对独立的问题 | 定价、库存、履约 |
| 界限上下文 | 一套语言和模型在什么范围内有效 | 目录中的商品、履约中的待发货物料 |
| 聚合 | 哪些对象共同维护一组业务不变量 | 订单与订单行 |
| 微服务 | 哪些能力需要独立部署、运行与运维 | 独立发布的支付服务 |
界限上下文用于保持模型内部语义一致,并明确不同模型之间的关系。目录里的“商品”关注名称与销售属性,库存里的同一 SKU 关注仓库和可售量,履约里的物料关注重量、打包与运输。共享标识不要求三者共享一个包含全部字段的类。
子域与上下文不必机械地一一对应。遗留系统、不同团队和不同业务语言可能形成多个上下文;部署则还要考虑调用、数据和运维成本。一个模块化单体可以包含多个上下文,一个上下文也可能由多个部署单元实现。
按差异化价值决定建模投入
核心域、支撑域和通用域的划分,是相对于具体组织的业务目标而言的。
| 分类 | 判断依据 | 可能的投入方式 |
|---|---|---|
| 核心域 | 直接支撑组织独特的竞争能力 | 深入建模、持续实验、保留关键知识 |
| 支撑域 | 为业务提供必要能力,存在定制但不是当前差异化重点 | 按复杂度建设,控制无效扩展 |
| 通用域 | 需求较成熟,市场已有可复用能力 | 评估购买、开源或平台服务 |
库存对一家轻资产内容平台可能只是支撑能力,对以履约效率竞争的零售商却可能是核心域。不能把“推荐必然核心、库存必然支撑”写成固定分类,也不能因为某个子域不核心就忽略其资金或可靠性要求。
用事件风暴发现问题,而不是自动切出服务
EventStorming是一种跨角色协作探索业务的工作坊形式。对下单场景,可以先还原客户、客服、仓库与支付团队看到的过程,再讨论命令、已发生的事件、规则与疑问。
常见的便利贴约定包括:橙色表示领域事件,蓝色表示命令,黄色表示聚合,紫色表示策略,红色表示热点问题。颜色是协作约定;探索全局流程时,不必一开始就给每张事件贴纸指定聚合。
例如,“订单已提交”之后可能出现库存不足、支付拒绝或支付结果暂时未知。这些分支迫使团队回答:
- 提交订单是否就代表承诺有货,还是先进入待确认状态?
- 库存预留有无期限,支付晚到时如何处理?
- 支付超时能否直接认定失败,重试用什么业务标识?
- 用户取消与支付成功同时发生时,由谁决定最终状态?
聚合边界要从这些不变量和并发冲突中推导,不能按事件出现的时间接近程度聚类。“订单提交”和“支付授权”前后相连,并不能证明它们天然松耦合;用消息连接后,状态与业务约束仍然可能紧密相关。
工作坊的产出是对流程、规则、术语冲突和候选边界的共同理解。后续仍需用具体用例与失败场景检验边界。
上下文映射说明双方如何合作
模型分开后,需要明确谁提供能力、谁适配谁,以及变更如何协调。常见的上下文映射模式如下;这些模式可以组合使用,不等同于某一种通信协议。
| 模式 | 关注点 | 示例 |
|---|---|---|
| 合作关系 Partnership | 双方共同协调演进 | 订单与支付团队联合交付新的结算流程 |
| 共享内核 Shared Kernel | 对一小部分共享模型共同负责 | 双方共同维护且协调发布的金额规则 |
| 客户—供应商 Customer–Supplier | 下游需求进入上游计划 | 库存团队为订单团队提供约定能力 |
| 遵从者 Conformist | 下游接受上游模型 | 使用外部系统的现成领域语义 |
| 防腐层 Anticorruption Layer | 下游翻译并隔离外部模型 | 内部支付状态与渠道状态转换 |
| 开放主机服务 Open Host Service | 提供稳定、可理解的集成协议 | 支付服务对多个业务提供授权与查询接口 |
| 发布语言 Published Language | 使用明确记录的交换语言 | 说明字段、状态和演进规则的事件契约 |
| 各行其道 Separate Ways | 不建立不值得维护的集成 | 两项无共享业务要求的能力独立演进 |
共享一个工具包未必形成共享内核;采用 JSON 也不自动形成发布语言。前者需要共同维护领域语义,后者需要描述数据含义与兼容规则。Customer–Supplier 可以使用同步 API,也可以使用消息,不能把组织关系写成同步调用的同义词。
防腐层保护语义,不只改字段名
假设外部渠道区分“授权成功”“扣款成功”和“结果待查询”,内部支付模型也必须保留相应差异。不能把 charge 的结果映射成 authorized,把已结算金额当成已授权金额,更不能把未知状态码一律映射为最终失败。
一个支付适配器需要明确以下契约:
| 输入或观察 | 内部处理 |
|---|---|
| 经过验证的授权成功响应 | 记录渠道交易标识与授权金额 |
| 明确的业务拒绝 | 记录拒绝原因,进入可解释的终态 |
| 超时、连接断开、未知结果码 | 保留“结果未知”,使用原业务标识查询或重试 |
| 重复响应或回调 | 以请求与渠道事件标识幂等处理 |
| 渠道金额单位或精度不同 | 明确换算与舍入规则,不假设所有币种都以分计量 |
防腐层可以吸收字段和协议变化。若渠道改变了授权有效期、退款能力或业务状态语义,内部模型与流程仍可能需要调整。“接入层隔离”不等于领域永远不受外部变化影响。
支付能力为什么适合集中管理
如果购物车、订单和客服系统各自维护一套支付状态,它们可能同时重试、撤销或退款,难以判断哪个记录代表真实支付结果。可以让支付上下文统一管理支付尝试、渠道标识和状态转换,再向其他上下文提供命令与结果契约。
订单发出“请求授权”表达意图;支付发出“授权成功”表达事实。两者不能仅通过改成异步消息就互换。同步返回或异步通知属于交互选择,支付状态的唯一写入责任和请求幂等性才是边界的关键。
聚合用来保护不变量,不用来规定服务数量
聚合把共同维护一致性规则的对象作为一个变更单元,聚合根对外暴露操作入口。订单与订单行可以形成一个聚合:新增行时,数量、币种、金额和订单状态必须同时合法。
常用的设计建议是保持聚合较小、通过根访问内部对象、用 ID 引用其他聚合,并尽量让一次事务围绕一个聚合完成。这是约束模型复杂度的设计方向,不是数据库无法跨聚合提交的技术定律。
若业务持续要求多个对象原子更新,应重新检查不变量和边界;也可以有明确理由使用跨聚合本地事务。需要跨服务保持某项强约束时,不能仅因为“DDD 建议最终一致”就取消业务要求。
三个有用的检查
- 不变量由谁维护? 订单总额可以由行金额与明确折扣规则推导,不能任意设置一个不相关的总额
- 并发由谁协调? Java 方法内的检查只能约束当前对象,还需要版本号、条件更新或合适的锁处理并发写入
- 对象是否过度膨胀? 一个客户的全部历史订单不应仅因“属于同一客户”就装进同一聚合
使用 CustomerId 而不是持有可变 Customer 对象,有助于减少无意的对象遍历与级联保存,但 ID 引用本身不能禁止跨聚合事务。大聚合也不必在所有实现中锁住整张对象图;其实际代价取决于加载和持久化方式,应通过访问行为验证。
战术构件如何共同实现一个用例
| 构件 | 责任 | 下单例子 |
|---|---|---|
| Entity | 以稳定标识区分对象,承载行为 | Order |
| Value Object | 用值表达概念,通常设计为不可变 | Money、地址快照 |
| Aggregate Root | 对外控制聚合修改并维护不变量 | Order.addLine、Order.submit |
| Domain Service | 表达不自然属于单个实体的领域操作 | 组合定价规则 |
| Repository | 按聚合语义加载与保存对象 | OrderRepository |
| Application Service | 协调用例、权限、事务与基础设施 | 提交订单用例 |
同一实体的两个内存实例可以代表同一个业务标识;不能把“两个实例不是同一对象”当作实体身份不同。值对象的相等性则由业务值决定,不要求它必须存成独立表。
一个可编译的金额值对象
下面使用 Java 17 record,并限定为采用 Java Currency 默认小数位的货币金额。它不覆盖加密资产、利率计算或中间高精度价格。构造时统一 scale,使 1.0 与 1.00 在同一币种下具有相同的 equals/hashCode 语义;不允许通过隐式舍入吞掉精度。
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Currency;
import java.util.Objects;
public record Money(BigDecimal amount, Currency currency) {
public Money {
Objects.requireNonNull(amount, "amount");
Objects.requireNonNull(currency, "currency");
int scale = currency.getDefaultFractionDigits();
if (scale < 0) {
throw new IllegalArgumentException("Unsupported currency precision");
}
amount = amount.setScale(scale, RoundingMode.UNNECESSARY);
}
public static Money zero(Currency currency) {
return new Money(BigDecimal.ZERO, currency);
}
public Money add(Money other) {
Objects.requireNonNull(other, "other");
if (!currency.equals(other.currency)) {
throw new IllegalArgumentException("Currency mismatch");
}
return new Money(amount.add(other.amount), currency);
}
public Money multiply(int quantity) {
return new Money(amount.multiply(BigDecimal.valueOf(quantity)), currency);
}
}
Money 可以表示正负金额,订单行的“数量必须大于零、单价不得为负”由订单规则另外校验。金额应由十进制字符串或精确数值构造,避免先经过 double 丢失精度。求和从 Money.zero(orderCurrency) 开始,不能用固定人民币零值汇总美元订单。
订单聚合与应用服务的职责边界
订单的 addLine 应校验草稿状态、数量、单价和币种,再更新行项与总额。submit 应再次确认仍是草稿且内容有效,避免重复提交产生重复业务事件。向外返回的行项及事件使用不可变快照,不能把内部可变 List 直接交给消费者。
如果有折扣,应明确区分原价、优惠与应付金额,例如“应付金额=行金额合计-优惠”。不能前面声明“总额永远等于行金额”,后面又任意 applyFinalPrice 破坏它。
提交订单的应用服务可以按以下步骤组织:
- 认证调用者并校验其对订单的操作权限
- 在事务中加载订单及需要的规则版本,检查乐观锁版本
- 调用定价服务计算结果,由订单验证并接受结果
- 调用 submit,产生带有业务标识和序号的领域事件
- 同一事务保存订单及 Outbox 记录,然后提交
- 由独立投递器发送集成消息,并跟踪投递结果
应用服务负责用例策略、权限和事务编排,领域对象负责业务不变量;不能把“应用服务不应塞满核心领域规则”解释成它完全没有任何业务判断。
Repository 面向聚合语义设计,不必为每张表设置一个。复杂列表查询可以通过查询接口读取专门视图,而不必先加载大量聚合。
分层与端口隔离
可以让应用层依赖领域接口,基础设施层实现仓储、消息与外部服务端口。领域规则因此能在不启动 Spring 或数据库的情况下单独测试。这里采用的是依赖倒置后的组织方式,不能一边要求 Domain 不依赖基础设施,一边又规定它必须按“上层依赖下层”调用数据库实现。
端口适配器隔离能减少领域测试的依赖,但集成行为仍需验证:事务是否真正覆盖 Outbox、SQL 条件更新是否生效、消息是否保持约定语义,都不是纯对象测试能证明的。
对于简单 CRUD,事务脚本可能更直接。问题不是所有 setter 都必须消失,而是复杂不变量分散在多个入口,任何调用方都能绕过规则。领域封装提高约束集中度,运行时校验仍需测试,不能宣称编译器自动证明全部业务正确性。
跨服务一致性要按业务结果设计
在同一数据库中,订单和库存记录可以共享本地事务;普通邮件发送或远程支付调用不会因为位于同一个 Java 进程或带有 @Transactional 就随数据库回滚。
拆成服务后,应逐项说明需要原子成立的事实、允许的中间状态和失败补救。CAP 讨论网络分区条件下的线性一致性与可用性,不能推出“互联网系统只能最终一致”。库存扣减、资金账务等约束仍可能要求拒绝或延迟请求,以保证正确性。
Outbox 补上提交与投递之间的缺口
订单状态更新与待发布事件写入同一个数据库事务,使“业务提交但没有留下待投递记录”的窗口消失。投递器随后读取记录,将消息发送到消息系统,收到确认后更新状态;也可通过 CDC 转发事务日志。Transactional Outbox 模式讨论了这种原子写入与独立投递的分工。
需要同时处理:
- 订单更新未命中预期版本时,不能继续插入虚假的成功事件
- 消息已送达但确认丢失时,重试可能重复投递
- 并行投递器需要竞争协调;同一聚合的有序要求还要靠分区键、序号与投递策略落实
- 积压、毒消息和长期失败需要监控、重试及修复入口
Outbox 持久化提高可恢复性,并不保证下游此刻已经处理,也不意味着不需要数据库备份或故障恢复。
幂等消费不能只做“先查再写”
两个并发消费者都可能先查到“未处理”。可靠的本地消费流程应在同一事务中:
- 利用唯一约束尝试插入
(consumer_id, event_id)处理记录;已存在则按该数据库的冲突处理方式退出 - 对目标业务执行状态受约束的变更
- 一并提交;业务失败时,去重记录也回滚
如果副作用是远程退款或外部邮件,它不受这个本地事务保护,需要独立的幂等业务键、发送状态或 Outbox 机制。恢复库存也应根据实际预留记录释放,不能仅凭订单取消事件中的数量给可售库存直接做加法。
乱序处理取决于事件的语义
| 事件含义 | 对迟到消息的处理 |
|---|---|
| 完整状态快照 | 在确认版本口径一致时,可以拒绝覆盖较新的快照 |
| 必须逐条应用的增量 | 不能只因版本旧就丢弃,需要补缺、按序应用或重建 |
| 可交换且可幂等的独立事实 | 按各事实标识去重,不必强加不存在的全局顺序 |
事件 schemaVersion 表示格式版本,aggregateVersion 表示某个聚合的变化序号,两者应分开。物理时间戳可能重复或受时钟偏差影响,不适合作为所有场景的唯一排序依据。
领域事件也不必一律携带全部关联数据。使用快照还是标识引用,需要权衡事件体积、数据敏感性和消费方依赖,并明确字段表示“发生时的值”还是“需要另行查询的当前值”。
Saga 把业务补救做成可恢复流程
Saga将跨服务流程拆成多个本地事务,并在需要时执行业务补偿。它不提供与单个 ACID 事务相同的隔离和回滚语义,也不是唯一的跨服务一致性方案。
| 方式 | 谁推进流程 | 需要处理的问题 |
|---|---|---|
| 事件协作 Choreography | 参与者根据事件推进 | 依赖分散,必须能追踪整体结果 |
| 集中编排 Orchestration | 编排器保存流程状态并下达命令 | 编排状态持久化、并发推进与恢复 |
以“预留库存—支付授权—确认订单”为例:
| 观察结果 | 下一步 |
|---|---|
| 库存预留成功 | 持久化结果,发出带幂等键的支付授权请求 |
| 支付明确拒绝 | 释放已确认的库存预留 |
| 支付请求超时 | 先保留未知状态并查询;不能直接当作未执行 |
| 支付成功但订单确认失败 | 重试确认,或按业务规则撤销授权并释放库存 |
| 补偿暂时失败 | 持久化待补偿状态,重试或转人工处置 |
| 流程执行器重启 | 从持久化状态恢复,不能重新无条件执行全部步骤 |
一个内存中的 for 循环加 catch 逆序调用,缺少这些状态与恢复能力,不能作为完整 Saga 实现。补偿通常按业务依赖逆序安排,但并非每一步都有可逆操作;已经发送的通知或发生的现实交付无法通过数据库回滚抹除。
CQRS 分离读写职责,不要求两套数据库
CQRS允许为命令和查询使用不同模型。订单写模型维护规则,列表读模型可以直接提供客户名称、状态和物流摘要。两者可以共享数据库,也可以通过事件异步维护独立存储;CQRS 本身不要求事件溯源或最终一致。
若选择异步读模型,需要处理重复、乱序、重建和延迟。列表上需要历史客户名称时,应使用约定的快照;在重放每条旧事件时查询客户服务当前名称,会得到不同的结果,不能默认具有可重放的一致性。
提交后立即可见可以通过返回已提交的结果、读取权威状态、等待投影达到指定版本等方式实现。前端乐观展示只改善体验,不证明读模型已更新。写完主库再单独同步写另一个读库,如果没有额外协议,仍然存在部分成功问题。
再决定是否独立部署与增加 BFF
订单、库存和支付可以先在一个应用内保持清楚的模块边界。只有当独立发布、扩缩容、团队协作或故障隔离收益足以覆盖分布式成本时,再迁移为服务。
几个判断信号比类和表的数量更有用:
| 观察 | 应检查什么 |
|---|---|
| 每次发布都要同步改动多个服务 | 契约与模型边界是否稳定,兼容策略是否不足 |
| 两个服务经常跨库协调同一不变量 | 是否需要调整归属,或保留同一事务边界 |
| 一个服务由多个团队频繁冲突地修改 | 团队责任、业务语言与发布边界是否匹配 |
| 服务共享一个数据库实例 | 继续检查表所有权和访问权限,不能仅凭实例共享判断耦合 |
| 一个服务直接改另一个服务的表 | 数据所有权已被穿透,需要收敛写入入口 |
独立数据所有权不必等于每个服务都独占一台数据库主机。网关作为技术能力也不是天然反模式;问题是将一次业务变更按 Controller、Service、DAO 分散成远程服务,导致端到端变化都要跨团队协调。
BFF 可以为不同终端组合数据和定制响应,但不必在业务初期无条件增加一层。若订单详情仅有简单查询,现有应用接口可能足够;多个终端的数据组合和演进节奏明显不同后,再评估 BFF。业务决定仍由相应领域维护,BFF 负责面向终端的组合与交互。
用变化与失败场景验证边界
一次渐进式拆分可以从边界明确的模块开始:确定唯一写入责任,建立兼容接口,迁移数据和流量,验证失败恢复,再收缩旧入口。拆分或合并哪个更贵,取决于数据、契约和团队,不能给出固定结论。
DDD 的结果不是一张服务清单,而是一组能被检验的解释:订单为什么拥有这些行项,支付为什么拥有渠道状态,库存为什么按预留记录释放,以及每个跨边界流程如何结束或恢复。先让这些解释与代码、数据和实际行为一致,再决定部署多少个服务。