一、先看看问题现场

一套订单系统上线半年,业务方天天提报表对不上。查了半天,发现根子不是算法复杂,而是校验逻辑写得到处都是。比如创建一个订单,构造函数里校验订单号不能为空,字段赋值方法里又校验金额不能为负,到了用例入口,业务人员不放心,又把同样的话念了一遍。三份代码长得很像,却散落在三个不同的地方。

看一个常见的坏味道,这是简化后的代码:

// 构造函数、字段赋值方法、用例入口三层重复校验的坏味道(Java 示例)
public class Order {
    private String id;
    private BigDecimal totalAmount;

    public Order(String id, BigDecimal totalAmount) {
        // 第一份校验:构造函数
        if (id == null || id.isBlank()) {
            throw new IllegalArgumentException("订单号不能为空");
        }
        if (totalAmount == null || totalAmount.compareTo(BigDecimal.ZERO) <= 0) {
            throw new IllegalArgumentException("金额必须大于 0");
        }
        this.id = id;
        this.totalAmount = totalAmount;
    }

    // 第二份校验:字段赋值方法
    public void setTotalAmount(BigDecimal totalAmount) {
        if (totalAmount == null || totalAmount.compareTo(BigDecimal.ZERO) <= 0) {
            throw new IllegalArgumentException("金额必须大于 0");
        }
        this.totalAmount = totalAmount;
    }
}

用例入口再来一遍:

// 用例入口(Java 示例)
public class CreateOrderService {
    public void create(String id, BigDecimal totalAmount) {
        // 第三份校验:用例入口
        if (totalAmount == null || totalAmount.compareTo(BigDecimal.ZERO) <= 0) {
            throw new IllegalArgumentException("金额必须大于 0");
        }
        Order order = new Order(id, totalAmount);
        orderRepo.save(order);
    }
}

这段代码最扎眼的地方,是“金额必须大于 0”这条规则出现了三次。如果哪天业务改成“金额可以为 0,但必须大于等于 0”,你得同时改三份。只要漏掉一份,规则就会在某个入口失效。这种散落不是懒,而是缺少一个统一的思路:到底谁该为实体的正确性负责。

1.1 构造函数里的校验

构造函数是对象出生的那一刻,大家本能地在这里做校验,这很自然。可问题是,构造函数只保护了“生出来的时候合格”,保护不了后来发生的事。如果后面通过字段赋值方法改成非法值,构造函数根本管不着。

1.2 字段赋值方法里的校验

于是不少人在字段赋值方法里加了第二重校验。可字段赋值方法只负责改单个字段,很多跨字段的规则它看不见。比如“订单总金额不得小于所有商品金额之和”这种规则,单独改一个字段根本判断不了,因为数据还没凑齐。

1.3 用例入口的校验

用例入口往往是最保险的一道,因为所有请求都会从这儿过。可是用例层的代码本该关心“做什么”,现在变成了“怎么判断”,职责歪了。更糟的是,有的项目为了省事,把规则写在 Controller、Service、MQ 消费端里,同一个项目能找出七八套说法。

二、实体不变量到底是什么?

不变量翻译成大白话,就是对象身上的“底线”。无论外部怎么调用,无论对象处于哪个状态,这些底线都不允许被突破。举个例子:人的身份证号不能是空的,这叫底线;订单创建之后状态必须是“草稿”,不能一出生就是“已支付”,这也叫底线;订单一旦确认,商品清单就不能再改,这还是底线。

这些底线有一个共同特点:它们依赖对象内部的状态和字段,只有对象自己才看得清。让用例层或字段赋值方法去守,等于让外人来管别人的家务事,既看不清,也很容易管重。

从面向对象的角度说,实体不变量是让对象始终“自洽”的保证。对象如果总是处于合法状态,调用方就不用到处做防御性判断,代码自然干净很多。这个词听起来高级,本质就是“自己管好自己”。

三、整洁架构里,规则应该住在哪?

整洁架构画出来有很多圈,最里面是实体,往外是用例层,再往外是接口适配层,最外面是框架和数据库。依赖规则有一条硬性规定:所有依赖只能指向内圈,实体不能反过来依赖外层。

业务规则校验恰恰是实体的核心职责。如果让构造函数、字段赋值方法和用例入口各管一角,就等于把规则拆散放到了多个圈里。用例层本来应该依赖实体,结果它还反过来帮助实体判断规则,依赖方向就变得乱糟糟。从架构视角看,这不是“多写几行代码”的问题,而是职责边界已经破了。

实体不变量原则在整洁架构里的落脚点,就是让所有业务规则都收拢到实体内部。用例层只负责编排,比如“接收请求、创建实体、保存实体”。不该由用例层判断“金额要大于零”,那是订单自己的事。

四、怎么收敛:让实体自己守规矩

4.1 收敛思路

收敛说难也难,说简单也简单。守住三条铁律:

  • 实体不提供暴露字段的赋值方法,所有状态变化通过业务方法发生。
  • 构造函数负责“出生即合格”,所有构造完的对象必须立刻满足不变量。
  • 每个业务方法在改变状态之后,统一调用一个私有校验方法,确保每次修改后仍然满足不变量。

这三条一落地,散落在外的规则自然就回到了实体里。

4.2 完整示例:自校验订单实体(Java)

下面用 Java 写一个完整的自校验订单实体。这个类不允许外部直接改状态,只能通过工厂方法创建,通过业务方法修改。每一次创建和修改都会触发校验。

import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;

/**
 * 自校验订单实体(Java 示例)
 *
 * 设计重点:
 * 1. 构造函数私有,外部不能直接 new
 * 2. 提供静态工厂方法 create,保证对象一出生就合规
 * 3. 所有修改动作都调用 validateInvariants()
 */
public class Order {
    /** 订单状态 */
    public enum Status {
        CREATED,
        CONFIRMED,
        CANCELLED
    }

    /** 订单号,只读 */
    private final String id;
    /** 商品清单,防止外部直接修改,因此拷贝一份 */
    private final List<OrderItem> items;
    /** 当前状态,只能通过业务方法改变 */
    private Status status;

    /**
     * 私有构造函数:外部无法直接调用,只能走工厂方法
     */
    private Order(String id, List<OrderItem> items) {
        this.id = id;
        this.items = new ArrayList<>(items);
        this.status = Status.CREATED;
        // 构造结束时立即校验,不合格就不允许出生
        validateInvariants();
    }

    /**
     * 工厂方法:创建订单的唯一入口
     */
    public static Order create(String id, List<OrderItem> items) {
        return new Order(id, items);
    }

    /**
     * 业务方法:添加商品
     * 规则:只有已创建状态的订单才能添加商品
     */
    public void addItem(OrderItem newItem) {
        if (status != Status.CREATED) {
            throw new IllegalStateException("只有新创建的订单才能添加商品");
        }
        // 这里不单独校验订单金额,交给统一的方法去检查
        this.items.add(newItem);
        // 状态变化后统一校验
        validateInvariants();
    }

    /**
     * 业务方法:确认订单
     */
    public void confirm() {
        if (status == Status.CANCELLED) {
            throw new IllegalStateException("已取消订单不能确认");
        }
        if (status == Status.CONFIRMED) {
            throw new IllegalStateException("订单已经确认过了");
        }
        if (this.items.isEmpty()) {
            throw new IllegalStateException("空订单不能确认");
        }
        this.status = Status.CONFIRMED;
        validateInvariants();
    }

    /**
     * 业务方法:取消订单
     */
    public void cancel() {
        if (status == Status.CONFIRMED) {
            throw new IllegalStateException("已确认订单不能取消");
        }
        this.status = Status.CANCELLED;
        validateInvariants();
    }

    /**
     * 不变量校验方法
     *
     * 不管前面走了哪个业务方法,最后都会汇聚到这里。
     * 这里检查的是实体最底层的几条底线。
     */
    private void validateInvariants() {
        if (id == null || id.isBlank()) {
            throw new IllegalStateException("订单号缺失,实体不完整");
        }
        if (items == null) {
            throw new IllegalStateException("商品清单缺失,实体不完整");
        }
        // 订单总金额不允许为负
        if (totalAmount().compareTo(BigDecimal.ZERO) < 0) {
            throw new IllegalStateException("订单总金额为负,违反不变量");
        }
        // 已确认订单必须至少包含一件商品
        if (status == Status.CONFIRMED && items.isEmpty()) {
            throw new IllegalStateException("已确认订单必须有商品");
        }
    }

    /** 计算订单总金额 */
    public BigDecimal totalAmount() {
        return items.stream()
                .map(OrderItem::getPrice)
                .reduce(BigDecimal.ZERO, BigDecimal::add);
    }

    /**
     * 订单项,也自带局部校验
     */
    public static class OrderItem {
        private final String productId;
        private final BigDecimal price;

        public OrderItem(String productId, BigDecimal price) {
            // 商品项的底线:编码不能空、价格必须大于 0
            if (productId == null || productId.isBlank()) {
                throw new IllegalArgumentException("商品编码不能为空");
            }
            if (price == null || price.compareTo(BigDecimal.ZERO) <= 0) {
                throw new IllegalArgumentException("商品价格必须大于 0");
            }
            this.productId = productId;
            this.price = price;
        }

        public BigDecimal getPrice() {
            return price;
        }
    }
}

有了这个实体,用例入口就变得非常干净。看看下面的对比:

// 用例入口,负责编排不负责判断(Java 示例)
public class CreateOrderUseCase {

    private final OrderRepository orderRepository;

    public CreateOrderUseCase(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    /**
     * 执行创建订单
     * 业务规则已经被 Order 内部守住了,这里不再重复
     */
    public void execute(CreateOrderRequest request) {
        Order.OrderItem item = new Order.OrderItem(
                request.getProductId(),
                request.getPrice()
        );
        // 如果订单号缺失,Order 内部会抛异常
        Order order = Order.create(request.getOrderId(), List.of(item));
        orderRepository.save(order);
    }
}

同样,确认订单入口也不用关心“是不是空订单”之类的规则:

// 确认订单用例(Java 示例)
public class ConfirmOrderUseCase {
    private final OrderRepository orderRepository;

    public ConfirmOrderUseCase(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    public void execute(String orderId) {
        Order order = orderRepository.findById(orderId);
        if (order == null) {
            throw new IllegalArgumentException("订单不存在");
        }
        // 状态能不能推进,由 Order 自己说了算
        order.confirm();
        orderRepository.save(order);
    }
}

4.3 关联技术:Bean Validation 能替代吗?

有同学会问,Java 生态的 Bean Validation 那套注解能不能用来做这道收口?答案是能做一部分,但不能完全替代。

Bean Validation 非常适合做入参校验。比如请求对象里的订单号不能为空、价格必须为正,这种单字段规则放注解上很直观。但它有一个短板:不适合表达跨字段、跨状态的业务规则。比如“已确认订单不能添加商品”这种规则,注解写不出来,需要写在业务方法里。而且如果把它当成不变量校验,当业务方法很多时,注解会在代码里到处飞,反而又散落了。

正确姿势是把两者结合。接口适配层负责把外部请求里的“低级格式问题”先过滤掉,用一个简单的 Java 类带上标准校验注解;到了领域层,实体的校验方法再守真正的业务底线。一个管外部输入,一个管内部状态,各司其职。

// 使用 Bean Validation 做入参格式校验(Java 示例)
public class CreateOrderRequest {

    @NotBlank(message = "订单号不能为空")
    private String orderId;

    @NotNull(message = "价格不能为空")
    @Positive(message = "价格必须大于 0")
    private BigDecimal price;

    public String getOrderId() {
        return orderId;
    }

    public BigDecimal getPrice() {
        return price;
    }
}

五、应用场景、优缺点、注意事项

5.1 应用场景

这种收敛方式并不是所有软件都适用。当你的项目满足以下特征时,收益最明显:

  • 同一个实体被多个用例入口复用,而且大家都靠一份规则在检查。
  • 实体的状态流转有明确限制,比如草稿、已支付、已发货,每一步都有门槛。
  • 团队成员经常在业务规则上互相“好心补齐”,导致同一个规则出现好几个版本。
  • 项目在做领域驱动设计,已经有聚合、实体、领域服务这些分层概念。

在这些场景下,把规则收进实体,等于把责任钉死在最合适的位置,减少很多无谓的争吵和返工。

5.2 技术优缺点

先列优点。第一,规则内聚。业务规则只写一遍,修改时只需要打开一个类。第二,实体可信。外部拿到的实体永远是合法实体,调用方不需要再做防御性判断。第三,可测试性好。写单元测试时,直接构造实体对象,反复调用业务方法,断言哪些操作会抛异常,要覆盖的分支非常集中。第四,架构姿态正确。依赖箭头从用例指向实体,业务规则没有跑到外层去。

再列缺点。第一,实体可能会变重。如果什么规则都往里塞,实体代码会膨胀,读起来有点累。解决方法是区分“实体自身的不变量”和“跨实体的协作规则”,后者放到领域服务。第二,对简单的 CRUD 项目是过度设计。如果实体只是一个数据容器,没有任何复杂的业务状态,把所有校验收进来反而显得笨重。第三,要有纪律。团队里有人绕过工厂方法去用反射创建对象,或者在用例层直接操作实体内部字段,这套防线就会破功。

5.3 注意事项

要让这套机制真正落地,有几个坑务必要绕开。

  • 不要试图把所有校验都塞进实体。比如“用户是否有权限创建订单”这种规则属于权限认证,不属于订单自身的不变量;跨实体的一致性,比如“订单总额不能超过用户余额”,需要领域服务去协调,硬塞进单个实体反而让实体承担责任之外的事。
  • 集合字段要做防御性拷贝。看上面 Order 的例子,构造函数里把传入的商品清单复制了一份,而不是直接赋值。如果不拷贝,外部拿到原始集合后随时改了内容,实体毫无察觉,不变量就被绕过了。
  • 校验方法不要产生副作用。校验方法里只允许读取字段、计算结果、抛异常,不能修改状态、打日志、发消息。一旦校验和修改混在一起,代码会变得特别难排查。
  • 业务方法要先执行业务判断,再更新状态,最后统一校验。有人喜欢在每个方法前面先调一次校验,但那是判“旧状态”,不是判“新状态”。正确的顺序是先改变状态,再让校验方法去检查新的状态合不合法。
  • 不要在业务方法里重复抛跟统一校验一样的异常。既然统一方法已经存在,业务方法只需要把好业务状态那一关,剩下的交给统一方法。不然又变回两套规则。

5.4 补充一个容易忽略的细节

在 Java 里,有人会想到用 assert 关键字校验不变量。assert 默认是关闭的,生产环境里基本不干活,千万别用它做校验。真要写,还是写成抛出异常比较靠谱。另一个细节是,当字段是 final 时,构造阶段更安全,但业务方法的赋值依然需要校验,两者不冲突。

六、总结

回到开头的问题:一套业务规则被拆到构造函数、字段赋值方法和用例入口里面,说到底是因为没人给实体一个明确的权利——自己守护自己的底线。整洁架构给了我们很好的提示:实体处于依赖的最内层,规则应该朝内聚,而不是朝外散。

收敛的办法也不复杂:不暴露字段赋值方法,所有状态变化走业务方法;构造函数里确保“出生即完整”;业务方法改完状态之后统一走私有校验。把这三点做完,散落的规则会自然回归到实体内部。用例层瘦身了,测试变集中了,后来者也不会再随便在入口处堆一套重复判断。

最后提醒一句,这种收敛要结合场景。如果你的项目只是十几个简单数据表,没有复杂状态流转,不必照搬;但如果你正在维护一个规则密集、状态多的领域模型,尽早收敛一定比晚做要好。守住不变量,就是守住软件质量的底线。