通用 DAO 与拆分小接口,哪种设计更合理?
结论
不建议把所有操作都塞进一个通用 Dao,再由实现类对不支持的方法抛出 UnsupportedOperationException。
这样的接口看起来统一,却无法保证调用方能安全使用其中的每个方法。它违反了接口隔离原则,也削弱了里氏替换原则。更合理的设计是:
- 按能力拆分小接口,不强求统一的继承层次。
- 每个支付代理只实现自己支持的能力。
- 业务服务通过组合依赖所需接口。
- 协议独有的操作放在专用接口中。
- 用适配器处理不同支付代理之间的协议差异。
接口中有少量重复声明通常无须担心。相比几行重复的方法签名,错误的抽象更难维护。
通用接口的问题
假设一段业务代码接收 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 同时表达协议限制、配置错误和远程调用失败,否则调用方很难判断应该采取哪种补偿措施。
推荐落地步骤
- 列出各代理支持的操作,整理成“代理 × 能力”矩阵。
- 按业务语义划分
PaymentCreator、PaymentRemover、PaymentStatusReader等小接口。 - 让每个代理适配器只实现它真正支持的接口。
- 让业务服务依赖完成当前用例所需的最小接口集合。
- 把协议独有的操作放进专用接口。
- 将本地数据库访问单独抽象为
PaymentRepository。 - 通过组合组件复用公共实现,不要为了消除少量重复而建立错误的继承关系。
- 在应用启动或代理注册阶段验证必需能力,尽早发现配置问题。
判断标准并不复杂:接口中的每个方法,都应该是所有实现类能够正常履行的承诺。如果部分实现只能通过抛出 UnsupportedOperationException 来满足接口定义,通常说明这个接口已经过大,需要拆分。
备注:内容仅供参考。
