A payment application supports Card, UPI, and Wallet payments. How would you design it so new payment methods can be added easily?
Interview preparation resource from Gate Smashers.
Design the payment module around a common PaymentProvider interface and a PaymentService that delegates to the appropriate provider through a registry or factory. Card, UPI, and Wallet are separate implementations of the same contract. To add a new method, implement the interface, register it under a method key, and provide any adapter needed for an external gateway SDK. The orchestration code remains unchanged, following the Open/Closed Principle.
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
public final class PaymentRequest {
public final String idempotencyKey;
public final String method;
public final long amount;
public PaymentRequest(String idempotencyKey, String method, long amount) {
this.idempotencyKey = idempotencyKey;
this.method = method;
this.amount = amount;
}
}
public final class PaymentResponse {
public final boolean success;
public final String providerReference;
public final String errorCode;
public PaymentResponse(boolean success, String providerReference, String errorCode) {
this.success = success;
this.providerReference = providerReference;
this.errorCode = errorCode;
}
}
public interface PaymentProvider {
String key();
PaymentResponse process(PaymentRequest request);
}
public final class ProviderRegistry {
private final Map<String, PaymentProvider> providers = new ConcurrentHashMap<>();
public void register(PaymentProvider provider) {
providers.put(normalize(provider.key()), provider);
}
public Optional<PaymentProvider> lookup(String key) {
return Optional.ofNullable(providers.get(normalize(key)));
}
private String normalize(String key) {
return key.toUpperCase(Locale.ROOT);
}
}
public final class PaymentService {
private final ProviderRegistry registry;
public PaymentService(ProviderRegistry registry) {
this.registry = registry;
}
public PaymentResponse pay(PaymentRequest request) {
if (request == null || request.method == null || request.amount <= 0) {
return new PaymentResponse(false, null, "invalid_request");
}
return registry.lookup(request.method)
.map(provider -> provider.process(request))
.orElseGet(() -> new PaymentResponse(false, null, "unsupported_method"));
}
}
public final class CardProvider implements PaymentProvider {
@Override
public String key() {
return "CARD";
}
@Override
public PaymentResponse process(PaymentRequest request) {
// Call a card gateway through an adapter and map its result.
return new PaymentResponse(true, "gateway-reference", null);
}
}
// Application wiring
ProviderRegistry registry = new ProviderRegistry();
registry.register(new CardProvider());
registry.register(new UpiProvider());
registry.register(new WalletProvider());
PaymentService service = new PaymentService(registry);
PaymentResponse response = service.pay(
new PaymentRequest("idem-1", "CARD", 1000)
);Core design
Use the Strategy pattern to represent each payment method behind a common interface. PaymentService should contain shared workflow concerns, such as request validation, provider selection, idempotency handling, and normalized error handling, while individual providers contain method-specific gateway logic.
A registry or factory maps a payment method key, such as CARD, UPI, or WALLET, to its PaymentProvider implementation. This prevents PaymentService from depending directly on concrete provider classes.
- PaymentProvider: Defines the common operation for processing a payment.
- PaymentService: Coordinates the payment flow and delegates to the selected provider.
- ProviderRegistry: Stores and resolves providers by payment-method key.
- Adapter: Wraps a third-party payment SDK and translates between application models and SDK-specific models.
Adding a payment method
A new payment method should be introduced without modifying PaymentService or existing providers. Create a new PaymentProvider implementation, adapt its external gateway if necessary, and register it with the registry during application configuration or dependency-injection setup.
- Implement: Create a provider that implements the common PaymentProvider contract.
- Adapt: Keep gateway-specific request conversion, response conversion, and error translation inside the provider or an adapter.
- Register: Associate the provider with a unique payment-method key so it can be resolved at runtime.
- Verify: Test that the provider follows the common contract, including success, failure, timeout, and idempotency behavior where supported.
Operational considerations
Keep provider-specific details out of the public response model so callers receive a stable application-level result. Store credentials securely, avoid logging sensitive payment data, and add provider-level monitoring.
Timeouts and carefully controlled retries are important for external gateways. Retries must be compatible with idempotency so that a transient failure does not unintentionally create duplicate charges.
- Security: Protect credentials and avoid exposing sensitive payment information in logs or errors.
- Resilience: Apply timeouts and use idempotency-aware retry behavior for gateway calls.
- Observability: Record provider-level latency, outcomes, and structured diagnostic information.
