PAYATHON 2026

通用 DAO 与拆分小接口,哪种设计更合理?

支付老李

结论

不建议把所有操作都塞进一个通用 Dao,再由实现类对不支持的方法抛出 UnsupportedOperationException。

这样的接口看起来统一,却无法保证调用方能安全使用其中的每个方法。它违反了接口隔离原则,也削弱了里氏替换原则。更合理的设计是:

  1. 按能力拆分小接口,不强求统一的继承层次。
  2. 每个支付代理只实现自己支持的能力。
  3. 业务服务通过组合依赖所需接口。
  4. 协议独有的操作放在专用接口中。
  5. 用适配器处理不同支付代理之间的协议差异。

接口中有少量重复声明通常无须担心。相比几行重复的方法签名,错误的抽象更难维护。

通用接口的问题

假设一段业务代码接收 Dao:

void cancelPayment(Dao dao, Payment payment) {
    dao.removePayment(payment);
}

按照接口定义,所有 Dao 都支持 removePayment。实际调用时,部分实现却会抛出 UnsupportedOperationException。接口的承诺因此变得不可靠,调用方还必须知道传入的是哪个具体实现:

if (dao instanceof AnotherAgentDao) {
    // 不能调用 removePayment
}

代码一旦需要这种判断,多态也就失去了大部分价值。

UnsupportedOperationException 并非完全不能使用。例如,Java 集合中的不可修改视图可能抛出这个异常,但相关行为有明确的 API 契约和适用场景。对于支付系统中的业务接口,某项能力通常是代理协议的稳定特征,应该直接反映在类型系统中,而不是等到运行时才发现它不受支持。

按能力拆分接口

不必强行寻找一个所有代理都支持的“根业务接口”。Java 接口可以相互独立,一个实现类也可以同时实现多个接口。

public interface PaymentCreator {
    void addPayment(Payment payment);
}

public interface PaymentRemover {
    void removePayment(Payment payment);
}

public interface PaymentStatusReader {
    PaymentStatus getPaymentStatus(PaymentId paymentId);
}

public interface ProtocolVersionReader {
    String getVersion();
}

每个代理按照实际能力选择要实现的接口:

public final class PaymentAgentAdapter
        implements PaymentCreator, PaymentRemover {

    @Override
    public void addPayment(Payment payment) {
        // 调用 PaymentAgent 协议
    }

    @Override
    public void removePayment(Payment payment) {
        // 调用 PaymentAgent 协议
    }
}
public final class AnotherAgentAdapter
        implements PaymentCreator, PaymentStatusReader, ProtocolVersionReader {

    @Override
    public void addPayment(Payment payment) {
        // 调用 AnotherAgent 协议
    }

    @Override
    public PaymentStatus getPaymentStatus(PaymentId paymentId) {
        // 查询支付状态
        return PaymentStatus.PENDING;
    }

    @Override
    public String getVersion() {
        // 查询协议版本
        return "some-version";
    }
}

需要删除能力的业务服务只接收 PaymentRemover:

public final class PaymentCancellationService {

    private final PaymentRemover paymentRemover;

    public PaymentCancellationService(PaymentRemover paymentRemover) {
        this.paymentRemover = paymentRemover;
    }

    public void cancel(Payment payment) {
        paymentRemover.removePayment(payment);
    }
}

不支持删除操作的代理无法传给这个服务。问题会在编译期暴露,而不是等到线上运行时才以 UnsupportedOperationException 的形式出现。

为常见能力组合定义聚合接口

如果几项能力经常一起使用,可以额外定义组合接口,但不必要求所有实现都继承一个总接口。

public interface MutablePaymentGateway
        extends PaymentCreator, PaymentRemover {
}

具备完整增删能力的代理可以实现该接口:

public final class FullPaymentAgentAdapter
        implements MutablePaymentGateway, PaymentStatusReader {

    @Override
    public void addPayment(Payment payment) {
        // ...
    }

    @Override
    public void removePayment(Payment payment) {
        // ...
    }

    @Override
    public PaymentStatus getPaymentStatus(PaymentId paymentId) {
        // ...
        return PaymentStatus.COMPLETED;
    }
}

这种聚合接口只是给常用的能力组合起一个名字,并不要求所有接口组成一棵统一而完整的继承树。

协议特有操作单独建模

如果某个操作只属于一种协议,就不该为了形式上的统一把它放进公共接口。

public interface AgentSpecificSettlement {
    SettlementResult settleWithAgentToken(
            PaymentId paymentId,
            String agentToken
    );
}

只有对应的适配器实现该接口:

public final class SpecialAgentAdapter
        implements PaymentCreator, AgentSpecificSettlement {

    @Override
    public void addPayment(Payment payment) {
        // ...
    }

    @Override
    public SettlementResult settleWithAgentToken(
            PaymentId paymentId,
            String agentToken
    ) {
        // 调用该代理独有的结算接口
        return new SettlementResult();
    }
}

如果某个业务流程确实需要这项协议能力,就让它明确依赖 AgentSpecificSettlement。不要把供应商差异藏进一个看似通用、实则到处都有例外的接口。

用组合表达一项业务所需的多种能力

业务服务有时同时需要创建支付和查询支付状态,此时可以分别注入两个接口:

public final class PaymentProcessingService {

    private final PaymentCreator paymentCreator;
    private final PaymentStatusReader statusReader;

    public PaymentProcessingService(
            PaymentCreator paymentCreator,
            PaymentStatusReader statusReader
    ) {
        this.paymentCreator = paymentCreator;
        this.statusReader = statusReader;
    }

    public void process(Payment payment) {
        paymentCreator.addPayment(payment);

        PaymentStatus status =
                statusReader.getPaymentStatus(payment.getId());

        // 根据状态继续处理
    }
}

同一个适配器对象可以同时作为这两个依赖传入:

AnotherAgentAdapter adapter = new AnotherAgentAdapter();

PaymentProcessingService service =
        new PaymentProcessingService(adapter, adapter);

如果应用使用依赖注入框架,也可以把同一个实现分别注册到多个接口下。

不要把所有外部支付操作都叫 DAO

DAO 通常用来屏蔽数据库或持久化细节,例如保存、查询和删除本地实体。外部支付代理属于远程系统,用 Gateway、Client、Connector 或 Adapter 往往更准确。

可以把本地持久化和外部代理职责分开:

public interface PaymentRepository {
    void save(Payment payment);

    Optional<Payment> findById(PaymentId paymentId);
}
public interface PaymentGateway {
    // 这里只放确实属于统一支付用例的操作;
    // 如果没有共同操作,也可以不定义该接口。
}

整体结构可以是:

PaymentProcessingService
 ├── PaymentRepository       // 本地计费数据库
 ├── PaymentCreator          // 外部代理能力
 └── PaymentStatusReader     // 外部代理能力

代理适配器处理协议转换,PaymentRepository 负责本地持久化,业务服务负责流程编排。这样可以避免把远程协议、数据库访问和业务规则混进同一个“万能 DAO”。

如何处理重复代码

拆分接口通常不会产生值得担心的代码重复。即使多个接口中出现了相似的方法,它们也可能只是名称相同,语义并不一致。抽象之前,应先确认这些方法的输入、输出、约束和失败语义是否相同。

如果重复出现在实现代码中,可以复用实现,而不必扩大接口:

public final class PaymentRequestMapper {

    public AgentRequest toAgentRequest(Payment payment) {
        // 公共转换逻辑
        return new AgentRequest();
    }
}

不同的适配器可以组合这个组件:

public final class FirstAgentAdapter implements PaymentCreator {

    private final PaymentRequestMapper mapper;

    public FirstAgentAdapter(PaymentRequestMapper mapper) {
        this.mapper = mapper;
    }

    @Override
    public void addPayment(Payment payment) {
        AgentRequest request = mapper.toAgentRequest(payment);
        // 发送请求
    }
}

序列化、签名、重试、日志和错误映射等逻辑也可以用同样的方式复用。除非多个实现之间确实存在稳定的“是一个”关系,否则应优先使用组合,不要只为消除几行重复代码就建立复杂的抽象基类。

能力必须在运行时动态选择时

如果代理能力取决于配置、账号权限或协议版本,纯静态接口可能无法描述所有情况。此时可以提供明确的能力查询接口:

public enum PaymentCapability {
    CREATE,
    REMOVE,
    READ_STATUS,
    READ_VERSION
}
public interface CapabilityProvider {
    Set<PaymentCapability> capabilities();
}

调用方先检查能力,再选择相应的业务路径。但能力查询不能用来掩盖万能接口的设计问题。稳定的协议差异仍应通过小接口表达,只有真正会在运行时变化的能力才适合动态查询。

也可以在注册阶段保存带有类型约束的适配器:

public record PaymentAgentComponents(
        PaymentCreator creator,
        Optional<PaymentRemover> remover,
        Optional<PaymentStatusReader> statusReader
) {
}

这样一来,可选能力在组装阶段就能得到明确表示:

components.remover().ifPresent(
        remover -> remover.removePayment(payment)
);

对于关键业务操作,不建议在缺少能力时直接跳过。更稳妥的处理方式是在流程开始前验证所需能力,并返回清晰的领域错误。

异常设计

“代理不支持某项能力”和“代理调用失败”是两类不同的问题:

  • 不支持能力:最好通过类型约束或在应用组装阶段发现。
  • 网络超时、签名失败、远端拒绝:属于调用过程中的失败,应映射成明确的领域异常。
  • 支付不存在、当前状态不允许操作:属于业务结果,应由领域模型表达。

例如:

public class PaymentGatewayException extends RuntimeException {

    public PaymentGatewayException(String message, Throwable cause) {
        super(message, cause);
    }
}

不要用 UnsupportedOperationException 同时表达协议限制、配置错误和远程调用失败,否则调用方很难判断应该采取哪种补偿措施。

推荐落地步骤

  1. 列出各代理支持的操作,整理成“代理 × 能力”矩阵。
  2. 按业务语义划分 PaymentCreator、PaymentRemover、PaymentStatusReader 等小接口。
  3. 让每个代理适配器只实现它真正支持的接口。
  4. 让业务服务依赖完成当前用例所需的最小接口集合。
  5. 把协议独有的操作放进专用接口。
  6. 将本地数据库访问单独抽象为 PaymentRepository。
  7. 通过组合组件复用公共实现,不要为了消除少量重复而建立错误的继承关系。
  8. 在应用启动或代理注册阶段验证必需能力,尽早发现配置问题。

判断标准并不复杂:接口中的每个方法,都应该是所有实现类能够正常履行的承诺。如果部分实现只能通过抛出 UnsupportedOperationException 来满足接口定义,通常说明这个接口已经过大,需要拆分。

备注:内容仅供参考。