先别急着定义"充血"。咱们看一段你大概率写过的代码------一个订单支付方法,它是怎么一点点烂掉的。
java
// 第一版:干净利落,三行搞定
public void pay(Order order, PayRequest req) {
order.setStatus(PAID);
orderRepository.save(order);
}
// 三个月后:每来一条需求,就有人往里补一段
public void pay(Order order, PayRequest req) {
if (order.getStatus() != IN_PROGRESS) throw ...; // 校验状态
if (!userService.qualified(order.getUserId())) ... // 查用户资格
order.setStatus(PAID);
order.setPaidAt(LocalDateTime.now());
order.setAmount(req.getAmount());
couponService.writeOff(req.getCouponId()); // 算优惠
ledgerService.append(order); // 写流水
mq.send(new OrderPaidEvent(order)); // 发消息
cache.evict(order.getId()); // 清缓存
orderRepository.save(order);
}
这三行到十几行的变化,还只是"支付"一个方法。等到取消、改址、发货、退款全堆进同一个 Service,整个类膨胀到几千行、没人敢动的时候------回头一看,真正属于"支付"的逻辑,就是开头那个"把订单标记为已支付"的核心动作;后面多出来的校验、优惠、流水、消息、缓存,没有一条是支付本身。
这篇文章想回答一个问题,也是我这些年被问得最多、也最容易被答歪的一个:充血模型,到底该充到什么地步?
先给结论,省得你看到一半忘了:
充血充到"外界只能通过业务方法改状态"就到顶了。再往聚合根里塞任何东西(校验、计算、远程调用、持久化),都是在透支前面赢来的可测试性和收敛性。
判断"充得够不够"的反向指标只有一条:外部还有多少条路能绕过你的业务方法? setter 还公开、还能直接落库、还能绕过规则------那你充得再满也是假的。
下面这篇不是"充血模型使用手册",是我自己从踩坑到想通这条边界的过程。
先说句得罪人的话:你那不是充血
想去治理那个越长越没人敢动的支付方法,你一定会撞见一句熟悉的挡箭牌:
"充血模型太理想化了,团队水平参差不齐,最后还是贫血模型稳。"
这句话我听过无数遍,它听起来像经验,其实是在回避一个更具体的问题:逻辑到底该住在哪,判断依据是什么?
"稳"在这里的真实意思是"不用做判断"------把所有对象都当数据袋,逻辑全堆在 Service,于是没有任何对象需要为不变量负责。
所以真正的分歧从来不是"贫血好还是充血好",而是:不变量(那些一旦被违反业务就出错的约束)该由谁守着。
而比"贫血"更隐蔽的一种状态,是我一定要先点破的。很多团队以为自己上 DDD 了,类图上 Order 是个有方法的"领域对象",结果行为全在 Service 里:
java
// 反例示意:顶着充血的名,干着贫血的事
@Data // ← 致命:setter 全是 public
public class Order extends AggregateRoot<Long> {
private OrderStatus status;
private PaymentStatus paymentStatus;
private LocalDateTime paidAt;
private String paymentSerialNo;
}
@Service
public class OrderService {
public void pay(Long orderId, PayRequest req) {
Order order = orderRepository.findById(orderId);
order.setPaymentStatus(PaymentStatus.PAID); // 业务逻辑全在 Service
orderRepository.save(order);
}
}
这种写法有个专门的名字,叫贫血的充血模型 。它比纯贫血更糟------因为它制造了一种"我们在做 DDD"的错觉,同时保留了贫血模型全部的问题:任何地方都能 order.setStatus(PAID),而没人知道支付还要改 paidAt 和流水号。
这恰恰回答了"该充到什么地步":充得满不等于充得对。判断标准不是"加了多少方法",而是"外部还有没有路绕过你的业务方法"。
我当年也被"充血=逻辑进实体"坑过
说回我自己。刚接触 DDD 那阵子,我对"充血"的理解就是字面意思:把逻辑往实体里搬 。于是有段时间我极其激进,凡是沾点业务意思的方法都塞进 Order------校验写在方法开头,远程查资格写在中间,算优惠写在后面,发消息写在末尾。
结果呢?Order 膨胀成上帝类,方法里查库存、调支付网关、写 Redis、发 MQ。然后我发现它没法单测、没法复用、改动一处牵连全身。我当时得出的结论是:"充血模型不适合我们团队。"
后来我才想明白,那个结论本身就是错的。问题不在"充血",在我把充血理解成了"逻辑全进实体"。
这中间卡了我很久的一个点,是标准里那句"不属于实体的逻辑,就放领域服务"------到底什么算"不属于实体"? 我记得有一次 code review,同事问我"这段校验逻辑到底该放实体还是放领域服务",我憋了半天回了句"你觉得呢"。那一刻我知道,标准出了问题,它只说"行为该靠近数据",没说靠近到哪为止。
我后来给自己定了条更笨但更管用的标准,也是全文的判断锚点:
这个字段被别人随手
set成任意值,业务会出事吗?
- 会出事 → 这里是充血的地盘,把 setter 收起来;
- 不会出事 → 安心贫血,别硬加行为。
顺着这个标准,我先纠正了一个自己曾经的错误认知:贫血不是没写好,是一种分工选择。
很多人默认贫血是低级形态,这是误读。贫血对应的是"以数据为中心"的建模范式------当一块数据的核心诉求是被搬运、被展示、被聚合统计时,它就不该有行为。pragmatic-ddd 的订单示例里有一大把故意贫血的类,比如列表查询用的读模型投影:
java
@Data // 纯数据,一个方法都没有
public class OrderSummaryProjection implements IOrderProjection {
private Long orderId;
private int status;
private String statusName;
private int paymentStatus;
private String customerName;
private BigDecimal actualAmount;
private LocalDateTime createdAt;
}
一个方法都没有。这不是偷懒,是职责决定了它只能是数据 :它的任务是把查询结果搬到页面上,给它加个 pay() 只会让维护者困惑。
一个系统里,下面这几类对象天生就该贫血:
| 对象类型 | 为什么该贫血 |
|---|---|
| 读模型投影与查询 DTO | 数据来自查询,不存在非法的状态组合 |
| 持久化对象 PO | 与表结构一一对应,只是数据的落库形态 |
| 跨系统的传输契约 | 契约只描述字段,行为属于各自系统内部 |
| 配置与参数载体 | 值由外部给定,约束力在读取的一方 |
所以:贫血不是错,把贫血当默认值才是错。 一个系统里贫血对象占大多数,是完全正常的状态。真正要充血的,是 Order 这种有明确不变量要守的对象------已签收不能取消、已发货不能改地址、总额不能为负。
▍一句话记住:判断充不充,只看"字段被随手 set 会不会出事",不看它是不是个"实体"。
充血到底赢在哪:不是更 OO,是收敛入口
先给结论:充血不是为了"更面向对象",而是为了让每一条不变量只有一个入口。
面向对象是手段,收敛入口才是收益。下面这张表是同一件事在两种写法下的实际差别。
六条收益对照
| 维度 | 贫血模型 | 充血模型 |
|---|---|---|
| 不变量的位置 | 跟着调用方走:下单、改址、导入、对账各写一遍 | 跟着聚合根走:只在 Order.changeAddress() 一处 |
| 新增字段的成本 | 得搜出所有 setStatus(PAID) 的地方逐个补 |
只改 pay() 一处 |
| 单测成本 | 起 Spring、mock 仓储与 MQ、备库数据 | new Order() 直接断言 |
| 代码表达力 | setStatus(PAID) 只说明改了哪个字段 |
pay(info) 说明发生了什么业务 |
| 事件与审计 | 靠调用方记得发,漏发不报错 | 状态、操作码、事件在同一方法内,落库后统一发布 |
| 规则的可枚举性 | 规则散在 Service 的 if 里,无法枚举 |
OrderRule 一个文件即全部约束,可试跑、可动态摘除 |
前四条容易理解,也容易验证;后两条是"充血之后才解锁"的能力,值得单独展开,因为它们正是"充到上限"后白赚的东西。
深挖一:测试成本差一个量级
贫血模型的测试困境,本质是业务规则和运行环境绑死了。要验证"已支付的订单不能重复支付",你得先起 Spring 容器、mock 仓储返回一个已支付订单、备好事务与事件基础设施,然后才能断言一个布尔值。
充血之后,同一个断言可以拆成两个纯单测,都不需要容器:
java
// 测规则容器:仓储用内存桩,不连数据库
@Test
@DisplayName("已发货订单改址会被规则拦截")
void changeAddress_afterShipped_broken() {
OrderRule rule = new OrderRule(new StubCustomerPermissionService(), new StubOrderRepository());
Order order = OrderFixture.inProgressOrder(1001L);
order.ship(OrderFixture.logisticsInfo()); // 推进到已发货
order.changeAddress(OrderFixture.newAddress()); // 记录 CHANGE_ADDRESS 操作,改址规则才会激活
assertThat(rule.satisfiesRule(order)).isFalse();
}
注意上面那段里那行 changeAddress 是必须 的,不是为了让场景更真实:改址规则挂在"本次操作触发了 CHANGE_ADDRESS"这个激活条件上,不记操作码,规则根本不会跑。这个坑后面还会再遇到。
这不只是"快一点"。业务规则的测试从**"能不能搭出环境"变成了"能不能把场景描述清楚"**,覆盖率才有可能真正上去。
不过有个前提得说清楚:上面的单测之所以能"new 一个 Order 直接断言",是因为聚合根没有自己 new 对象、没有调 LocalDateTime.now()、没有碰任何静态工具类。一旦聚合根里出现这些,可测试性立刻漏掉:
可测试性不是把逻辑挪进聚合根就自动获得的,它要求外部事实一律从外面进来。
深挖二:规则集中之后,多出来的四种能力
这一节是贫血模型给不了的,因为它没有"规则集合"这个可以被枚举、被替换、被预检的对象。
能力一,试跑。 规则集中在 OrderRule 之后,框架可以在不改任何状态的前提下回答"这单现在能不能支付":
java
public DryRunResult tryPayOrder(Long orderId, PayOrderInput input) {
Order order = orderRepository.findById(orderId);
if (order == null) return null;
return super.tryExecute(order, orderRule, orderRepository, t -> orderPayUpdater.apply(t, input));
}
返回的 DryRunResult 是结构化的:passed 加一份 brokenRules 明细。运营后台的改址按钮可以据此提前置灰,对账任务可以在修复前先预检一遍。
能力二,按操作精确激活规则。 因为聚合根记录了"本次工作单元发生了什么操作",规则才能只在真正相关时才跑:
java
this.addRule(
EntityRule.of(order -> RuleCheckResult.of(this.cancelStatusValid(order))),
OrderRuleRegistry.ORDER_CANCEL_STATUS_INVALID,
IActiveRuleCondition.of(order -> order.hasOperation(OrderOperationRegistry.CANCEL)
? ActiveStatus.ACTIVE : ActiveStatus.INACTIVE));
"仅允许取消进行中的订单"这条规则,只在本次真的执行取消时才校验。它顺手解决了那个经典尴尬:订单支付后推进状态,却不小心被取消规则拦住。
能力三,新旧快照对比。 这是最容易被忽略、但绕不过去的一条。规则在领域方法执行之后 才校验,此时 pay() 已经把支付状态改成 PAID 了,从当前状态根本无法区分"首次支付"和"重复支付"。所以框架提供了旧快照机制:
java
@Override
protected boolean requireOldEntity() { return true; }
@Override
protected Order supplyOldEntity(Order currentModel) {
if (currentModel.getEntityId() == null) return null; // 新建聚合,放行
return this.orderRepository.findById(currentModel.getEntityId());
}
需要新旧对比时才加载,不需要就不查库,零成本。对应到规则体上,签名从单参变成双参:(order, old) -> ...
收益成立的前提
上面这些收益有一个共同前提:聚合根里的东西没有越界。
一旦把远程调用、复杂计算、事务控制也塞进聚合根,收益会立刻反噬------单测跑不起来,改动牵连全身,于是"充血模型不适合我们"的结论就诞生了。这正好把我当年踩的坑和"充到什么地步"连上了。
接下来三章,我就用"下限 / 形状 / 上限"把这条边界画清楚。
充到什么地步(下限):先把改状态的权力收回来
从这一章开始讲"怎么充"。第一步不是加代码,而是收权力------这也是充血的下限,必须做到的部分。
我当年想通这点的契机很具体:团队里有人为了"图省事",在聚合根之外直接 order.setStatus(COMPLETED)。我第一反应是加 code review 规则,但发现约定敌不过便利。后来我意识到,真正的下限不该靠人守,得靠编译器守------把 setter 的访问权限锁死。
pragmatic-ddd 的做法很干脆,在实体基类上直接把 setter 的访问权限锁死:
java
@Getter
@Setter(AccessLevel.PROTECTED) // ← 关键:外部拿不到任何公开 setter
public abstract class AbstractEntity<T> implements IEntity<T> {
private T entityId;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
// ...审计字段与 markCreated() / markModified()
}
子类同样遵守这套约定:
java
@Getter
@Setter(AccessLevel.PROTECTED)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Order extends AggregateRoot<Long> { /* ...字段略... */ }
效果是什么?应用层和 Controller 层在编译期就无法直接改订单状态,唯一的出路是调业务方法:
java
public void pay(PaymentInfo paymentInfo) {
this.paymentStatus = PaymentStatus.PAID;
this.paidAt = paymentInfo.getPaidAt(); // 外部事实从外面进来
this.paymentSerialNo = paymentInfo.getPaymentSerialNo();
this.platformDiscount = paymentInfo.getPlatformDiscount();
this.actualAmount = paymentInfo.getActualAmount();
this.markModified();
this.recordOperation(OrderOperationRegistry.PAY);
this.collectEvent(OrderPaidEvent.buildEvent(this));
}
这就是充血的下限:把"能改"变成"允许怎么改"。 支付必须同时改支付状态、支付时间、流水号、优惠金额、实付金额,这五件事被焊死在一个方法里,谁也漏不掉。
充血不是往类里加代码,是把修改状态的权力收回来。
哪怕你的业务方法只有三行赋值,只要 setter 是 protected,这个对象就已经比"贫血的充血模型"强了一个量级------因为六条收益里有前四条在这一步就已经拿到了。
▍一句话记住:收 setter,是投入产出比最高的一步,比引入任何模式都管用。
充到什么地步(形状):一个业务方法的三步式
收完权力,接下来说业务方法里面该写什么。看 ship():
java
public void ship(LogisticsInfo logisticsInfo) {
this.shipmentStatus = ShipmentStatus.SHIPPED; // 1. 改自己的状态
this.logisticsInfo = logisticsInfo;
this.markModified(); // 顺手更新审计时间收尾
this.recordOperation(OrderOperationRegistry.SHIP); // 2. 记录操作码
this.collectEvent(OrderShippedEvent.buildEvent(this)); // 3. 收集领域事件,但不发布
}
三步,缺一不可,但也到此为止:
- 改状态 :只改自己的字段,绝不碰别的聚合;末尾用
markModified()更新审计时间; - 记操作 :
recordOperation声明本次发生了什么,供规则激活与事件因果溯源使用; - 收事件 :
collectEvent声明这个事实值得被外界知道,不负责发布------发布由框架在落库之后统一完成。
这个度可以概括成一句话:
充血 = 描述发生了什么,不负责什么时候执行。
这也是 pragmatic-ddd 最核心的设计主张:领域层只声明业务事实 ,校验时机、落库、发布、重试兜底全部交给框架托管。带来的直接好处在上一章已经见过:聚合根方法可以被单独单测,new 一个 Order、调 ship()、断言状态与事件,结束。
充到什么地步(上限):把三件事请出聚合根
下限和模板都有了,最后一件事是划上限 。充血模型被骂用不好,九成是死在了没有上限。看看 Order 聚合根里没有什么,比看它有什么更有意思。
不该有校验,规则外移到规则容器
在展开之前,先堵一个我当年也纠结过的疑问。
按"不变量在哪行为就在哪"的标准,现在把订单的全部不变量搬进 OrderRule,连"总额必须为正"这种纯状态校验都搬走了------那 Order 凭什么还算充血?这不是自相矛盾吗?
我当年真就这么质疑过自己。后来想通三件事:
- 规则容器没有离开领域层。
OrderRule和Order在同一个领域包里,它不是应用层的参数校验器,而是聚合根的另一半。把规则从"方法内"挪到"同层的规则类里",是同层内的分工,不是失控外逃; - 规则与聚合根是配对关系,由框架强制绑定。 应用服务的
execute(aggregate, rule, repository, ...)模板决定了"调pay()必然过OrderRule",不存在绕过规则改状态的路径; - 所以那条标准要补一句限定:不变量的归属,决定对象是否充血;不变量的执行位置,由治理需求决定。
直觉上充血模型应该把校验写在方法里:
java
// 反例示意:把校验塞进聚合根
public void pay(PaymentInfo paymentInfo) {
if (this.paymentStatus == PaymentStatus.PAID) {
throw new IllegalStateException("订单已支付,不可重复支付");
}
if (this.status != OrderStatus.IN_PROGRESS) {
throw new IllegalStateException("订单状态不允许支付");
}
// ...
}
这段代码看着很充血,问题有三个:
- 有些校验需要外部依赖。 比如下单用户是否具备资格要查用户中心,把远程调用塞进聚合根,聚合根就没法单测了;
- 有些校验需要修改前的旧值。 这就是前面讲过的旧快照问题:
pay()执行之后paymentStatus已经变成PAID,此刻从当前状态根本无法区分首次支付和重复支付。而聚合根不该持有仓储; - 规则需要运行时开关。 大促期间临时放开某条限制、灰度环境关闭某条校验,写在方法里的
if无法动态摘除。
于是校验整体外移到规则容器:
java
public class OrderRule extends EntityRule<Order> {
@Override
protected boolean requireOldEntity() { return true; } // 支付前置守卫需要旧快照
private void registerRules() {
// 纯状态校验:订单金额必须为正数(不需要任何外部依赖)
this.addRule(
EntityRule.of(order -> RuleCheckResult.of(
order.getTotalAmount() != null
&& order.getTotalAmount().getAmount().compareTo(BigDecimal.ZERO) > 0)),
OrderRuleRegistry.ORDER_AMOUNT_POSITIVE);
// 需要外部依赖的校验:下单用户资格,仅在下单一刻激活
this.addRule(
EntityRule.of(order -> this.customerPermissionService.check(order.getCustomer())),
OrderRuleRegistry.ORDER_CUSTOMER_QUALIFIED,
IActiveRuleCondition.of(order -> order.hasOperation(OrderOperationRegistry.PLACE)
? ActiveStatus.ACTIVE : ActiveStatus.INACTIVE));
// 还有一条"仅允许取消进行中的订单",只在触发 CANCEL 时激活------
// 就是上一节"能力二"的样本,这里不再重复贴。
}
}
三个细节值得注意:激活条件 (让规则只在相关操作发生时才跑)、旧快照机制 (需要新旧对比时才加载)、消息码驱动(每条规则可被运行时替换与摘除)。
还有一个问题我得给明确立场,否则熟悉 DDD 的人会用 Guard 那套直接反驳:像"总额必须为正"这种不需要任何外部依赖的纯状态校验,也要搬出去吗?
我的立场是:要,哪怕它简单到只有一行。
理由不是技术上的,是治理上的。我当年就犯过这个错------把简单的留在方法里、复杂的搬出去,结果半年后一看,一半规则在方法里、一半在容器里,新人不看两处根本不知道全貌。一旦允许"简单的留在方法里、复杂的搬出去",你就造出了一条需要每个人自己拿捏的模糊边界,半年后必定是一半在里面、一半在外面。
规则容器的价值之一,就是给不变量提供唯一的入口 :想知道订单有哪些约束,打开 OrderRule 一个文件就够了,不用去十个业务方法里翻 if。
不该有任何计算,算好的值传进来
再看 addItem 的签名,注意第二个参数:
java
public void addItem(OrderItem item, Money totalAmount) {
// ...合并同商品订单项的逻辑略...
this.totalAmount = totalAmount; // 总额是外部算好传进来的,聚合根自己不算
this.markModified();
this.recordOperation(OrderOperationRegistry.ADD_ITEM);
}
订单总额是外部算好传进来的,聚合根自己不算。 为什么?因为"总额怎么算"是一个独立的业务策略(是否含运费、是否叠加优惠券、币种怎么换算),它会变,而且变化频率远高于"添加订单项"这件事。
框架用领域服务来声明这个意图:
java
@DomainService(
category = DomainServiceCategory.ATTRIBUTE_CALCULATOR,
targetName = "Order/OrderItem",
description = "汇总各订单项得到订单总额")
public interface IOrderTotalAmountCalculator
extends IEntityPropertyCalculator<List<OrderItem>, Order, Money> {
}
领域层只声明"需要算总额",实现放在应用层,换算法策略时聚合根一行不改。
这里的立场是统一的,没有例外:计算不进聚合根,算好的值传进来。 不管是汇总一整个订单项的列表,还是只拿自己几个字段乘一下,OrderItem 的小计也好,订单的总额也好,都是"算出来的结果",那就都该由外面算好、以值对象的形式传进来。
按"字段被随手 set 会不会出事"判断也一样:这些小计、折扣、税费、总额上,没有一个需要聚合根保护的不变量------它们只是结果,不是约束,所以聚合根没有理由负责产生它们。
还有一条比"策略会变"更硬的理由,还是治理上的:只要允许"自己顺手就能算的留在里面",边界就又变回了需要每个人自己拿捏的模糊地带。这和上面为什么连"总额必须为正"这种一行校验都要搬出去,是同一个理由------留一个例外,半年后就会长成一半在里面、一半在外面。
所以宁可让 addItem 的签名多带一个 totalAmount,也不给聚合根留一条"我可以自己算"的口子。
不该有数据转换与流程编排
Input DTO 到领域值对象的转换,放在独立的 Updater 里:
java
@Component
public class OrderPayUpdater implements EntityUpdater<Order, PayOrderInput> {
@Override
public void apply(Order aggregateRoot, PayOrderInput command) {
Money platformDiscount = new Money(command.getPlatformDiscountAmount(), command.getCurrency());
Money actualAmount = new Money(command.getAmount(), command.getCurrency());
PaymentInfo paymentInfo = new PaymentInfo(
command.getPaymentSerialNo(), platformDiscount, actualAmount,
PaymentMethod.of(command.getPayMethod()));
aggregateRoot.pay(paymentInfo); // 只负责调充血方法
}
}
流程编排则收敛在应用服务:一行 super.execute(order, orderRule, repository, updater::apply) 就搞定了校验、持久化、发事件、清状态。订单不存在这类业务事实,直接抛领域异常 OrderNotFoundException,而不是返回 null:
充血模型里,异常也是领域的一部分。
订单不存在、订单状态不允许支付、支付金额与应付金额不符,这些都是业务事实,应该有自己的领域异常类型,而不是
RuntimeException加一句中文消息,更不是null。
职责地图
| 位置 | 负责什么 | 不负责什么 |
|---|---|---|
聚合根 Order |
状态变更、记录操作、收集事件 | 校验、任何计算、远程调用、持久化 |
规则容器 OrderRule |
全部不变量、新旧对比、激活时机 | 改状态 |
| 领域服务接口 | 声明需要算、需要查的意图 | 具体实现 |
| Updater / Factory | Input 到领域对象的转换 | 校验与持久化 |
| 应用服务 | 编排:校验、落库、发事件、清状态 | 写业务规则 |
到这一步,"充到什么地步"的完整边界就齐了:收回权力(下限)→ 立三步模板(形状)→ 把校验、计算、编排请出去(上限)。 三步都做完,前面那六条收益才是真的。
为什么大家用不好:三个真实的坎
回到开头那句挡箭牌。充血模型确实容易翻车,但原因和充血本身无关 。三个坎里,前两个是同一条标准用反了的两个方向------一个做过头、一个做错对象;第三个才是真正难的那条。
坎一:把充血理解成"逻辑全塞进实体"
这是最大的坑,属于**"做过头"**,也是我当年自己踩过的。一旦认为充血等于逻辑进实体,聚合根就会迅速膨胀成上帝类:方法里查库存、调支付网关、写 Redis、发 MQ。然后你发现它没法单测、没法复用、改动一处牵连全身,于是得出结论"充血模型不适合我们"。
解法:给充血设上限,就是上面那张职责地图。需要外部数据或外部能力的逻辑,一律不进聚合根,用领域服务接口声明意图,实现放外层。
坎二:分不清"状态归属"和"数据搬运"
这是**"做错对象"**。有人被充血洗脑后,连 DTO、PO、读模型投影都要加业务方法,理由是这样才面向对象。结果列表查询的对象上挂了一堆用不到的方法,序列化时还踩坑。
解法 :回到"字段被随手 set 会不会出事"那条标准。只有存在需要保护的不变量的对象才充血,纯数据容器安心贫血。一个系统里贫血对象占大多数,是完全正常的状态。
坎三:约定守不住,充血会自发退化
最隐蔽、也最难的一个。 就算团队第一天把 setter 全设成了 protected,只要没有机制约束,三个月后一定会出现这样的代码:
java
// 反例示意:为了方便,setter 又被放开了
order.setStatus(OrderStatus.COMPLETED); // 绕过业务方法
原因很朴素:赶工期、图省事、新人不知道规矩。约定敌不过便利,靠 code review 守不住。 这是我当年加 review 规则失败换来的教训。
如果你暂时不想引入任何框架,有三件事是可以立刻做的。
第一,用 ArchUnit 写一条架构守卫测试。 把"聚合根的 setter 不允许被领域层以外调用""聚合根不允许依赖仓储和基础设施"变成可执行的断言:
java
// 示意代码,API 按你用的 ArchUnit 版本调整
@AnalyzeClasses(packages = "com.yourcompany")
class DomainRulesTest {
@ArchTest
static final ArchRule setter_only_in_domain =
noClasses().resideOutsideOfPackage("..domain..")
.should().callMethodWhere(
target(nameMatching("set.*"))
.and(target(owner(assignableTo(AggregateRoot.class)))));
// 另一条同理:聚合根不得依赖 ..infrastructure.. / ..repository..
}
第二,把守卫接进 CI。 测试写了不跑等于没写,必须让违规直接阻断合并,而不是生成一个没人看的报告。
第三,用 IDE 文件模板生成骨架。 让"正确的写法"一键生成------带 protected setter、带三步式方法的聚合根模板,比任何规范文档都有效,因为它省的是写代码的人的时间。
这三招能挡住大部分退化,但它们终归是外围约束:ArchUnit 规则要自己维护,模板要人手一份,新项目要重新配一遍,漏配了也没人知道。
更彻底的做法,是让框架在基类层面把边界锁死 ------setter 在 AbstractEntity 上就是 protected,业务方法强制走 execute() 模板,事件由框架统一发布,让正确的写法比省事的写法更省力。这是框架该干的活,也是 pragmatic-ddd 这类框架存在的意义。
所以下次再听到"充血模型用不好",可以把问题翻译成一句更准确的话:
不是充血模型用不好,是没人告诉我们充血的边界在哪,也没人把这条边界变成编译器和 CI 能拦住的东西。
写在最后:充到什么地步的一句话
如果这篇你只带走一句,我希望是这句:
充血的收益不是把逻辑搬进实体换来的,是把逻辑搬进来之后、还忍得住不把别的东西搬进来换来的。
而你判断"充到什么地步"的尺子,始终是开头那句话------充到"外界只能通过业务方法改状态"就到顶了;外部还有多少条路能绕过你的业务方法,就是你还差多少。
你们团队现在是贫血、充血,还是"贫血的充血模型"?踩过哪些坑?评论区聊聊。