6 Design Patterns That Made My Payment Service Actually Maintainable
Pasindu Jayasinghe9 min read·Just now--
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:
- Internal (same bank, own account):
FT_CBM_OWN - Third-party (same bank, different user):
FT_CBM_THIRD_PARTY - External (different bank, IPS/SWIFT):
FT_CBM_OTHER
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
- Define a single interface that all variants implement.
- Add a
supports()orisApplicable()method so each strategy self-selects. - Register all strategies as Spring beans and let a Factory choose (see next pattern).
- Adding a new variant = adding a new class, zero changes to existing 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
- Combine Factory + Strategy whenever you have a growing family of algorithms.
- Inject
List<YourInterface>— Spring fills it with all implementations automatically. - Make each implementation declare what it handles via a
supports()method. - Throw a clear exception for unknown types — silent fallbacks hide bugs.
Pattern #3 — Chain of Responsibility
The Problem
Before any transfer executes, we validate three things in a strict order:
- Currency rules — is this currency pair allowed?
- Limit rules — does the amount respect min/max and daily caps?
- 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
- Use Chain of Responsibility for ordered, multi-step validation pipelines.
- Each handler calls
validateNext()at the end — only if its own step passes. - Throwing an exception stops the chain immediately. No further handlers run.
- To add a new validation step, create a new handler and insert it into the chain. No existing handlers change.
Pattern #4 — Template Method Pattern
The Problem
Every transfer — immediate or scheduled — goes through the same lifecycle:
- Validate the request
- Pre-process (generate ID, apply exchange rates, calculate commission)
- Execute (hit the bank API)
- Post-process (save the transaction, update status)
- Send notifications (SMS, push, email)
- 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
- Use Template Method when multiple implementations share the same lifecycle but differ in the details.
- Mark the skeleton method
final— it prevents accidental override and documents intent clearly. - Use
protectedfor optional hooks that have sensible defaults. - Use
abstractfor mandatory steps that every subclass must customise.
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
- Use Facade when a service or controller needs to trigger a complex flow but shouldn’t own the routing logic.
- The Facade can be
@Transactionalwhile individual processors are not — giving you clean transaction boundary control. - Combine Facade with the same
stream().filter()Factory pattern for zero-config processor routing.
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
- Create one Mapper interface per domain entity, with one method per response shape.
- Keep the implementation in a single class — all mapping logic for one entity lives together.
- Pass extra context (like
categoryName) as method parameters rather than loading inside the mapper. - Never expose JPA entities directly from your API layer — always map through a DTO.
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: CurrencyValidationHandler → LimitValidationHandler → BalanceValidationHandler. 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. 🚀