充血模型该充到什么地步?

先别急着定义"充血"。咱们看一段你大概率写过的代码------一个订单支付方法,它是怎么一点点烂掉的。

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. 收集领域事件,但不发布
}

三步,缺一不可,但也到此为止:

  1. 改状态 :只改自己的字段,绝不碰别的聚合;末尾用 markModified() 更新审计时间;
  2. 记操作recordOperation 声明本次发生了什么,供规则激活与事件因果溯源使用;
  3. 收事件collectEvent 声明这个事实值得被外界知道,不负责发布------发布由框架在落库之后统一完成。

这个度可以概括成一句话:

充血 = 描述发生了什么,不负责什么时候执行。

这也是 pragmatic-ddd 最核心的设计主张:领域层只声明业务事实 ,校验时机、落库、发布、重试兜底全部交给框架托管。带来的直接好处在上一章已经见过:聚合根方法可以被单独单测,new 一个 Order、调 ship()、断言状态与事件,结束。


充到什么地步(上限):把三件事请出聚合根

下限和模板都有了,最后一件事是划上限 。充血模型被骂用不好,九成是死在了没有上限。看看 Order 聚合根里没有什么,比看它有什么更有意思。

不该有校验,规则外移到规则容器

在展开之前,先堵一个我当年也纠结过的疑问。

按"不变量在哪行为就在哪"的标准,现在把订单的全部不变量搬进 OrderRule,连"总额必须为正"这种纯状态校验都搬走了------Order 凭什么还算充血?这不是自相矛盾吗?

我当年真就这么质疑过自己。后来想通三件事:

  1. 规则容器没有离开领域层。 OrderRuleOrder 在同一个领域包里,它不是应用层的参数校验器,而是聚合根的另一半。把规则从"方法内"挪到"同层的规则类里",是同层内的分工,不是失控外逃;
  2. 规则与聚合根是配对关系,由框架强制绑定。 应用服务的 execute(aggregate, rule, repository, ...) 模板决定了"调 pay() 必然过 OrderRule",不存在绕过规则改状态的路径;
  3. 所以那条标准要补一句限定:不变量的归属,决定对象是否充血;不变量的执行位置,由治理需求决定。

直觉上充血模型应该把校验写在方法里:

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 能拦住的东西。


写在最后:充到什么地步的一句话

如果这篇你只带走一句,我希望是这句:

充血的收益不是把逻辑搬进实体换来的,是把逻辑搬进来之后、还忍得住不把别的东西搬进来换来的。

而你判断"充到什么地步"的尺子,始终是开头那句话------充到"外界只能通过业务方法改状态"就到顶了;外部还有多少条路能绕过你的业务方法,就是你还差多少。

你们团队现在是贫血、充血,还是"贫血的充血模型"?踩过哪些坑?评论区聊聊。

相关推荐
杨利杰YJlio1 小时前
ITSK PE 26U5 测试版解读:组件完善、VMD 修复与服务器支持边界
前端·javascript·后端
雨辰AI1 小时前
信创多租户项目 9 大踩坑|数据隔离失效、权限越权终极解决(金仓 / 达梦 / 高斯全库适配)
java·大数据·数据库·后端
广州山泉婚姻1 小时前
微服务时代,前后端分离架构该怎样高效协同?
前端·后端
孙启超1 小时前
【AI开发之Rust】第 5 课:引用与生命周期 —— 借用能活多久?
人工智能·后端·rust·llm·ai应用开发
EatFan1 小时前
多租户系统到底怎么设计?从 tenant_id 到 JWT 租户隔离的真实实践
前端·后端·全栈·多租户
Bs_MoneyMagnet1 小时前
基于springboot+vue的旅游行程分享与推荐小程序的设计与实现 源码+文档
java·vue.js·spring boot·后端·微信小程序·毕业设计·计算机毕业设计
EatFan1 小时前
我为什么把患者与病例从 1:1 改成 1:N?一次真实数据库模型重构记录
数据库·后端·重构·健康医疗·全栈
wno7042 小时前
Spring Boot整合MongoDB
spring boot·后端·mongodb
GreenTea2 小时前
🔥别再用压缩了,Codex、NVIDIA、DeepSeek、Uber 给出 Agent 上下文管理的新答案
前端·后端·算法