Skip to content

03 Processor Integration Lld

Processor Integration — Low Level Design

Section titled “Processor Integration — Low Level Design”

A payment gateway must integrate with multiple card processors (Visa/Mastercard networks via different acquirers, PayPal, Braintree, etc.) and adapt to different API formats. Three patterns work together: Strategy for swapping processor implementations, Adapter for wrapping legacy or incompatible processor APIs, and Facade for presenting a single simple interface to the transaction engine.


Design the processor integration layer for a payment gateway. This layer must:

  • Route each transaction to the correct card processor (Visa network for Visa cards, Mastercard for Mastercard cards, PayPal for PayPal payments, ACH processor for bank transfers)
  • Adapt legacy or third-party processor APIs that are incompatible with the gateway’s internal interface — without modifying the gateway’s core logic
  • Expose a single, simple entry point to the transaction engine that hides all subsystem complexity (MLE decryption, fraud evaluation, duplicate checking, processor routing, event publishing)

The key design challenges:

  1. Routing variety: different card types route to different processors. Adding a new acquirer partnership must not require changes to the transaction engine.
  2. Legacy incompatibility: some processors have 10-year-old APIs with different method names, parameter types, and response formats. The transaction engine must never know about these differences.
  3. Orchestration complexity: a single processPayment() call requires 9 coordinated steps across 7 subsystems. This complexity must be hidden from the API controller.

Q: How many processors must be supported? Can a single merchant use multiple processors for different card types?

A: 4-6 processors initially (VisaNet, Mastercard, Amex, PayPal, ACH, Legacy Bank). Yes — a merchant can be configured with VisaNet for Visa cards and a regional acquirer for Mastercard. The ProcessorRouter reads the merchant’s processor configuration per card type.

Q: How many concurrent processor calls can the system handle? What is the timeout for a processor call?

A: Each processor has its own bulkhead thread pool (200 threads for VisaNet, 200 for Mastercard — sized for async I/O or ~200 concurrent blocking calls at 15s average timeout). Processor timeout is 15–30 seconds — this is the realistic range used by production payment gateways. Visa/Mastercard authorization typically responds in 1–3 seconds at the network level; a 30-second timeout catches legitimate slow responses without holding threads indefinitely. The gateway-to-merchant timeout is set slightly higher (e.g., 35 seconds) so the gateway always resolves the processor call before the merchant’s HTTP connection times out — preventing the merchant from giving up and retrying while the original processor call is still in flight.

Q: What happens if the processor call succeeds but the response is lost in transit?

A: The PENDING record written before the call identifies this gap. Recovery process queries the processor using the processorTransactionId to determine if the authorization succeeded. This is why the Facade must ensure write-before-call happens before any processor call.

Q: How difficult is it to add a new processor (e.g., a new regional acquirer)?

A: With Strategy Pattern: implement PaymentProcessor interface (4 methods), register in ProcessorRouter. Zero changes to TransactionEngine, FraudEngine, or PaymentGatewayFacade. A new processor is a new file + a configuration change.

Q: Is the ProcessorRouter and PaymentGatewayFacade shared across threads?

A: Both are stateless singletons — safely shared. The LegacyBankGatewayAdapter stores currentReferenceNumber as instance state, which IS a thread-safety issue — it should be request-scoped (see Disadvantages).

Q: What happens when a processor is down? Does it affect all transactions or only that processor’s?

A: Circuit breaker per processor (stored in Redis). When VisaNet’s circuit opens, only VisaNet transactions fail fast. Mastercard, PayPal, and ACH are unaffected (bulkhead isolation). The Facade catches ProcessorUnavailableException and returns an error response — PENDING record already written, so no silent money loss.

Q: A merchant reports their transaction was declined by the processor with an opaque error code. How do we investigate?

A: The processorTransactionId and raw declineCode from the processor are stored on the transaction record. The TransactionInvoker audit log shows which ProcessorStrategy handled the request, the raw response, and timing. Cross-reference with the processor’s own logs using processorTransactionId.

Q: If the PaymentGatewayFacade.processPayment() crashes halfway through, what’s the recovery path?

A: The PENDING write (step 4 in the Facade) ensures a recovery record exists before any external call. If the crash happens after the processor call but before updateResponse(), the PENDING record exists and ops can reconcile it against the processor. The Facade’s 9-step sequence is designed so every failure after step 4 is recoverable.

Q: If the primary processor for a card type is down (circuit open), is there a fallback?

A: Yes — ProcessorRouter supports a fallback routing rule per card type. If VisaNet’s circuit is open, route to the secondary acquirer configured for that merchant. The fallback adds ~50ms overhead (different network path, possibly different fee schedule). Fallback is merchant-configurable: some merchants accept a secondary acquirer with different settlement terms; others prefer to decline during primary outage rather than settle to an unexpected acquirer. The circuit breaker state is checked before routing: CLOSED → primary; OPEN → fallback (if configured) → decline (if no fallback).

Q: Each processor has different decline code schemes. How does the facade expose a consistent code to the merchant?

A: Each processor adapter translates its native codes to a NormalizedDeclineCode enum: INSUFFICIENT_FUNDS, CARD_EXPIRED, CVV_MISMATCH, DO_NOT_HONOR, LOST_STOLEN, INVALID_CARD, GENERIC_DECLINE. The translation lives inside the Strategy implementation — VisaNetProcessor.authorize() maps Visa’s “51” to INSUFFICIENT_FUNDS, Mastercard’s “N7” to CVV_MISMATCH. The PaymentGatewayFacade returns the normalized code to the merchant. The raw processor code is also stored on the transaction record for debugging and processor-specific analysis.

Q: Some issuers return partial approvals (e.g., authorize 80ona80 on a 100 request for a prepaid card). How is this handled?

A: AuthorizationResponse includes an approvedAmount field alongside the requested amount. If approvedAmount < requestedAmount, the facade can: (a) accept the partial authorization and capture approvedAmount — common for prepaid/gift cards where the merchant ships what the card can cover; (b) void the partial authorization and decline — the merchant’s configured preference. The Transaction.amount is set to approvedAmount on the AUTHORIZED state transition, not the original requested amount. Partial authorizations are most common with debit cards, prepaid cards, and restaurant pre-authorizations.

Q: Are there card network rules about when and how often declined transactions can be retried?

A: Yes — Visa and Mastercard publish strict retry rules that carry financial penalties for violations: (1) Hard declines (stolen card, account closed, invalid card number) — must NOT be retried. Retrying these risks EXCESSIVE_RETRY fees (0.100.10–0.25 per violation, thousands of them per day at scale). (2) Soft declines (insufficient funds “51”) — may retry, minimum 1 day between attempts. (3) “Do Not Honor” (05) — minimum 30 days before retry. The ProcessorRouter stores the last decline code per stored credential and enforces minimum retry intervals before allowing a retry. Violations of network retry rules can result in increased per-transaction fees and eventually losing processing rights.

13. PCI DSS Scope in Processor Integration

Section titled “13. PCI DSS Scope in Processor Integration”

Q: Does the processor integration layer fall within PCI DSS scope? What must it never do?

A: Yes — any component that transmits cardholder data is in PCI scope (PCI DSS Requirement 4: encrypt transmission). The processor adapter transmits the PAN to the card network, so it is in scope. Requirements: (1) All transmission uses TLS 1.2+ with certificate validation. (2) The AuthorizationRequest passed to processor.authorize() must contain the PAN only transiently — it must not be logged, cached, or stored. (3) The processorTransactionId returned in AuthorizationResponse is safe to log; it’s the processor’s own reference, not cardholder data. (4) With network tokenization (Visa VTS / Mastercard MDES), the actual PAN is replaced with a network token before it reaches the processor adapter — this reduces PCI scope for the adapter significantly.


Section 1: Strategy Pattern — Processor Routing

Section titled “Section 1: Strategy Pattern — Processor Routing”

plantuml

PaymentProcessor.java
public interface PaymentProcessor {
AuthorizationResponse authorize(AuthorizationRequest request);
CaptureResponse capture(CaptureRequest request);
VoidResponse void_(VoidRequest request);
RefundResponse refund(RefundRequest request);
ProcessorType getProcessorType();
}
AuthorizationRequest.java
public class AuthorizationRequest {
private final String transactionId;
private final BigDecimal amount;
private final String currency;
private final PaymentMethod paymentMethod;
private final BillingAddress billingAddress;
private final String merchantId;
private final String customerIp;
// constructor + getters
}
AuthorizationResponse.java
public class AuthorizationResponse {
private final boolean approved;
private final String authCode;
private final String processorTransactionId;
private final String declineCode;
private final String avsResult;
private final String cvvResult;
// constructor + getters
}
VisaNetProcessor.java
6 collapsed lines
public class VisaNetProcessor implements PaymentProcessor {
private final VisaNetClient visaNetClient;
private final MessageFormatter formatter;
public VisaNetProcessor(VisaNetClient visaNetClient, MessageFormatter formatter) {
this.visaNetClient = visaNetClient;
this.formatter = formatter;
}
@Override
public AuthorizationResponse authorize(AuthorizationRequest request) {
// Build ISO 8583 message for Visa network
Iso8583Message message = formatter.buildAuthorizationMessage(request);
Iso8583Response response = visaNetClient.send(message);
return formatter.parseAuthorizationResponse(response);
}
@Override
public CaptureResponse capture(CaptureRequest request) {
Iso8583Message message = formatter.buildCaptureMessage(request);
Iso8583Response response = visaNetClient.send(message);
return formatter.parseCaptureResponse(response);
}
@Override
public ProcessorType getProcessorType() { return ProcessorType.VISA_NET; }
// void_ and refund implementations omitted for brevity
}
ProcessorRouter.java
public class ProcessorRouter {
private final Map<ProcessorType, PaymentProcessor> processors;
private final BinLookupService binLookupService;
public ProcessorRouter(Map<ProcessorType, PaymentProcessor> processors,
BinLookupService binLookupService) {
this.processors = processors;
this.binLookupService = binLookupService;
}
public PaymentProcessor route(Transaction transaction) {
ProcessorType type = determineProcessorType(transaction);
PaymentProcessor processor = processors.get(type);
if (processor == null) {
throw new UnsupportedProcessorException("No processor registered for " + type);
}
return processor;
}
private ProcessorType determineProcessorType(Transaction transaction) {
String bin = transaction.getPaymentMethod().getCardNumber().substring(0, 8);
CardNetwork network = binLookupService.lookupNetwork(bin);
return switch (network) {
case VISA -> ProcessorType.VISA_NET;
case MASTERCARD -> ProcessorType.MASTERCARD;
case AMEX -> ProcessorType.AMEX;
case PAYPAL -> ProcessorType.PAYPAL;
default -> throw new UnsupportedCardNetworkException(network.name());
};
}
}
  • Isolated test surface: each processor can be unit tested with a mock without touching others
  • Zero-downtime processor swap: route 0% to old processor, 100% to new without code changes
  • Independent deployment: VisaNetProcessor changes deploy without touching MastercardProcessor
  • Response code normalization: each processor has unique decline codes; the router must normalize them into a common DeclineCode enum — ongoing maintenance as processors change their codes
  • Configuration complexity: routing rules must cover all card BIN ranges, edge cases, and fallbacks
AlternativeWhy it fails for processor routing
if-else by card type in TransactionEngineTransaction engine knows about every processor. Adding a new processor means modifying the engine. Testing the engine requires mocking all processors.
Abstract base class per processorInheritance creates hidden dependencies. VisaNetProcessor and MastercardProcessor share no actual implementation — there’s nothing to inherit. Prefer composition.
Service locator / registry lookupWorks but is implicit — the routing logic is hidden in a registry lookup instead of in explicit, readable routing code. Harder to trace and test.
StrategyProcessorRouter selects the correct PaymentProcessor at runtime. Transaction engine calls processor.authorize(request) — completely decoupled from which network handles it. New processor = new class + routing rule.

Section 2: Adapter Pattern — Legacy Processor Integration

Section titled “Section 2: Adapter Pattern — Legacy Processor Integration”

Problem: the gateway uses PaymentProcessor interface everywhere. But LegacyBankGateway has a completely different API — different method names, different request format, different response format:

LegacyBankGateway.java
public class LegacyBankGateway {
private long lastReferenceNumber;
public void submitTransaction(double totalAmount, String currencyCode, String cardData) {
System.out.println("LegacyGateway: submitting " + currencyCode + " " + totalAmount);
this.lastReferenceNumber = System.currentTimeMillis(); // simulated reference
}
public boolean verifyTransactionStatus(long referenceNumber) {
System.out.println("LegacyGateway: checking status for ref " + referenceNumber);
return true; // simulated approval
}
public long getLastReferenceNumber() {
return lastReferenceNumber;
}
public void reverseTransaction(long referenceNumber) {
System.out.println("LegacyGateway: reversing ref " + referenceNumber);
}
}

plantuml

LegacyBankGatewayAdapter.java
public class LegacyBankGatewayAdapter implements PaymentProcessor {
private final LegacyBankGateway legacyGateway; // holds instance of adaptee
private long currentReferenceNumber;
public LegacyBankGatewayAdapter(LegacyBankGateway legacyGateway) {
this.legacyGateway = legacyGateway;
}
@Override
public AuthorizationResponse authorize(AuthorizationRequest request) {
// Translate: BigDecimal → double, PaymentMethod → cardData string
String cardData = formatCardData(request.getPaymentMethod());
double amount = request.getAmount().doubleValue();
legacyGateway.submitTransaction(amount, request.getCurrency(), cardData);
currentReferenceNumber = legacyGateway.getLastReferenceNumber();
boolean approved = legacyGateway.verifyTransactionStatus(currentReferenceNumber);
return AuthorizationResponse.builder()
.approved(approved)
.processorTransactionId("LEGACY_" + currentReferenceNumber)
.authCode(approved ? String.valueOf(currentReferenceNumber) : null)
.declineCode(approved ? null : "LEGACY_DECLINED")
.build();
}
@Override
public VoidResponse void_(VoidRequest request) {
long ref = Long.parseLong(request.getProcessorTransactionId().replace("LEGACY_", ""));
legacyGateway.reverseTransaction(ref);
return new VoidResponse(true, "LEGACY_" + ref + "_REVERSED");
}
@Override
public CaptureResponse capture(CaptureRequest request) {
// Legacy gateway uses auto-capture — capture is a no-op
return new CaptureResponse(true, request.getTransactionId(), request.getAmount());
}
@Override
public RefundResponse refund(RefundRequest request) {
long ref = Long.parseLong(request.getProcessorTransactionId().replace("LEGACY_", ""));
legacyGateway.reverseTransaction(ref);
return new RefundResponse(true, "LEGACY_REFUND_" + ref);
}
@Override
public ProcessorType getProcessorType() { return ProcessorType.LEGACY_BANK; }
private String formatCardData(PaymentMethod pm) {
return pm.getCardNumber() + "|" + pm.getExpiryMonth() + "/" + pm.getExpiryYear();
}
}

Usage — the transaction engine never knows it’s talking to a legacy gateway:

// No changes to TransactionEngine — it uses PaymentProcessor interface
PaymentProcessor legacyProcessor = new LegacyBankGatewayAdapter(new LegacyBankGateway());
processorRouter.register(ProcessorType.LEGACY_BANK, legacyProcessor);
// ProcessorRouter selects it when a merchant is configured to use LEGACY_BANK
  • Zero changes to existing code: TransactionEngine and ProcessorRouter are unchanged
  • Incremental migration: run legacy and new processors in parallel; migrate merchants one at a time
  • Clean isolation: all the ugly translation code is in one class — easy to find and update
  • State leakage risk: currentReferenceNumber is instance state — adapter must be request-scoped or thread-safe
  • Partial translation: if the legacy API doesn’t support partial refunds, the adapter must simulate it or throw UnsupportedOperationException
AlternativeWhy it fails for legacy processor integration
Modify LegacyBankGateway directlyCannot — it’s a third-party library or a system owned by another team. Modifying it creates a maintenance fork.
Add if (isLegacy) branches in ProcessorRouterRouter becomes coupled to legacy API specifics. Every legacy quirk pollutes the core routing logic.
Rewrite the legacy processor from scratchToo expensive. The adapter wraps the existing battle-tested implementation.
AdapterLegacyBankGatewayAdapter is the only place that knows about LegacyBankGateway. All translation in one class. ProcessorRouter uses it via PaymentProcessor interface — zero knowledge of legacy internals.

Section 3: Facade Pattern — Payment Processing API

Section titled “Section 3: Facade Pattern — Payment Processing API”

Without a Facade, the API controller would need to:

  1. Decrypt the MLE-encrypted card data → calls MleDecryptionService
  2. Look up merchant config → calls MerchantConfigService
  3. Write PENDING transaction → calls TransactionRepository
  4. Check for duplicates → calls DuplicateDetectionService
  5. Run fraud evaluation → calls FraudPipeline
  6. Route to processor → calls ProcessorRouter
  7. Call the processor → calls PaymentProcessor
  8. Update transaction with result → calls TransactionRepository
  9. Publish events → calls FraudEventPublisher

This couples the controller to 7+ subsystems.

plantuml

PaymentGatewayFacade.java
public class PaymentGatewayFacade {
private final MleDecryptionService mleDecryptionService;
private final MerchantConfigService merchantConfigService;
private final DuplicateDetectionService duplicateDetector;
private final FraudPipeline fraudPipeline;
private final ProcessorRouter processorRouter;
private final TransactionRepository transactionRepository; // write-before-call
private final FraudEventPublisher eventPublisher;
// Constructor injection (Spring @Autowired or manual)
public PaymentGatewayFacade(MleDecryptionService mle, MerchantConfigService config,
DuplicateDetectionService dedup, FraudPipeline fraud,
ProcessorRouter router, TransactionRepository repo,
FraudEventPublisher publisher) {
this.mleDecryptionService = mle;
this.merchantConfigService = config;
this.duplicateDetector = dedup;
this.fraudPipeline = fraud;
this.processorRouter = router;
this.transactionRepository = repo;
this.eventPublisher = publisher;
}
public PaymentResult processPayment(PaymentRequest request) {
// Step 1: Decrypt MLE-encrypted card data
PaymentMethod decryptedMethod = mleDecryptionService.decrypt(request.getEncryptedPayload());
// Step 2: Load merchant config (from cache)
MerchantConfig config = merchantConfigService.load(request.getMerchantId());
// Step 3: Idempotency / duplicate check
Optional<Transaction> existing = duplicateDetector.findDuplicate(request);
if (existing.isPresent()) {
return PaymentResult.fromExisting(existing.get());
}
// Step 4: Write PENDING record (write-before-call)
Transaction transaction = transactionRepository.insertPending(request, config);
// Step 5: Fraud evaluation
FraudContext fraudContext = new FraudContext(transaction, config, request.getCustomerIp());
FilterResult fraudResult = fraudPipeline.evaluate(fraudContext);
if (fraudResult.getAction() == FraudAction.DECLINE) {
transactionRepository.updateDeclined(transaction.getId(), fraudResult.getReason());
return PaymentResult.declined(transaction.getId(), fraudResult.getReason());
}
// Step 6: Route to processor and authorize
PaymentProcessor processor = processorRouter.route(transaction);
AuthorizationRequest authRequest = buildAuthRequest(transaction, decryptedMethod);
AuthorizationResponse authResponse = processor.authorize(authRequest);
// Step 7: Update transaction record with result
transactionRepository.updateResponse(transaction.getId(), authResponse);
// Step 8: Publish fraud event if held
if (fraudResult.getAction() == FraudAction.AUTH_AND_HOLD) {
eventPublisher.publish(new FraudEvent(FraudEventType.TRANSACTION_HELD,
transaction.getId(), request.getMerchantId(), fraudResult.getTriggeredBy(),
FraudAction.AUTH_AND_HOLD, Instant.now()));
}
return PaymentResult.from(transaction, authResponse);
}
}
PaymentApiController.java
4 collapsed lines
@RestController
public class PaymentApiController {
private final PaymentGatewayFacade gateway;
public PaymentApiController(PaymentGatewayFacade gateway) {
this.gateway = gateway;
}
@PostMapping("/v1/transactions")
public ResponseEntity<PaymentResult> createTransaction(@RequestBody PaymentRequest request) {
PaymentResult result = gateway.processPayment(request);
int status = result.isApproved() ? 201 : 402;
return ResponseEntity.status(status).body(result);
}
}
  • Simple controller: the API controller has ONE dependency and ONE method call — easy to read, test, and maintain
  • Centralized orchestration: the 9-step sequence is in one place — when a step changes, one file changes
  • Testable facade: the facade can be tested by mocking its 7 dependencies independently
  • God object risk: the facade can grow to handle every edge case — resist adding business logic into it; it should only orchestrate
  • Hidden complexity: subsystems CAN still be used directly; the facade doesn’t prevent bypassing it
AlternativeWhy it fails for payment orchestration
Put all 9 steps in the API controllerController handles HTTP AND payment logic. 500-line controller. Cannot reuse the logic in the recurring billing engine or test it without HTTP context.
Service mesh / orchestration engineOverkill for a deterministic 9-step sequence. Adds infrastructure dependency. The sequence never changes at runtime.
Direct coupling between each serviceFraudEngine calls ProcessorRouter which calls TransactionRepository. Circular dependencies, impossible to test in isolation.
FacadeSingle PaymentGatewayFacade.processPayment(). Controller has ONE dependency. Recurring billing engine calls the same facade. Unit test the facade by mocking 7 dependencies. Subsystems stay decoupled from each other.

Final wiring example showing how all three patterns compose:

// Application startup wiring
LegacyBankGateway legacyGateway = new LegacyBankGateway();
LegacyBankGatewayAdapter legacyAdapter = new LegacyBankGatewayAdapter(legacyGateway); // Adapter
Map<ProcessorType, PaymentProcessor> processors = Map.of( // Strategy
ProcessorType.VISA_NET, new VisaNetProcessor(visaNetClient, formatter),
ProcessorType.MASTERCARD, new MastercardProcessor(mcClient, formatter),
ProcessorType.LEGACY_BANK, legacyAdapter // Adapter plugs into Strategy
);
ProcessorRouter router = new ProcessorRouter(processors, binLookupService);
PaymentGatewayFacade facade = new PaymentGatewayFacade( // Facade
mleService, merchantConfigService, duplicateDetector,
fraudPipeline, router, transactionRepository, eventPublisher
);

← Payment Gateway LLD