以订单提交校验为例,使用可组合规则对象拆分复杂条件,讨论快速失败、错误聚合、执行顺序、外部查询隔离及测试策略。

问题背景

订单提交、退款申请、优惠券领取这类业务,通常都存在一组不断增长的前置条件。最初只有两三个判断时,直接写 if 很自然:

public void submit(Order order, Customer customer) {
    if (order.getStatus() != OrderStatus.CREATED) {
        throw new IllegalStateException("订单状态不允许提交");
    }
    if (order.getAmount().compareTo(BigDecimal.ZERO) <= 0) {
        throw new IllegalArgumentException("订单金额必须大于零");
    }
    if (customer.isBlocked()) {
        throw new IllegalStateException("客户已被限制交易");
    }
    if (!stockService.hasEnoughStock(order)) {
        throw new IllegalStateException("库存不足");
    }

    order.submit();
}

真正的问题并不是 if 本身,而是规则会持续变化:不同入口需要不同组合,页面希望一次展示全部错误,后台任务却只关心能否执行;部分判断依赖数据库,部分判断只依赖订单字段。继续把逻辑堆在一个方法里,会逐渐出现重复代码、隐式执行顺序和难以覆盖的分支。

这时可以借鉴 Specification 思想,将每条业务规则表示为对象,并通过组合形成完整校验。重点不是消灭条件判断,而是给规则明确的边界和名字。

设计一个可解释的规则接口

只返回 boolean 虽然简单,却无法告诉调用方失败原因。下面定义一个适用于 Java 8 及以上版本的规则接口:

@FunctionalInterface
public interface Rule<T> {

    CheckResult check(T target);

    default Rule<T> and(Rule<T> next) {
        return target -> {
            CheckResult current = check(target);
            if (!current.isPassed()) {
                return current;
            }
            return next.check(target);
        };
    }
}

and 采用短路语义:前一条规则失败后,不再执行下一条。这适合后台任务和命令处理,因为可以减少无意义的计算,也能避免后续规则建立在错误前提上。

校验结果同时保存状态和错误信息:

public final class CheckResult {
    private final boolean passed;
    private final List<String> errors;

    private CheckResult(boolean passed, List<String> errors) {
        this.passed = passed;
        this.errors = Collections.unmodifiableList(
                new ArrayList<>(errors));
    }

    public static CheckResult pass() {
        return new CheckResult(true, Collections.emptyList());
    }

    public static CheckResult fail(String error) {
        return new CheckResult(false,
                Collections.singletonList(error));
    }

    public static CheckResult fail(List<String> errors) {
        return new CheckResult(false, errors);
    }

    public boolean isPassed() {
        return passed;
    }

    public List<String> getErrors() {
        return errors;
    }
}

这里让结果保持不可变,避免多个规则共享并修改同一个错误列表。规则对象如果注册为 Spring 单例,也不会因此产生并发问题。

先准备上下文,再执行规则

规则经常需要订单之外的信息,例如客户是否被限制、库存是否充足。一个常见错误是让每条规则自行查询数据库,最终一次校验触发多次重复查询,而且调用方无法看出成本。

更稳妥的做法是由应用服务统一准备上下文:

public final class ReviewContext {
    private final Order order;
    private final boolean customerBlocked;
    private final boolean stockEnough;

    public ReviewContext(Order order,
                         boolean customerBlocked,
                         boolean stockEnough) {
        this.order = order;
        this.customerBlocked = customerBlocked;
        this.stockEnough = stockEnough;
    }

    public Order getOrder() {
        return order;
    }

    public boolean isCustomerBlocked() {
        return customerBlocked;
    }

    public boolean isStockEnough() {
        return stockEnough;
    }
}

这样,规则只负责判断,不隐藏网络或数据库访问。应用服务还可以批量加载数据,并明确设置超时、事务和缓存边界。

完整的规则组合示例

下面给出几条独立规则:

Rule<ReviewContext> statusRule = context ->
        context.getOrder().getStatus() == OrderStatus.CREATED
                ? CheckResult.pass()
                : CheckResult.fail("只有待创建订单可以提交");

Rule<ReviewContext> amountRule = context ->
        context.getOrder().getAmount().compareTo(BigDecimal.ZERO) > 0
                ? CheckResult.pass()
                : CheckResult.fail("订单金额必须大于零");

Rule<ReviewContext> customerRule = context ->
        context.isCustomerBlocked()
                ? CheckResult.fail("客户已被限制交易")
                : CheckResult.pass();

Rule<ReviewContext> stockRule = context ->
        context.isStockEnough()
                ? CheckResult.pass()
                : CheckResult.fail("商品库存不足");

后台提交可以采用快速失败组合:

Rule<ReviewContext> submitRule = statusRule
        .and(amountRule)
        .and(customerRule)
        .and(stockRule);

CheckResult result = submitRule.check(context);
if (!result.isPassed()) {
    throw new OrderRejectedException(result.getErrors());
}

order.submit();

规则名称表达了业务含义,组合处则直接展示本场景的校验清单。以后如果“人工补单”不需要库存校验,可以组合另一套规则,而不是在规则内部增加 if (source == MANUAL)

页面场景如何收集全部错误

快速失败并不适合所有入口。用户填写表单后,通常希望一次看到所有可以独立判断的问题。可以提供另一种显式组合方式:

public final class Rules {
    private Rules() {
    }

    @SafeVarargs
    public static <T> Rule<T> allOf(Rule<T>... rules) {
        return target -> {
            List<String> errors = new ArrayList<>();
            for (Rule<T> rule : rules) {
                CheckResult result = rule.check(target);
                if (!result.isPassed()) {
                    errors.addAll(result.getErrors());
                }
            }
            return errors.isEmpty()
                    ? CheckResult.pass()
                    : CheckResult.fail(errors);
        };
    }
}

使用时应当明确表达意图:

Rule<ReviewContext> pageValidation = Rules.allOf(
        statusRule,
        amountRule,
        customerRule,
        stockRule
);

不要机械地收集所有错误。例如订单不存在时,继续校验金额和库存没有意义。更合理的方式是分阶段:先验证订单存在和数据完整性,再聚合彼此独立的业务规则。

测试应围绕规则,而不是覆盖组合数量

拆分后的直接收益是测试范围变小。金额规则只需要验证边界值,不必构造客户和库存服务:

@Test
void amountMustBeGreaterThanZero() {
    Order order = new Order(OrderStatus.CREATED, BigDecimal.ZERO);
    ReviewContext context = new ReviewContext(order, false, true);

    CheckResult result = amountRule.check(context);

    assertFalse(result.isPassed());
    assertEquals("订单金额必须大于零", result.getErrors().get(0));
}

除了单条规则,还应为每种业务场景保留少量组合测试,确认规则没有遗漏、顺序符合预期。没有必要穷举所有规则的排列组合。

常见的重构陷阱

1. 把规则类拆得过碎

如果每个规则只有一行代码,却需要接口、实现类、工厂和配置类才能运行,可读性反而下降。稳定且简单的规则可以使用 Lambda;逻辑复杂、需要独立测试或拥有明确业务名称时,再提取为类。

2. 在规则中修改订单

校验规则最好保持只读。如果某条规则顺便锁库存、改状态,执行顺序就会变成隐藏契约,重复校验还可能产生副作用。状态变更应在全部必要规则通过后执行。

3. 混淆业务拒绝与系统异常

“库存不足”是可预期的业务结果,应返回失败原因;数据库不可用、响应解析失败则是系统异常,不应该伪装成普通校验失败,否则监控和重试策略都会失真。

4. 用配置动态编排所有规则

规则组合写在代码中并不落后。代码能够接受编译检查、重构和测试。只有规则确实需要由运营人员频繁调整时,才值得引入动态配置或规则引擎,同时还要承担版本管理、灰度和审计成本。

实践建议

重构时不要一次改写整个校验模块。可以先选取重复最多、变化最频繁的一组条件,提取为无副作用规则;随后把外部查询上移到应用服务,形成明确的上下文;最后根据入口决定快速失败还是错误聚合。

可组合规则对象并不是为了展示设计模式,而是让业务约束拥有名字、边界和可测试性。当新增规则只需要增加一个判断并修改清晰的组合列表,而不必穿过多层嵌套条件时,这次重构才真正改善了可维护性。

最后修改:2026 年 08 月 11 日
如果觉得我的文章对你有用,请随意赞赏