一、先看看问题现场
一套订单系统上线半年,业务方天天提报表对不上。查了半天,发现根子不是算法复杂,而是校验逻辑写得到处都是。比如创建一个订单,构造函数里校验订单号不能为空,字段赋值方法里又校验金额不能为负,到了用例入口,业务人员不放心,又把同样的话念了一遍。三份代码长得很像,却散落在三个不同的地方。
看一个常见的坏味道,这是简化后的代码:
// 构造函数、字段赋值方法、用例入口三层重复校验的坏味道(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 时,构造阶段更安全,但业务方法的赋值依然需要校验,两者不冲突。
六、总结
回到开头的问题:一套业务规则被拆到构造函数、字段赋值方法和用例入口里面,说到底是因为没人给实体一个明确的权利——自己守护自己的底线。整洁架构给了我们很好的提示:实体处于依赖的最内层,规则应该朝内聚,而不是朝外散。
收敛的办法也不复杂:不暴露字段赋值方法,所有状态变化走业务方法;构造函数里确保“出生即完整”;业务方法改完状态之后统一走私有校验。把这三点做完,散落的规则会自然回归到实体内部。用例层瘦身了,测试变集中了,后来者也不会再随便在入口处堆一套重复判断。
最后提醒一句,这种收敛要结合场景。如果你的项目只是十几个简单数据表,没有复杂状态流转,不必照搬;但如果你正在维护一个规则密集、状态多的领域模型,尽早收敛一定比晚做要好。守住不变量,就是守住软件质量的底线。
评论
围绕“当业务规则校验散落在构造函数、字段赋值方法和用例入口时,从整洁架构视角该怎么收敛才能符合实体不变量原则?”参与讨论