一场架构评审上,两派争了一个小时:"贫血模型简单易维护" vs "充血模型才叫面向对象"。最后决定"保持贫血",而真正的分歧从未被说出来:"这条规则(满 100 减 15)应该由谁保证不被绕过"。
贫血与充血的争论之所以反复,是因为它被当成了"风格偏好"。这个"可判定"来自一个很朴素的观察:规则的"归属"由"它需要什么数据"决定——只依赖对象自身数据的规则,放在对象里天然安全;需要外部数据的,放在服务里更清楚。
而它实质是"规则的唯一归属地"问题——而这件事有可判定的答案。
1. 两种模型的实际差别
| 维度 | 贫血 | 充血 |
|---|---|---|
| 数据 | 只有字段 + getter/setter | 字段 + 行为方法 |
| 规则位置 | Service 层 | 实体/值对象内部 |
| 谁能改状态 | 任何人(setter 公开) | 只能通过方法(有校验) |
| 测试对象 | Service(需 mock 依赖) | 实体(纯内存) |
第三项是关键差别:贫血模型里"状态可以被任意设置",而充血模型里"状态只能通过合法动作改变"。
所以"充血"的核心不是"把代码搬到实体里",而是"让非法状态无法表达"。这个区别决定了"充血"的正确做法:不是"给实体加方法",而是"把改动状态的方法收窄到几个有意义的动作"(pay()、cancel()),并把 setter 设为私有。
2. 什么时候贫血是"对的"
| 场景 | 原因 |
|---|---|
| 纯 CRUD(无业务规则) | 规则只有"字段校验" |
| 数据与规则变化节奏不同 | 规则频繁变,数据稳定 |
| 团队规模小、领域简单 | 分层的收益低于成本 |
| 已有强约束在数据库 | 唯一/外键/检查约束 |
第一项的"推论"是"多数后台管理系统适合贫血":它们的规则大多是"字段校验 + 权限",而不是"状态机 + 不变式"。
第一项的判据很硬:如果"业务规则"只有"这个字段不能为空",那贫血就是正解——因为"规则"本身不需要对象来保护。
第四项常被忽略:数据库约束能保证的一致性,不必在代码里再做一遍第四项的"边界"在"错误信息":数据库约束报的是"约束名冲突",需要映射成"用户能懂的话"——而这层映射是必须写的,否则"友好错误"做不到。
(但要处理"违反约束"的友好错误)。
3. 什么时候充血是"必要的"
| 场景 | 原因 |
|---|---|
| 状态机(订单、审批) | 非法转移必须被拒绝 |
| 有不变式(金额 = 明细之和) | 每个动作后都要成立 |
| 多入口修改同一实体 | 无法靠"约定"保证 |
| 计算属于数据(运费、折扣) | 与数据强耦合 |
第一项的"遗忘机制"很具体:新增入口的人只读了他要用的那个 Service 方法,而"别的入口有检查"这件事不在他视野里。
第一项最典型:"已取消的订单不能再支付"这条规则,如果只写在 Service 里,第二个入口就会忘。
第三项是"贫血失效"的主因:规则散落在多个 Service 方法里,而没有任何机制保证"新增的第三个入口也遵守它"。第三项的"量化"方式是"数同一规则的重复次数":若"检查订单状态"出现在 5 个方法里,那它就是"该收归实体"的候选。
// 贫血:状态可以被任意设置,规则靠"记得检查"
order.setStatus(CANCELLED); // 谁都能改
// 之后某处:if (order.getStatus() != CANCELLED) pay(); ← 忘写就出 bug
// 充血:非法转移直接抛错
public void pay() {
if (status != CREATED) throw new IllegalStateException("只有待支付订单可支付");
this.status = PAID;
}
// 于是"第二个入口"即使忘了检查,也改不出非法状态
"收窄 setter" 是最小可行的改造,可以在一个下午完成:
// 改造前:任何状态都能被设置
public class Order { private Status status; public void setStatus(Status s){...} }
// 改造后:只暴露"有意义的动作",非法转移直接抛
public class Order {
private Status status = CREATED; // 无 public setter
public void pay() { require(CREATED); status = PAID; }
public void ship() { require(PAID); status = SHIPPED; }
private void require(Status expected) {
if (status != expected) throw new IllegalStateException(status + " -> 不允许");
}
}
// 收益:新增入口无法绕过;代价:需要把"直接 setStatus"的旧代码改成"动作调用"
4. 三种"半充血"的折中
| 折中 | 说明 |
|---|---|
| 实体只做"状态转移",规则仍在 Service | 边界清晰但两端都不彻底 |
| 值对象(金额、区间)充血,实体贫血 | 常见且实用 |
| 实体充血 + Service 只编排 | 最接近"分层清晰" |
第二项的"成本"极低:值对象不需要持久化映射,也不会与 ORM 打架——而这两件事正是"实体充血"最常见的阻力。
第二项是最被低估的:"金额""时间段""数量"这类值对象最适合充血——它们小、无副作用、规则集中,且"非法值无法构造"。
第三项是"贫血争论"的常见终点:实体管"状态与不变式",Service 管"编排与事务"。于是争论的焦点从"要不要充血"变成"哪些规则属于实体"。
5. 怎么判断"这条规则该放哪"
| 判据 | 归属 |
|---|---|
| 只依赖本对象的数据 | 实体 |
| 依赖多个对象/外部服务 | 领域服务 |
| 需要事务/流程编排 | 应用服务 |
| 只校验格式 | 值对象构造器 |
第一项是最实用的判据:"这条判断需要的所有数据,是否都在这个对象里"。是,就放实体;需要查库或调外部,就放服务。
// 三个例子的归属(用同一判据)
"满 100 减 15" → 只依赖订单金额 → 实体
"该用户能否用这张券" → 需要用户与券库 → 领域服务
"下单要扣库存并建订单" → 编排 + 事务 → 应用服务
"不变式"可以被写成"构造后与每个动作后的断言",于是"违反"在开发期就暴露:
// 不变式断言(订单金额 = 明细之和)
private void checkInvariant() {
BigDecimal sum = items.stream().map(Item::amount).reduce(ZERO, BigDecimal::add);
if (amount.compareTo(sum) != 0) throw new IllegalStateException("金额与明细不符");
}
// 调用点:构造器末尾 + 每个改状态的方法末尾
// 收益:不是"测试发现",而是"运行时立刻发现";测试里也能直接断言
6. 排错:五种"模型选择带来的问题"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 状态出现非法组合 | 贫血 + setter 公开 | 收窄 setter,加状态转移方法 |
| 同一规则在两处不一致 | 规则散落 | 收归实体 |
| 新增入口后出 bug | 无机制保证 | 充血(非法状态不可表达) |
| 实体类膨胀到千行 | 充血过度 | 拆值对象/领域服务 |
| Service 里全是计算 | 规则错位 | 按判据迁移 |
第四项是"充血的反面":把"查询、编排、外部调用"都塞进实体,于是实体变成"什么都知道"的上帝类。第四项的"识别信号"是"实体里出现 repository 或 HTTP 客户端":一旦实体需要"查库",它就跨了层,且无法在纯内存里测试。
最后一个"团队"视角:贫血/充血的选择应当与"团队对领域的熟悉度"匹配。贫血下"规则散在 Service",新人只需看一个方法;充血下"规则在实体",需要理解领域模型——熟手更快,新人更慢。
// 两种模型对"新人上手"的影响
贫血:改一个规则 → 找对应 Service 方法 → 改 → 可能漏掉别的入口
充血:改一个规则 → 找实体方法 → 改 → 影响面被"动作"限定
// 判据:团队是否"长期维护"这个领域(长期 → 充血收益大)
7. 边界:什么情况不必纠结
| 场景 | 为什么 | 做法 |
|---|---|---|
| 报表/查询类服务 | 无状态变更 | 贫血即可 |
| 数据传输对象(DTO) | 只是载体 | 无行为 |
| 外部系统的映射对象 | 不归你管 | 贫血 + 防腐层 |
| 一次性脚本 | 不长期 | 随意 |
最后一条与"重构"有关的边界:贫血转充血不必一次做完,可以"先把 setter 收窄成方法"——这个动作不改分层、不改测试结构,而它立刻让"非法状态不可表达"。
判断口径一句话:"这个对象的状态会不会被"多个入口"修改,而每次修改都要保证同一条规则。会(订单、账户、审批单),就必须充血(或至少收窄修改入口);不会(只读、单入口),贫血更省事。
8. 自测
- 贫血与充血的"关键差别"是什么?为什么充血的核心不是"把代码搬进实体"?
- 什么情况下贫血是"对的"?为什么"数据库约束"能替代一部分充血?
- 为什么"多入口修改同一实体"会让贫血失效?
- "值对象充血、实体贫血"为什么是最被低估的折中?
- "这条规则需要的所有数据是否都在这个对象里"这条判据怎么用?
- 判断"是否必须充血"的那个问题是什么?