一个团队把"下单"拆成了四个领域服务:InventoryService、PriceService、CouponService、OrderService。上线后发现"下单"这个业务动作要跨四个服务、两个事务边界,而"优惠券与库存"的校验顺序一旦错,就会出现"扣了库存但优惠券无效"。
领域服务的粒度问题不在于"拆得对不对",而在于**"哪些逻辑必须一起成功"。这个区分很关键:"服务数"是部署单位,"事务边界"是正确性单位**。于是"拆服务"可以无限做,而"拆事务"一旦做错,就产生了"必须靠对账修"的状态。
而这句话在代码里对应的不是"服务数",是"事务边界"。
1. 三种"服务"混在一起
| 类型 | 职责 |
|---|---|
| 应用服务 | 编排用例、管事务 |
| 领域服务 | 无归属的业务规则 |
| 基础设施服务 | 外部系统适配 |
第一项是"用例的入口":它决定"顺序与事务",而不含业务规则。而多数"拆得太细"的问题,是把"编排"也拆到了各服务里。第一项被拆散的后果是"顺序无人负责":库存、优惠券、订单各自都"做了自己那一步",而"先校验哪个"变成了调用方的责任——于是不同入口有不同的顺序,出不同的 bug。
第二项的判据是"这条规则是否需要用多个实体才能表达"。只有一个实体参与的规则,应当留在实体里(或叫"实体方法"),而不是"抽一个 Service"。
第二项只在"规则不属于任何实体"时才需要:比如"跨两个账户的转账规则"。它不应该变成"每个名词一个服务"。
2. 拆分的三个判据
| 判据 | 说明 |
|---|---|
| 是否同一事务 | 必须一起成功的别拆 |
| 是否同一变更节奏 | 一起改的别拆 |
| 是否有独立生命周期 | 独立存储/独立团队才拆 |
第一项最硬:"扣库存 + 建订单"必须在一个事务里第一项的推论很直接:"必须一起成功"的操作,其可靠性上限由"最不可靠的那个参与者"决定。放在一个事务里,这个上限是数据库;拆成两个服务,上限变成网络。
(或用一个明确的补偿机制)。把它拆成两个服务,等于把"一致性"交给网络。
第三项还有个"反向"用法:如果一个"服务"没有独立存储(共用同一个库、同一张表),那它其实不是服务,而是"同一个部署单元里的模块"——只是被包装成了 HTTP 调用。
第三项是"微服务"的正当理由:独立的存储、独立的团队、独立的发布节奏。没有这三样,"拆"只是增加了调用。
3. 粒度过细的五个症状
| 症状 | 表现 |
|---|---|
| 一次用例调 5 个服务 | 编排散落 |
| 事务边界断裂 | 部分成功需人工修 |
| 循环依赖 | A 调 B、B 调 A |
| 每个服务都很薄 | 只有一个方法 |
| 改一个规则要发 4 个服务 | 变更耦合 |
第二项最严重:**"扣了库存但订单创建失败"**会留下不一致状态第二项的"成本"要算清:对账任务(写)+ 补偿接口(写)+ 监控指标(长期维护),而且每次出问题都要人工介入。它比"一开始放一个事务里"贵得多。
,而它需要"对账 + 补偿"来清理——成本远高于"当初不拆"。
第四项是"过度设计"的直接证据:一个服务只有一个方法,且没有独立数据,那它就是一个"类"被误当成了"服务"。
"领域服务"与"应用服务"的混淆有一个很实用的区分法:问"这个方法里有没有 if(业务分支)"。
// 两种服务的区分(一句话判据)
应用服务:编排 + 事务,方法体多为"调用 + 顺序",if 很少
领域服务:包含业务分支与规则,方法体有多个 if/计算
// 反例:应用服务里有 8 个 if → 规则泄漏到了编排层
// 反例:领域服务里在开事务 → 事务泄漏到了领域层
4. 粒度过粗的症状
| 症状 | 表现 |
|---|---|
| 一个服务 30 个方法 | 无边界 |
| 每次改动都影响全体 | 发布风险 |
| 团队互相等 | 协作瓶颈 |
| 无法独立扩容 | 一损俱损 |
第一项的判据是"改动影响面":如果改一个规则只需要改一个包、跑一组测试,那"粗"就没问题;而要改五处、跑全量回归,说明边界已经烂了。
第一项与"模块化单体"的区别在于"边界":粗不等于错,只要模块内边界清晰、依赖方向正确。
第三项是"该拆"的真实信号:两个团队因为同一个服务的发布互相等,说明它们的变更节奏不同——这比"代码太乱"更有说服力。
"应用服务"应当只有一个入口方法,且它是"事务边界"——这条约定能同时防住"编排散落"与"事务断裂":
// 应用服务的形状(一个用例一个方法)
PlaceOrderAppService.place(cmd):
1 校验(无副作用)
2 事务开始 {}
2.1 扣库存(仓储/领域服务)
2.2 核销优惠券(同库调用)
2.3 建订单(同库写入)
3 事务结束
4 事务外:发事件/通知(可失败、可重试)
// 关键:2 全在一个事务里;4 在事务外且"可重试"
5. 一个可用的中间态
| 方案 | 特点 |
|---|---|
| 模块化单体 | 单进程、模块边界清晰 |
| 拆分"读写" | 写走事务、读走投影 |
| 只拆"基础设施" | 外部依赖独立 |
| 只拆"高负载"部分 | 按容量需求 |
第一项是最被低估的:它保留了"事务",同时强制了"边界"。多数团队其实只需要这个,而不需要"独立部署"。
第二项的实质是"缩小一致性范围":只有写路径需要强一致,读路径可以接受延迟。于是"拆"发生在"最不需要一致的地方",风险最低。
第二项在"读多写少"的系统上很有效:写路径保持单库事务,读路径拆出投影/缓存——于是"一致性"只在一个小范围内需要保证。
// 三种方案的取舍(按"是否真的需要独立部署")
不需要独立部署 → 模块化单体(推荐)
需要独立扩容(读多)→ 拆读写(写单库、读多副本)
需要独立团队/独立发布 → 拆服务(并接受最终一致)
"该不该拆"的论证可以从"三个数"开始,比"架构讨论"有效:
// 三个可量化的数(按季度看)
1 一次用例的跨服务调用数 > 4 → 编排散落
2 因"部分成功"而人工修复的次数 > 0 → 事务断裂
3 因"等对方发布"而延迟的天数 > 2 → 变更节奏不一致
// 用法:1、2 指向"该合并",3 指向"该拆开"
6. 排错:五种"领域服务粒度问题"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 一次下单调四个服务 | 编排散落 | 收到应用服务 |
| 库存扣了订单没建 | 事务断裂 | 合并或加补偿 |
| 服务互相调用成环 | 边界未定 | 明确依赖方向 |
| 改规则要发多个服务 | 变更耦合 | 合并或抽共享 |
| 每个服务一个方法 | 过度拆分 | 合并成模块 |
第二项的"补偿"是可行的,但要算代价:一次对账任务 + 一个补偿接口 + 一个监控指标,成本约为"合并成一个事务"的三倍。
最后一个"防腐层"的用途:"必须与第三方交互"不等于"要把对方建模进自己的领域"。用防腐层把外部模型挡在外面,于是"外部改了"不影响内部,"内部改了"也不必先改外部。
// 两种应对"第三方"的方式
差:领域里直接用对方的 DTO(外部一改,全链路改)
好:防腐层做转换(外部 DTO → 内部模型),边界只有一层
// 关键:转换层是"唯一知道外部格式"的地方
7. 边界:什么情况必须拆
| 场景 | 为什么 | 做法 |
|---|---|---|
| 独立团队、独立发布 | 协作成本 | 拆,并接受最终一致 |
| 独立合规边界(数据隔离) | 法务要求 | 拆,用接口 |
| 独立扩容需求(差 10 倍) | 成本 | 拆那部分 |
| 第三方系统(不可控) | 天然边界 | 用防腐层 |
判断口径一句话:"这些逻辑必须一起成功"还是"它们可以各自成功"。最后一条与"演进"有关的边界:粒度应当随"团队规模"与"流量结构"变化。早期"一个服务"是对的,因为团队小、流量低;而"独立扩容"或"独立团队"出现时,才是拆的时机——反过来"先拆好等以后用"通常付出双倍代价。
必须一起成功(同一业务动作的多个副作用),就放在一个事务/一个服务里;可以各自成功(后续的派生动作),才允许拆。
8. 自测
- 三种"服务"分别是什么职责?为什么"把编排也拆到服务里"是常见错误?
- 三个拆分判据里,哪一个最硬?它对应代码里的什么?
- "粒度过细"的五个症状里,哪一个最严重?它的清理成本来自哪?
- "粒度过粗"不等于错,它的判据是什么?
- "模块化单体"为什么被低估?它保留了什么、强制了什么?
- 判断"该不该拆"的那个问题是什么?