2612 字
约 8 分钟
15
S18-A21 · 贫血还是充血:争议的两端其实都在问同一件事

一场架构评审上,两派争了一个小时:"贫血模型简单易维护" 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. 自测

  1. 贫血与充血的"关键差别"是什么?为什么充血的核心不是"把代码搬进实体"?
  2. 什么情况下贫血是"对的"?为什么"数据库约束"能替代一部分充血?
  3. 为什么"多入口修改同一实体"会让贫血失效?
  4. "值对象充血、实体贫血"为什么是最被低估的折中?
  5. "这条规则需要的所有数据是否都在这个对象里"这条判据怎么用?
  6. 判断"是否必须充血"的那个问题是什么?
S18-A21 · 贫血还是充血:争议的两端其实都在问同一件事
https://www.clxhxhhr.top/posts/6581/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。