Start now →

6 Design Patterns That Made My Payment Service Actually Maintainable

By Pasindu Jayasinghe · Published April 13, 2026 · 11 min read · Source: Fintech Tag
RegulationPayments
6 Design Patterns That Made My Payment Service Actually Maintainable

6 Design Patterns That Made My Payment Service Actually Maintainable

Pasindu JayasinghePasindu Jayasinghe9 min read·Just now

--

Press enter or click to view image in full size

A deep dive into the real-world codebase of a fintech transfer module — and the 6 patterns that keep it from turning into spaghetti.

When I started building the transfer module of our payment service — handling internal bank transfers, third-party transfers, IPS payments, scheduled transfers, and recurring payments — I had a choice. I could write it fast, cramming logic into big service classes. Or I could write it right, even though it would take longer.

I chose right. After months of production traffic, I’m glad I did.

In this article I’ll walk you through the 6 design patterns I used — not the textbook version, but the actual code running in production. By the end, you’ll understand not just what each pattern is, but exactly why it’s there and how to apply the same thinking to your own service.

All code examples are from a real Spring Boot microservice handling bank-grade fund transfers for a fintech platform.

The 6 Patterns at a Glance

1. Strategy — Swap transfer routing without if/else chains

2. Factory — Pick the right Strategy automatically at runtime

3. Chain of Responsibility — Pluggable, ordered validation steps

4. Template Method — Fixed pipeline, customisable steps

5. Facade — Hide processor complexity from callers

6. Mapper — Clean entity-to-DTO conversion

Pattern #1 — Strategy Pattern

The Problem

Our payment service handles three kinds of fund transfers:

The naive approach is a single method with a massive if-else block. That breaks the moment you add a fourth transfer type — which will definitely happen in fintech.

The Pattern

Strategy defines a family of algorithms, encapsulates each one, and makes them interchangeable. The caller doesn’t know which algorithm runs — it just calls execute().

The Interface

public interface TransferStrategy {
BankTransferResponse execute(TransferRequest request);
boolean supports(TransferType type);
}

The supports() method is the secret ingredient. Each strategy declares what it handles:

// InternalTransferStrategy
@Override
public boolean supports(TransferType type) {
return type == TransferType.FT_CBM_OWN
|| type == TransferType.FT_CBM_THIRD_PARTY;
}
// ExternalTransferStrategy
@Override
public boolean supports(TransferType type) {
return type == TransferType.FT_CBM_OTHER;
}

How to Use It in Your Own Code

Open/Closed Principle in action: the system is open for extension (new TransferStrategy) but closed for modification (no existing code changes).

Pattern #2 — Factory Pattern

The Problem

Now that we have multiple strategies, something needs to pick the right one. Without a factory, every caller would need its own selection logic — duplicated everywhere and error-prone.

The Pattern

The Factory encapsulates object creation. Here it goes one step further: because Spring injects all TransferStrategy beans into a list, the factory doesn't hard-code anything. It filters at runtime.

@Component
@RequiredArgsConstructor
public class TransferStrategyFactory {
private final List<TransferStrategy> strategies;
public TransferStrategy getStrategy(TransferType type) {
return strategies.stream()
.filter(s -> s.supports(type))
.findFirst()
.orElseThrow(() ->
new IllegalArgumentException("No strategy for: " + type));
}
}

Why This Is Powerful

Spring automatically discovers every class that implements TransferStrategy and is annotated with @Service or @Component. The factory doesn't need to know they exist. You add a new strategy, Spring wires it in, the factory finds it. No factory code changes. Ever.

How to Use It in Your Own Code

Pattern #3 — Chain of Responsibility

The Problem

Before any transfer executes, we validate three things in a strict order:

  1. Currency rules — is this currency pair allowed?
  2. Limit rules — does the amount respect min/max and daily caps?
  3. Balance rules — does the account have enough funds, including the fee?

Balance depends on the fee. The fee depends on the limit rule type. So order matters. But we don’t want a single god-method that does all three in one place.

The Pattern

Chain of Responsibility links handlers in a chain. Each handler does its job, then passes the request to the next — unless it decides to stop by throwing an exception.

The Abstract Base

public abstract class AbstractValidationHandler implements ValidationHandler {
protected ValidationHandler next;
@Override
public ValidationHandler setNext(ValidationHandler next) {
this.next = next;
return next; // enables fluent chaining
}
protected void validateNext(String userId, TransferRequest request) {
if (next != null) {
next.validate(userId, request);
}
}
public abstract void validate(String userId, TransferRequest request);
}

Assembling the Chain

@Component
public class ValidationChain {
private final CurrencyValidationHandler currencyValidator;
private final LimitValidationHandler limitValidator;
private final BalanceValidationHandler balanceValidator;
public ValidationHandler getChain() {
currencyValidator
.setNext(limitValidator)
.setNext(balanceValidator);
return currencyValidator;
}
}

To run validation, the processor simply calls:

ValidationHandler chain = validationChain.getChain();
chain.validate(userId, request);

How to Use It in Your Own Code

Pattern #4 — Template Method Pattern

The Problem

Every transfer — immediate or scheduled — goes through the same lifecycle:

  1. Validate the request
  2. Pre-process (generate ID, apply exchange rates, calculate commission)
  3. Execute (hit the bank API)
  4. Post-process (save the transaction, update status)
  5. Send notifications (SMS, push, email)
  6. Build and return the response

The skeleton is identical. What differs is what each step does. Template Method is made for exactly this.

The Pattern

Template Method defines the algorithm skeleton in a base class as a final method. Abstract methods are the "hooks" that subclasses must implement. The skeleton itself cannot be overridden.

public abstract class AbstractTransferProcessor {
// Final: the skeleton cannot be changed by subclasses
public final TransferResponse process(String userId, TransferRequest request) {
validate(userId, request);
preProcess(request);
BankTransferResponse bankResponse = execute(request);
Transaction txn = postProcess(userId, request, bankResponse);
sendNotifications(userId, txn, bankResponse);
return buildResponse(txn, bankResponse);
}
// Mandatory hooks
protected abstract void validate(String userId, TransferRequest request);
protected abstract void preProcess(TransferRequest request);
protected abstract BankTransferResponse execute(TransferRequest request);
protected abstract Transaction postProcess(
String userId, TransferRequest request, BankTransferResponse bankResponse);
// Optional hook with a default implementation
protected void sendNotifications(
String userId, Transaction txn, BankTransferResponse response) {
log.info("Sending notifications for: {}", txn.getTransactionId());
}
}

The Two Concrete Processors

ImmediateTransferProcessor hits the bank API via the Strategy Factory, saves the transaction record, and fires full SMS + push + email notifications.

ScheduledTransferProcessor skips the bank API entirely. Its execute() returns a mock "SCHEDULED" response. Its postProcess() saves the transaction with TransferStatus.SCHEDULED. No notifications are sent.

Same skeleton. Completely different behaviour. Neither processor touches the other’s code.

How to Use It in Your Own Code

Pattern #5 — Facade Pattern

The Problem

TransferService is called by controllers. It shouldn't need to know that ImmediateTransferProcessor and ScheduledTransferProcessor exist, or how to choose between them, or that a list of processors is iterated to find the right one. That's not its job.

The Pattern

Facade provides a simple interface over a complex subsystem. All the complexity — choosing a processor, calling it, routing validation — lives inside TransferFacade. The service calls one method.

@Service
@Transactional
public class TransferFacade {
private final List<AbstractTransferProcessor> processors;
public TransferResponse processTransfer(String userId, TransferRequest request) {
return getProcessor(request).process(userId, request);
}
public void validateTransfer(String userId, TransferRequest request) {
getProcessor(request).preValidate(userId, request);
}
private AbstractTransferProcessor getProcessor(TransferRequest request) {
return processors.stream()
.filter(p -> p.isApplicable(request))
.findFirst()
.orElseThrow(() ->
new IllegalArgumentException("No processor found for this request"));
}
}

The isApplicable() rule is simple: immediate if the date is null or today, scheduled if the date is in the future. The facade figures that out. TransferService never sees it.

How to Use It in Your Own Code

Pattern #6 — Mapper Pattern

The Problem

One Transaction entity needs to appear in many different shapes depending on the endpoint: a schedule list view, a detailed receipt, a portal admin view, a repeat-transfer pre-fill. Each needs different fields.

The worst thing you can do is expose the raw entity — it leaks your database schema, creates coupling, and breaks whenever you rename a column.

The Pattern

The Mapper pattern defines one interface with multiple mapTo*() methods, all implemented in a single class:

public interface TransactionMapper {
ScheduleDto mapToScheduleDto(Transaction entity);
ScheduleHistoryDto mapToScheduleHistoryDto(Transaction entity);
ScheduleUpcomingDto mapToScheduleUpcomingDto(Transaction entity);
TransactionDto mapToTransactionDto(Transaction entity, String categoryName);
TransactionRepeatDto mapToTransactionRepeatDto(Transaction entity);
PortalTranHistoryDto mapToPortalTranHistoryDto(Transaction entity);
}

Each method cherry-picks exactly the fields needed for that view. The TransactionMapperImpl also handles edge cases — masked account numbers, formatted recurring text, null-safe field resolution — all in one place.

How to Use It in Your Own Code

Bonus: Request-Scoped Cache

There’s a hidden seventh pattern worth mentioning. The ExchangeRateCache uses @RequestScope to create a per-request in-memory HashMap. During a single transfer, the exchange rate for USD→MVR may be fetched three times across different components. Without caching, that's three external API calls per request.

@Component
@RequestScope
public class ExchangeRateCache {
private final Map<String, ExchangeRateResponse> cache = new HashMap<>();
public ExchangeRateResponse get(String from, String to) { ... }
public void put(String from, String to, ExchangeRateResponse rate) { ... }
}

By scoping it to the request, the cache is automatically created at the start of each HTTP request and destroyed at the end — no eviction logic needed, no stale data risk across requests.

How All 6 Patterns Fit Together

Here’s the full journey of a single transfer request through the system:

Step 1 — Facade: TransferService calls TransferFacade.processTransfer(). One line. It knows nothing else.

Step 2 — Facade: The facade iterates its processor list and finds ImmediateTransferProcessor via isApplicable().

Step 3 — Template Method: processor.process() begins executing the fixed pipeline: validate → preProcess → execute → postProcess → sendNotifications → buildResponse.

Step 4 — Chain of Responsibility: The validate step runs the full chain: CurrencyValidationHandlerLimitValidationHandlerBalanceValidationHandler. Any failure throws immediately.

Step 5 — Factory: The preProcess step calls TransferStrategyFactory.getStrategy(transferType), which filters the strategy list and returns the correct one.

Step 6 — Strategy: The chosen strategy (InternalTransferStrategy or ExternalTransferStrategy) calls the bank API with the correctly built payload.

Step 7 — Template Method: postProcess() saves the Transaction entity and updates its status. sendNotifications() fires SMS, push, and email.

Step 8 — Mapper: buildResponse() calls TransactionMapper to convert the raw Transaction entity into a clean TransferResponse DTO. The caller receives a safe, shaped response — never the raw entity.

Key Takeaways

Design patterns aren’t about impressing colleagues or passing interviews. They’re about writing code that doesn’t fight you six months later.

Strategy — Adding a new transfer type means adding one new class. Zero changes elsewhere.

Factory — No if-else routing. Spring auto-discovers new strategies automatically.

Chain of Responsibility — Each validator is independently testable, mockable, and reorderable.

Template Method — The transfer lifecycle is documented in code, not in someone’s head.

Facade — Controllers stay simple. Complex routing is hidden where it belongs.

Mapper — Database changes don’t break API contracts. Entity internals stay internal.

The patterns also make the codebase self-documenting. When a new engineer opens TransferFacade, they immediately understand the shape of the system without reading a single implementation class.

Good architecture is not about being clever. It’s about making the next change easy and the next engineer’s life better.

Final Thought

You don’t need to use all six patterns in every project. Start with one. The next time you find yourself writing the third if-else branch to handle a new case, reach for Strategy. The next time you have five steps that always happen in the same order, reach for Template Method.

Patterns are tools. Learn when to use them, not just how. That’s the difference between a junior who memorises them and a senior who reaches for exactly the right one at exactly the right moment.

Happy coding — and may your PRs always be clean. 🚀

Looking for a crypto payment gateway?

NexaPay lets merchants accept card payments and receive crypto. No KYC required. Instant settlement via Visa, Mastercard, Apple Pay, and Google Pay.

Learn More →
This article was originally published on Fintech Tag and is republished here under RSS syndication for informational purposes. All rights and intellectual property remain with the original author. If you are the author and wish to have this article removed, please contact us at [email protected].

NexaPay — Accept Card Payments, Receive Crypto

No KYC · Instant Settlement · Visa, Mastercard, Apple Pay, Google Pay

Get Started →