支付方式不断增加时,真正难维护的不是 if-else 本身,而是业务规则、依赖获取、异常处理和扩展入口被挤在同一个方法里。本文从一段常见代码出发,使用策略模式与注册表重构支付路由,并讨论如何避免过度设计。
支付方式只有两三种时,if-else 往往是最直接、也最容易读懂的方案。问题通常不是它一开始写得不好,而是业务增长之后,新的判断不断被追加进去:银行卡要校验限额,钱包支付要检查实名状态,积分支付要判断余额,某些渠道还需要查询外部服务。
最后,一个原本几十行的方法开始同时负责参数校验、支付方式选择、渠道调用、异常转换和日志记录。任何新增支付方式,都必须修改这个“大方法”,测试范围也随之扩大。
这篇文章选择一个具体问题:如何把不断膨胀的支付方式分支,重构成容易扩展、容易测试的策略结构。重点不在于套用一个设计模式,而在于重新划分职责,让变化集中在合适的位置。
一段正在变复杂的代码
下面是一段简化后的支付服务:
public class PaymentService {
private final BankClient bankClient;
private final WalletClient walletClient;
private final PointClient pointClient;
public PaymentService(BankClient bankClient,
WalletClient walletClient,
PointClient pointClient) {
this.bankClient = bankClient;
this.walletClient = walletClient;
this.pointClient = pointClient;
}
public PaymentResult pay(PaymentRequest request) {
if (request == null || request.amount() == null
|| request.amount().signum() <= 0) {
throw new IllegalArgumentException("支付金额必须大于 0");
}
if ("BANK".equals(request.method())) {
if (request.accountId() == null) {
throw new IllegalArgumentException("银行卡账户不能为空");
}
return bankClient.pay(request.accountId(), request.amount());
} else if ("WALLET".equals(request.method())) {
if (request.accountId() == null) {
throw new IllegalArgumentException("钱包账户不能为空");
}
return walletClient.pay(request.accountId(), request.amount());
} else if ("POINT".equals(request.method())) {
return pointClient.pay(request.accountId(), request.amount());
}
throw new UnsupportedOperationException("不支持的支付方式");
}
}这段代码并非立刻需要重构。它的问题在于,变化方向已经很清楚:支付方式增加时,PaymentService 会持续增加条件分支,并且每个分支都包含不同的校验和调用细节。
如果继续扩展,主流程会逐渐失去稳定性。一个新增的支付渠道,可能因为修改了共享校验、异常处理或日志逻辑,影响原来已经工作的渠道。这里真正需要隔离的,不只是“选择哪个分支”,而是每种支付方式自身的业务规则。
先确定稳定的抽象
支付方式虽然实现不同,但通常都有一个共同动作:接收支付请求,返回支付结果。因此可以先定义一个策略接口:
public interface PaymentStrategy {
String method();
PaymentResult pay(PaymentRequest request);
}method() 是策略的唯一标识,pay() 承载该支付方式自己的处理流程。接口不应该暴露银行卡客户端、钱包客户端等具体依赖,否则调用方仍然需要知道策略内部的细节。
请求和结果可以使用简单的不可变对象:
import java.math.BigDecimal;
public record PaymentRequest(
String method,
String accountId,
BigDecimal amount) {
}
public record PaymentResult(
boolean success,
String transactionId,
String message) {
public static PaymentResult success(String transactionId) {
return new PaymentResult(true, transactionId, "支付成功");
}
public static PaymentResult failure(String message) {
return new PaymentResult(false, null, message);
}
}这里使用了 record 来表达只读数据对象。若项目尚未使用支持 record 的 Java 版本,也可以用 final 字段、构造方法和访问器实现同样的不可变语义;设计重点并不依赖某个语法特性。
让每种策略只处理自己的规则
下面实现银行卡和钱包两种策略。外部客户端是接口,便于替换真实实现和编写单元测试。
import java.math.BigDecimal;
public interface BankClient {
String pay(String accountId, BigDecimal amount);
}
public interface WalletClient {
String pay(String accountId, BigDecimal amount);
}
public final class BankPaymentStrategy implements PaymentStrategy {
private final BankClient bankClient;
public BankPaymentStrategy(BankClient bankClient) {
this.bankClient = bankClient;
}
@Override
public String method() {
return "BANK";
}
@Override
public PaymentResult pay(PaymentRequest request) {
requireAccount(request);
String transactionId = bankClient.pay(
request.accountId(), request.amount());
return PaymentResult.success(transactionId);
}
private void requireAccount(PaymentRequest request) {
if (request.accountId() == null
|| request.accountId().isBlank()) {
throw new IllegalArgumentException("银行卡账户不能为空");
}
}
}
public final class WalletPaymentStrategy implements PaymentStrategy {
private final WalletClient walletClient;
public WalletPaymentStrategy(WalletClient walletClient) {
this.walletClient = walletClient;
}
@Override
public String method() {
return "WALLET";
}
@Override
public PaymentResult pay(PaymentRequest request) {
if (request.accountId() == null
|| request.accountId().isBlank()) {
throw new IllegalArgumentException("钱包账户不能为空");
}
String transactionId = walletClient.pay(
request.accountId(), request.amount());
return PaymentResult.success(transactionId);
}
}现在,银行卡规则的修改不会触碰钱包策略。每个策略的测试也可以围绕自己的输入、依赖调用和异常行为展开,而不是通过一个庞大的服务方法间接验证。
用注册表代替新的分支工厂
策略模式解决了“每种支付方式怎么做”,但还需要解决“根据标识找到哪个策略”。如果再写一个包含大量 if-else 的工厂,分支只是换了位置,并没有真正降低变化成本。
可以在组装阶段建立注册表:
import java.math.BigDecimal;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public final class PaymentService {
private final Map<String, PaymentStrategy> strategies;
public PaymentService(List<PaymentStrategy> strategyList) {
Map<String, PaymentStrategy> index = new HashMap<>();
for (PaymentStrategy strategy : strategyList) {
String method = normalize(strategy.method());
if (index.put(method, strategy) != null) {
throw new IllegalArgumentException(
"重复的支付方式: " + method);
}
}
this.strategies = Map.copyOf(index);
}
public PaymentResult pay(PaymentRequest request) {
validateCommonRequest(request);
String method = normalize(request.method());
PaymentStrategy strategy = strategies.get(method);
if (strategy == null) {
throw new UnsupportedOperationException(
"不支持的支付方式: " + request.method());
}
return strategy.pay(request);
}
private void validateCommonRequest(PaymentRequest request) {
if (request == null || request.amount() == null
|| request.amount().signum() <= 0) {
throw new IllegalArgumentException("支付金额必须大于 0");
}
}
private String normalize(String method) {
if (method == null || method.isBlank()) {
throw new IllegalArgumentException("支付方式不能为空");
}
return method.trim().toUpperCase();
}
}注册表的关键不是 Map,而是依赖方向发生了变化:PaymentService 只依赖 PaymentStrategy,不再直接依赖每个具体支付客户端。新增积分支付时,只需实现 PointPaymentStrategy,并在组装处加入实例,主流程不需要修改。
在 Spring 项目中,可以让各个策略成为组件,再通过构造器注入 List<PaymentStrategy>。不过要注意,自动扫描只是完成对象组装,不能替代业务设计。策略标识必须唯一,重复标识应在启动或构造阶段尽早失败,而不是等到线上请求才发现。
这次重构真正改善了什么
第一,变化被隔离了。支付方式自己的校验、客户端调用和结果转换,集中在自己的策略中。
第二,主流程更稳定。公共校验、策略查找和调用顺序清晰可见,不会随着渠道增加而不断嵌套。
第三,测试边界更自然。可以单独测试银行卡策略是否校验账户、是否正确调用客户端,也可以单独测试注册表是否拒绝重复标识。
第四,依赖更容易替换。策略依赖的是 BankClient、WalletClient 这样的接口,真实 HTTP 客户端、测试替身和故障模拟实现可以分别提供。
常见坑:不是所有 if-else 都值得改
1. 为了使用模式而使用模式
如果系统永远只有两种支付方式,且规则非常简单,引入多个接口、注册表和类文件,可能只会增加阅读成本。重构的依据应该是变化频率、分支复杂度和测试困难程度,而不是看到 if-else 就机械替换。
2. 把所有公共逻辑都塞进接口默认方法
金额校验可以放在服务层,但“银行卡必须绑定某类账户”属于银行卡策略自己的规则。过度抽取公共方法,容易得到一个表面统一、内部充满特殊判断的基类。
3. 用字符串作为到处传递的协议
示例中的 BANK、WALLET 便于展示,但实际项目应统一定义来源和规范。可以使用枚举作为内部模型,或至少将标识集中管理,避免控制器、数据库和策略类各自写一套字符串。
4. 忽略异常和幂等语义
策略只负责选择和调用渠道,不代表支付系统天然可靠。外部调用超时后是否可以重试、重复请求如何识别、渠道成功但本地落库失败怎么办,仍然需要在更高层设计幂等键、状态机和补偿流程。不能因为代码结构变清晰,就误以为业务一致性问题已经解决。
5. 在策略中偷偷承担事务边界
如果一次支付包含本地订单状态更新和外部渠道调用,事务边界应由应用服务根据业务流程决定。不要让每个策略自行开启不同事务,否则调用链会变得难以推断,也容易产生“本地事务回滚但外部支付已经成功”的误解。
实践中的重构顺序
比较稳妥的步骤是:
- 先为原有支付分支补充行为测试,记录成功、参数错误、未知方式和客户端异常等情况。
- 抽取
PaymentStrategy接口,但先保持原有行为不变。 - 一次迁移一个支付分支,避免大规模重写后无法定位差异。
- 引入注册表,并在构造阶段检查空标识和重复标识。
- 最后删除旧分支,观察日志、指标和异常类型是否保持兼容。
重构不是把代码拆成更多类,而是让每个类面对更单一、更稳定的问题。一个好的结果应该让新增支付方式变成“增加一个实现并完成组装”,而不是“修改核心服务,再祈祷所有旧分支都没有被影响”。
总结
策略模式适合处理一组可替换的业务算法,但它的价值来自职责边界,而不是模式名称。面对不断增长的支付方式分支,可以把问题拆成三层:服务负责公共校验和流程,注册表负责根据标识查找策略,具体策略负责自己的业务规则与外部依赖。
同时也要保持克制:分支少、变化小的代码不必强行模式化;分支多并不自动意味着需要继承体系;真正值得重构的信号,是核心方法频繁修改、测试难以隔离、不同规则互相污染,以及新增功能总要触碰稳定代码。
代码质量最终体现在变化发生时的成本。不能保证需求永远不变,但可以让变化尽量只影响它应该影响的地方。