R23 — Low-level design: principles and patterns
Everything up to R22 is high-level design: boxes, data flows, scale and failure across many machines. Many loops also include a low-level design (LLD) round — sometimes called object-oriented design or machine coding. The question is small in scope ("design an ATM", "design a parking lot", "design Splitwise"), but the bar is different: you are judged on the classes you choose, how responsibilities are split between them, how they collaborate, and whether the code you write would survive the next requirement.
This section is the toolkit for that round, and R24 applies it to ten case studies. It builds in three steps:
- How to run the round — what the interviewer grades and a method that fits 45–60 minutes.
- Principles — SOLID and a handful of others. They explain why a design is good, and every pattern below is one of them made concrete.
- The 23 classic patterns from the "Gang of Four" book, grouped as creational (how objects are made), structural (how objects are composed) and behavioural (how objects share work and talk to each other). Each comes with the problem it solves, a plain-language idea, a small diagram, Java code with payments-flavoured examples, where you have already seen it in the JDK or Spring, and what to say in an interview.
Patterns are named solutions to recurring problems — reach for one when you can name the problem it solves, never to show you know the name.
How to approach a low-level design round
A low-level design (LLD) round asks you to design the classes for a small, bounded problem: an ATM, a parking lot, an expense splitter. It is also called an object-oriented design round or, when you are expected to produce running code, a machine-coding round. In the high-level rounds (R1–R22) the building blocks were services, queues and databases. Here the building blocks are classes, interfaces and methods, and the question behind every question is: if the requirements change next month, how much of this code has to change with them?
What interviewers grade
| Area | What a strong answer shows | What a weak answer looks like |
|---|---|---|
| Requirements | Asks what is in and out of scope, which actors exist, and which flows matter most | Starts drawing classes before knowing what the system does |
| Entities and responsibilities | Each class has one clear job, named in the domain's own words | A Manager or Service class that does everything |
| Relationships | Chooses between is-a (inheritance), has-a (composition) and uses-a (dependency) deliberately | Inheritance everywhere because it looks object-oriented |
| Class diagram | A small diagram with the key classes, interfaces and arrows, drawn before code | No diagram, or forty boxes with no hierarchy |
| Key flows | Walks one or two use cases through the classes, method by method | Classes that are never shown working together |
| Core code | Compiles in their head: interfaces, a few concrete classes, the main method bodies | Pseudocode for everything, or getters and setters for ten minutes |
| Concurrency | Names the shared mutable state and protects it with the smallest correct lock | Ignores it, or puts synchronized on every method |
| Extension | Shows where a new type (a new payment method, a new vehicle) plugs in without edits elsewhere | "We would add another if" |
An LLD round grades the shape of your code under change, not the amount of code you write.
A method for a 45–60 minute round
| Step | 45 min | 60 min | What you produce |
|---|---|---|---|
| 1. Clarify requirements | 5 | 7 | A short list of use cases, actors and explicit non-goals |
| 2. Find entities and responsibilities | 5 | 7 | Nouns become candidate classes, verbs become methods |
| 3. Relationships and class diagram | 8 | 10 | A mermaid-sized diagram: 6–12 classes and interfaces |
| 4. Walk the key flows | 5 | 8 | One or two use cases traced call by call |
| 5. Code the core classes | 15 | 20 | Interfaces, main entities, the one or two methods with real logic |
| 6. Concurrency, extension, testing | 5 | 6 | Where locks go, where new types plug in, how you would unit-test |
| 7. Wrap-up | 2 | 2 | What you would do next with more time |
Step 1 — clarify. Ask about actors (customer, admin, operator), the two or three flows that matter, and limits ("one ATM or a fleet?", "partial payments allowed?"). Write the answers down as a list the interviewer can see. Say what you will not build.
Step 2 — entities and responsibilities. Underline the nouns and verbs in the requirements. Nouns that hold state and identity become entities (objects with an ID that persists over time, such as Account). Nouns that are just values become value objects (Money, Address). Verbs become methods on whichever class owns the data the verb needs.
"A user creates a group, adds an expense paid by one member and split among others,
and the app shows who owes whom."
nouns -> User, Group, Expense, Split, Balance (Money is a value object)
verbs -> createGroup, addExpense, computeBalances, settle
owner -> addExpense belongs to Group (it owns the member list)
computeBalances belongs to a BalanceCalculator (it is an algorithm that may vary)
Step 3 — relationships. For each pair of classes decide: does A own B's lifetime (composition), hold a B that lives elsewhere (aggregation), or just use a B in a method (dependency)? Only use inheritance when a subclass can stand in for its parent everywhere (Liskov, below). Then draw the diagram.
Step 4 — walk the flows. Trace "withdraw cash" or "add expense" through the diagram: which object receives the call, what it asks of whom, what can fail. This is where missing classes and wrong owners show up — far cheaper to fix on the whiteboard than in code.
Step 5 — code the core. Write the interfaces first, then the entities, then the one or two methods that hold the real logic. Skip getters, setters and boilerplate; say "plus the usual accessors".
Step 6 — concurrency and extension. Ask: what state is shared between threads? Usually it is a handful of things — an account balance, a seat map, a slot pool. Protect each with the narrowest tool: a per-entity lock, an atomic variable, a ConcurrentHashMap, or an optimistic version check. Then show one extension: "a new payment method is a new class implementing PaymentProcessor; nothing else changes".
Jumping to code in minute two is the most common way to fail an LLD round. Without agreed requirements and a diagram, you will refactor live and run out of time.
The other common mistakes:
- God classes. A
BankSystemclass with forty methods. Every change touches it, and nothing can be tested alone. - Patterns for their own sake. Naming five patterns in the first ten minutes sounds rehearsed. Introduce a pattern only when you can say what changes it protects you from.
- Anaemic models. Entities that are only fields and getters, with all logic in "service" classes. Put behaviour next to the data it guards:
account.debit(money)checks the balance, not a separate helper. - Inheritance for code reuse. Extending a class just to borrow its methods couples you to its internals.
- Ignoring failure. What happens when the card is declined, the cash dispenser jams, the network drops? One sentence per failure is enough.
What's being probed: whether you design before you type.
Concept — Responsibility-driven design. Start from what each object is responsible for knowing and doing, not from data tables. Like staffing a small office: decide who answers the phone and who keeps the books before buying desks. R24 applies this method to ten case studies.
Takeaway: requirements, nouns and verbs, diagram, flows, then code — in that order.
Principles
Design patterns are not a list to memorise. Almost every one of them is a principle applied to a recurring situation. Learn the principles first and most patterns become obvious; the reverse is not true.
S — Single responsibility principle
Definition. A class should have one reason to change: one concern, owned by one group of people who might ask for changes.
Violation.
class PaymentService {
void pay(PaymentRequest req) {
if (req.amountPaise() <= 0) throw new IllegalArgumentException("amount"); // validation rules
jdbc.update("INSERT INTO payment(id, amount) VALUES (?, ?)", // persistence
req.id(), req.amountPaise());
String body = "Paid Rs " + req.amountPaise() / 100 + " to " + req.payee(); // receipt wording
smtp.send(req.email(), "Payment receipt", body); // delivery channel
}
}
Compliance changes the validation rules, the DBA changes the schema, marketing changes the receipt wording, and the platform team swaps SMTP for a notification service. Four groups, one file.
Fix.
class PaymentService {
private final PaymentValidator validator;
private final PaymentRepository repository;
private final ReceiptFormatter formatter;
private final NotificationSender sender;
PaymentService(PaymentValidator v, PaymentRepository r, ReceiptFormatter f, NotificationSender s) {
this.validator = v; this.repository = r; this.formatter = f; this.sender = s;
}
void pay(PaymentRequest req) {
validator.validate(req);
Payment payment = repository.save(Payment.from(req));
sender.send(req.customerId(), formatter.receipt(payment));
}
}
Why it matters. Each class can now be changed and tested alone: you can test the validation rules without a database or a mail server. PaymentService keeps one job — orchestrating the steps — which is itself a single responsibility.
O — Open/closed principle
Definition. Code should be open for extension and closed for modification: adding a new case should mean adding a class, not editing a tested one.
Violation.
long feePaise(Payment p) {
return switch (p.method()) {
case "CARD" -> p.amountPaise() * 2 / 100; // 2%
case "UPI" -> 0;
case "NETBANKING" -> 1_500; // flat Rs 15
default -> throw new IllegalArgumentException(p.method());
};
}
Adding wallets means editing this method, and any other switch on payment method scattered around the codebase.
Fix.
interface FeePolicy {
PaymentMethod method();
long feePaise(Payment p);
}
@Component class CardFee implements FeePolicy {
public PaymentMethod method() { return PaymentMethod.CARD; }
public long feePaise(Payment p) { return p.amountPaise() * 2 / 100; }
}
@Component class FeeCalculator {
private final Map<PaymentMethod, FeePolicy> policies = new EnumMap<>(PaymentMethod.class);
FeeCalculator(List<FeePolicy> all) { all.forEach(fp -> policies.put(fp.method(), fp)); }
long feePaise(Payment p) { return policies.get(p.method()).feePaise(p); }
}
A wallet fee is now a new @Component; Spring injects it into the list. This is the Strategy pattern, covered with the behavioural patterns in this section.
Why it matters. Tested code stays untouched, so a new case cannot break an old one. One nuance worth saying in an interview: when the set of cases is genuinely closed and small, a Java 17 sealed interface with an exhaustive switch is a fine alternative — the compiler then tells you every place to update.
L — Liskov substitution principle
Definition. A subclass must be usable anywhere its parent is expected, without the caller noticing: it may not demand more of its inputs or promise less in its results.
Violation.
class Account {
protected long balancePaise;
void withdraw(long paise) {
if (paise > balancePaise) throw new InsufficientFundsException();
balancePaise -= paise;
}
}
class FixedDepositAccount extends Account {
@Override void withdraw(long paise) {
throw new UnsupportedOperationException("locked until maturity");
}
}
Any code that takes an Account and calls withdraw now has to know about fixed deposits. The type hierarchy lies.
Fix.
abstract class Account {
protected long balancePaise;
long balancePaise() { return balancePaise; }
}
interface Withdrawable {
void withdraw(long paise);
}
class SavingsAccount extends Account implements Withdrawable {
public void withdraw(long paise) {
if (paise > balancePaise) throw new InsufficientFundsException();
balancePaise -= paise;
}
}
class FixedDepositAccount extends Account {
LocalDate maturesOn; // no withdraw method at all
}
Why it matters. Code that accepts Withdrawable can trust it completely. Violations of this principle surface as instanceof checks and UnsupportedOperationException — both signs that inheritance was used where it did not fit.
I — Interface segregation principle
Definition. No class should be forced to implement methods it does not use; prefer several small, role-specific interfaces over one large one.
Violation.
interface PaymentProcessor {
AuthResult authorise(PaymentRequest r);
void capture(String paymentId);
void refund(String paymentId, long paise);
Mandate createMandate(MandateRequest r); // recurring payments
void payout(PayoutRequest r); // sending money out
}
class NetBankingProcessor implements PaymentProcessor {
public Mandate createMandate(MandateRequest r) { throw new UnsupportedOperationException(); }
public void payout(PayoutRequest r) { throw new UnsupportedOperationException(); }
// ...
}
Fix.
interface Authoriser { AuthResult authorise(PaymentRequest r); void capture(String paymentId); }
interface Refunder { void refund(String paymentId, long paise); }
interface MandateManager { Mandate createMandate(MandateRequest r); }
interface PayoutSender { void payout(PayoutRequest r); }
class NetBankingProcessor implements Authoriser, Refunder { /* only what it supports */ }
class CardProcessor implements Authoriser, Refunder, MandateManager { /* ... */ }
Why it matters. A refund job depends only on Refunder, so it cannot accidentally call payout, and a change to the mandate API does not force every processor to recompile. ISP is LSP's neighbour: fat interfaces are what tempt classes into throwing UnsupportedOperationException.
D — Dependency inversion principle
Definition. High-level policy should not depend on low-level details; both should depend on an abstraction, and the abstraction belongs to the high-level side.
Violation.
class PaymentReminderJob {
private final SmtpEmailSender sender = new SmtpEmailSender("smtp.internal", 25);
void run(List<Invoice> overdue) {
overdue.forEach(i -> sender.sendEmail(i.email(), "Payment due", i.summary()));
}
}
The business rule (remind customers of overdue invoices) is welded to one mail server. It cannot switch to SMS, and it cannot be unit-tested without SMTP.
Fix.
interface NotificationSender { // owned by the business layer
void send(String customerId, Message message);
}
class PaymentReminderJob {
private final NotificationSender sender;
PaymentReminderJob(NotificationSender sender) { this.sender = sender; } // injected
void run(List<Invoice> overdue) {
overdue.forEach(i -> sender.send(i.customerId(), Message.of("Payment due", i.summary())));
}
}
class SmtpNotificationSender implements NotificationSender { /* detail lives at the edge */ }
Why it matters. The arrow of dependency now points from the detail to the policy. Keep two terms apart: dependency injection is the technique (pass collaborators in through the constructor, which Spring automates); dependency inversion is the principle (the interface is shaped by and belongs to the code that uses it).
Most patterns are one move: find what varies, hide it behind an interface, and compose objects instead of inheriting from them.
Composition over inheritance
Inheritance says "B is an A" and gives B all of A's code. Composition says "B has an A" and calls it. Inheritance looks like reuse, but it couples the subclass to the parent's internals: if the parent changes how one method calls another, the subclass can silently break. This is the fragile base class problem.
It also multiplies classes. Suppose senders need optional retry and logging:
inheritance: EmailSender, SmsSender
RetryingEmailSender, RetryingSmsSender
LoggingEmailSender, LoggingSmsSender
RetryingLoggingEmailSender, RetryingLoggingSmsSender -> 8 classes, and growing
composition: EmailSender, SmsSender, RetryingSender, LoggingSender -> 4 classes, any combination
final class RetryingSender implements NotificationSender {
private final NotificationSender delegate;
private final int maxAttempts;
RetryingSender(NotificationSender delegate, int maxAttempts) {
this.delegate = delegate; this.maxAttempts = maxAttempts;
}
public void send(String customerId, Message m) {
for (int attempt = 1; ; attempt++) {
try { delegate.send(customerId, m); return; }
catch (TransientException e) { if (attempt == maxAttempts) throw e; }
}
}
}
NotificationSender sender = new LoggingSender(new RetryingSender(new SmsSender(), 3));
That wrapper is the Decorator pattern, below. Use inheritance only for a true is-a relationship that passes the Liskov test; use composition for everything else.
Programming to interfaces
Declare variables, fields and parameters by the role they play, not the class that happens to fill it: List<Payment> payments = new ArrayList<>(), PaymentGateway gateway, not ArrayList or HdfcGatewayImpl. Callers then depend only on the contract, and the implementation can change, be mocked in tests, or be wrapped in a decorator or proxy. "Interface" here means any abstraction — a Java interface or an abstract class.
DRY, KISS and YAGNI
| Principle | Plain meaning | The trap on the other side |
|---|---|---|
| DRY — don't repeat yourself | Each piece of knowledge (a fee rule, a validation) lives in one place | Merging two pieces of code that only look alike but change for different reasons |
| KISS — keep it simple | The simplest design that meets the requirements wins | Mistaking "simple" for "fewest classes" — a 300-line method is not simple |
| YAGNI — you aren't gonna need it | Do not build for requirements nobody has asked for | Ignoring extension points the interviewer explicitly hinted at |
In an interview, YAGNI is a useful sentence: "I'll keep this as one class for now; if we add a second currency, I'd extract a FxConverter interface here."
Cohesion and coupling
Cohesion is how closely the contents of one class belong together. High cohesion means every method uses most of the fields; low cohesion means the class is really two classes sharing a file. Coupling is how much one class knows about another. Low coupling means a class knows only the other's interface, and only a little of it.
The goal is high cohesion, low coupling. A quick smell test for coupling is the Law of Demeter ("talk only to your immediate friends"): payment.getAccount().getCustomer().getAddress().getCity() couples the caller to four classes. Ask the payment for what you need instead, or pass in what the method needs.
Immutability and value objects
A value object is defined entirely by its values and has no identity: two Money(50000, INR) objects are interchangeable. An entity has an identity that persists while its values change: two accounts with the same balance are still different accounts. Value objects should be immutable — once built, they never change — which makes them safe to share between threads, safe as map keys, and impossible to corrupt by aliasing.
Java 17 records are the natural tool: final fields, a canonical constructor, and equals, hashCode and toString generated from the components.
public record Money(long minorUnits, Currency currency) { // minorUnits = paise for INR
public Money { // compact constructor: validate once
Objects.requireNonNull(currency, "currency");
}
public static Money inr(long paise) {
return new Money(paise, Currency.getInstance("INR"));
}
public Money plus(Money other) {
requireSameCurrency(other);
return new Money(Math.addExact(minorUnits, other.minorUnits), currency); // new object
}
public boolean isGreaterThan(Money other) {
requireSameCurrency(other);
return minorUnits > other.minorUnits;
}
private void requireSameCurrency(Money other) {
if (!currency.equals(other.currency))
throw new IllegalArgumentException(currency + " vs " + other.currency);
}
}
Math.addExact throws on overflow instead of wrapping to a negative number. A record is only shallowly immutable: if a component is a List, copy it in the compact constructor with List.copyOf(...), or callers can still modify it.
Creational patterns
Creational patterns answer one question: who creates an object, and how much does the caller need to know about its concrete class? The less the caller knows, the more freely the concrete classes can change.
Simple factory
The problem. Code all over the system decides which payment gateway class to instantiate with the same if/else on payment method, so adding a method means finding every copy.
The idea. Put the decision in one method that takes a key and returns an interface. It is like a restaurant counter: you order "a coffee" and the counter decides which machine makes it.
classDiagram
class PaymentGateway {
<<interface>>
+charge(ChargeRequest req) ChargeResult
}
class CardGateway
class UpiGateway
class GatewayFactory {
+forMethod(PaymentMethod m) PaymentGateway
}
PaymentGateway <|.. CardGateway
PaymentGateway <|.. UpiGateway
GatewayFactory ..> PaymentGateway : creates
Checkout --> GatewayFactory
In Java.
final class GatewayFactory {
private GatewayFactory() {}
static PaymentGateway forMethod(PaymentMethod method) {
return switch (method) {
case CARD -> new CardGateway();
case UPI -> new UpiGateway();
case NETBANKING -> new NetBankingGateway();
};
}
}
In a Spring application the same job is usually done by injection: make each gateway a bean exposing its method(), inject List<PaymentGateway>, and build an EnumMap in the constructor — exactly the FeeCalculator shape under the open/closed principle. Now a new gateway needs no edit to the factory at all.
Why it is not a GoF pattern. The 1994 "Gang of Four" book catalogued patterns built on polymorphism. A simple factory is just a static method with a conditional — one concrete class, no subclass overriding anything. It is a useful idiom, and it is what most people mean when they say "factory", but the GoF Factory Method is a different thing (next).
Where you have seen it. Calendar.getInstance() returns a calendar suited to the default locale — usually a GregorianCalendar, but a Buddhist or Japanese imperial calendar for certain locales. NumberFormat.getInstance(). EnumSet.noneOf(type) returns a RegularEnumSet for enums of up to 64 constants and a JumboEnumSet above that — the caller never sees either class. List.of(...) returns different internal immutable list classes depending on how many elements you pass.
Use it when / avoid it when.
- Use it when the choice of class depends on a runtime value and you want that choice made in exactly one place.
- Avoid it when the list of cases grows often and lives in different teams' code — then registration (Spring injection, a plugin map) beats a central
switch.
In an interview. Say: "I'll put the choice in one factory so callers depend only on PaymentGateway; in Spring I'd let the container build the map." If asked whether it breaks open/closed: yes, mildly — one method changes per new type — but the change is contained, and injection removes even that.
Factory Method
The problem. A base class runs a fixed workflow, but one step needs an object whose concrete type only the subclass knows.
The idea. The base class declares an abstract "create" method and calls it; each subclass overrides it to return its own product. Like a franchise manual that says "serve the house bread" — every branch follows the same recipe, but each bakes its own bread.
classDiagram
class StatementExporter {
<<abstract>>
+export(txns, out)
#createWriter(out) StatementWriter
}
class CsvExporter {
#createWriter(out) StatementWriter
}
class Mt940Exporter {
#createWriter(out) StatementWriter
}
class StatementWriter {
<<interface>>
+header()
+line(Txn t)
+footer()
}
StatementExporter <|-- CsvExporter
StatementExporter <|-- Mt940Exporter
StatementWriter <|.. CsvWriter
StatementWriter <|.. Mt940Writer
StatementExporter ..> StatementWriter : uses
In Java. A bank exports account statements as CSV for customers and as MT940 (the SWIFT statement format) for corporate treasury systems. The workflow is identical; only the writer differs.
interface StatementWriter extends Closeable {
void header(Account a) throws IOException;
void line(Txn t) throws IOException;
void footer(Money closingBalance) throws IOException;
}
abstract class StatementExporter {
public final void export(Account account, List<Txn> txns, OutputStream out) throws IOException {
try (StatementWriter w = createWriter(out)) { // the factory method call
w.header(account);
for (Txn t : txns) w.line(t);
w.footer(account.balance());
}
}
protected abstract StatementWriter createWriter(OutputStream out); // the factory method
}
class CsvExporter extends StatementExporter {
protected StatementWriter createWriter(OutputStream out) { return new CsvWriter(out); }
}
class Mt940Exporter extends StatementExporter {
protected StatementWriter createWriter(OutputStream out) { return new Mt940Writer(out); }
}
The fixed export workflow with one overridable step is also the Template Method pattern; the two often appear together.
Where you have seen it. Iterable.iterator() — every collection overrides it to return its own iterator class, and the enhanced for loop works against the interface. Spring's FactoryBean<T>, whose getObject() each implementation overrides to produce a bean that is awkward to construct directly.
Use it when / avoid it when.
- Use it when you already have (or genuinely want) a class hierarchy and each subclass pairs with its own product.
- Avoid it when you would create subclasses only to vary creation — passing a
Supplier<StatementWriter>or a writer factory into one exporter is lighter.
In an interview. A common follow-up is "simple factory vs Factory Method vs Abstract Factory?" Short answer: a simple factory is one method with a switch; Factory Method lets subclasses decide what one product to create; Abstract Factory is an object that creates a whole family of related products.
Abstract Factory
The problem. You need several related objects that must match each other, and mixing them from different families is a bug.
The idea. One factory interface with a create method per product; each concrete factory produces a consistent family. Like buying furniture from one collection: the chair, table and shelf all come in the same style because they come from the same catalogue page.
classDiagram
class PaymentRailFactory {
<<interface>>
+accountValidator() AccountValidator
+messageBuilder() MessageBuilder
}
class NeftRailFactory
class SepaRailFactory
class AccountValidator {
<<interface>>
}
class MessageBuilder {
<<interface>>
}
PaymentRailFactory <|.. NeftRailFactory
PaymentRailFactory <|.. SepaRailFactory
AccountValidator <|.. IfscAccountValidator
AccountValidator <|.. IbanValidator
MessageBuilder <|.. NeftMessageBuilder
MessageBuilder <|.. Pain001Builder
NeftRailFactory ..> IfscAccountValidator
NeftRailFactory ..> NeftMessageBuilder
SepaRailFactory ..> IbanValidator
SepaRailFactory ..> Pain001Builder
In Java. An Indian domestic transfer needs an IFSC-plus-account-number check and a NEFT message; a European transfer needs an IBAN check and an ISO 20022 pain.001 message. Validating with one rail's rules and formatting with the other's would produce a payment that is rejected downstream.
interface PaymentRailFactory {
AccountValidator accountValidator();
MessageBuilder messageBuilder();
}
final class NeftRailFactory implements PaymentRailFactory {
public AccountValidator accountValidator() { return new IfscAccountValidator(); }
public MessageBuilder messageBuilder() { return new NeftMessageBuilder(); }
}
final class SepaRailFactory implements PaymentRailFactory {
public AccountValidator accountValidator() { return new IbanValidator(); }
public MessageBuilder messageBuilder() { return new Pain001Builder(); }
}
final class TransferService {
private final PaymentRailFactory rail;
TransferService(PaymentRailFactory rail) { this.rail = rail; }
OutboundMessage prepare(Transfer t) {
rail.accountValidator().validate(t.beneficiary());
return rail.messageBuilder().build(t); // guaranteed to match the validator
}
}
Where you have seen it. java.sql.Connection is an abstract factory: from one vendor's connection you get that vendor's Statement, PreparedStatement and CallableStatement, which work together. javax.xml.parsers.DocumentBuilderFactory.newInstance() returns a factory for whichever XML parser implementation is configured.
Use it when / avoid it when.
- Use it when products come in families that must not be mixed — per region, per vendor, per environment.
- Avoid it when there is only one product, or families never vary. It is the heaviest creational pattern.
In an interview. Mention its asymmetry: adding a new family (a new rail) is one new class, but adding a new kind of product (say a ChargesCalculator) means changing the interface and every factory. Say which one your domain is more likely to need.
Builder
The problem. An object has a few required fields and many optional ones. Constructors with ten parameters are unreadable (which String is the payee?), and setters leave a half-built, mutable object lying around.
The idea. A separate helper collects the values one named call at a time, then validates and creates the finished, immutable object in one step. Like ordering a sandwich at a counter: you call out each choice, and the sandwich is only made when you say "done".
classDiagram
class PaymentRequest {
+idempotencyKey String
+amount Money
+payeeVpa String
+remarks String
+expiresAt Instant
+builder() Builder
}
class Builder {
+idempotencyKey(String k) Builder
+amount(Money m) Builder
+payeeVpa(String v) Builder
+remarks(String r) Builder
+build() PaymentRequest
}
Builder ..> PaymentRequest : builds
In Java.
public record PaymentRequest(String idempotencyKey, Money amount, String payerVpa, String payeeVpa,
String remarks, Instant expiresAt, Map<String, String> metadata) {
public PaymentRequest { metadata = Map.copyOf(metadata); } // keep the record immutable
public static Builder builder(String idempotencyKey, Money amount) { // required fields up front
return new Builder(idempotencyKey, amount);
}
public static final class Builder {
private final String idempotencyKey;
private final Money amount;
private String payerVpa, payeeVpa, remarks;
private Instant expiresAt;
private final Map<String, String> metadata = new HashMap<>();
private Builder(String key, Money amount) {
this.idempotencyKey = Objects.requireNonNull(key);
this.amount = Objects.requireNonNull(amount);
}
public Builder payer(String vpa) { this.payerVpa = vpa; return this; }
public Builder payee(String vpa) { this.payeeVpa = vpa; return this; }
public Builder remarks(String r) { this.remarks = r; return this; }
public Builder expiresAt(Instant t) { this.expiresAt = t; return this; }
public Builder meta(String k, String v) { metadata.put(k, v); return this; }
public PaymentRequest build() {
if (payeeVpa == null) throw new IllegalStateException("payee is required");
if (amount.minorUnits() <= 0) throw new IllegalStateException("amount must be positive");
Instant expiry = expiresAt != null ? expiresAt : Instant.now().plus(Duration.ofMinutes(10));
return new PaymentRequest(idempotencyKey, amount, payerVpa, payeeVpa, remarks, expiry, metadata);
}
}
}
PaymentRequest req = PaymentRequest.builder("ord-7781-pay-1", Money.inr(49_900))
.payee("merchant@bank")
.remarks("Order 7781")
.meta("channel", "app")
.build();
Where you have seen it. StringBuilder (builds a String piece by piece — a builder in the loose sense). java.net.http.HttpRequest.newBuilder(), Stream.builder(), Locale.Builder. In Spring: UriComponentsBuilder, ResponseEntity.ok().header(...).body(...), WebClient.builder(), and MockMvcRequestBuilders in tests. Lombok's @Builder generates the boilerplate.
Use it when / avoid it when.
- Use it when there are four or more parameters, several optional, or cross-field rules to check before the object exists.
- Avoid it when an object has two or three required fields — a constructor or a static factory such as
Money.inr(...)is clearer.
In an interview. A good follow-up is "how do you make required fields impossible to forget?" Answer: take them in the builder(...) method as above, so the compiler enforces them, and check cross-field rules in build(). For many required fields, a step builder returns a different interface after each step so only valid next calls compile.
Prototype
The problem. Building an object from scratch is expensive or needs a lot of configuration, and you need many near-identical copies.
The idea. Keep a fully configured instance and copy it, then change only what differs. Like a letter template: you duplicate it and fill in the name, rather than retyping the letterhead each time.
classDiagram
class PayoutTemplate {
-debitAccount String
-purposeCode String
-remittanceLines List
+copy() PayoutTemplate
+forBeneficiary(Beneficiary b, Money m) PayoutTemplate
}
class TemplateRegistry {
-templates Map
+get(String name) PayoutTemplate
}
TemplateRegistry o-- PayoutTemplate
In Java. A corporate client runs monthly salary payouts. The debit account, purpose code and remittance text are set up once, then copied for each of thousands of employees.
final class PayoutTemplate {
private final String debitAccount;
private final String purposeCode;
private final List<String> remittanceLines;
private Beneficiary beneficiary;
private Money amount;
PayoutTemplate(String debitAccount, String purposeCode, List<String> lines) {
this.debitAccount = debitAccount;
this.purposeCode = purposeCode;
this.remittanceLines = new ArrayList<>(lines);
}
private PayoutTemplate(PayoutTemplate src) { // copy constructor
this.debitAccount = src.debitAccount;
this.purposeCode = src.purposeCode;
this.remittanceLines = new ArrayList<>(src.remittanceLines); // deep copy of mutable state
this.beneficiary = src.beneficiary;
this.amount = src.amount;
}
PayoutTemplate copy() { return new PayoutTemplate(this); }
PayoutTemplate forBeneficiary(Beneficiary b, Money m) {
PayoutTemplate p = copy();
p.beneficiary = b; p.amount = m;
return p;
}
}
final class TemplateRegistry {
private final Map<String, PayoutTemplate> templates = new ConcurrentHashMap<>();
void register(String name, PayoutTemplate t) { templates.put(name, t); }
PayoutTemplate get(String name) { return templates.get(name).copy(); } // never hand out the original
}
A shallow copy shares mutable fields: change the copy's list and you have changed the template for everyone. Copy every mutable field, or make the fields immutable so sharing is safe.
Where you have seen it. Object.clone() and the Cloneable marker interface; ArrayList.clone() (a shallow copy). Copy constructors such as new ArrayList<>(other) and new HashMap<>(other). Spring's prototype scope shares the name but not the mechanism: the container builds a fresh bean from its definition on each request; it does not copy an existing instance.
Use it when / avoid it when.
- Use it when configuration is costly (parsed from files, assembled from many sources) and copies differ in a few fields.
- Avoid it when objects are immutable — a record with a
withAmount(...)method that returns a new record does the job with no copying logic.
In an interview. If asked why Cloneable is disliked: the interface has no clone method, Object.clone is protected and shallow, it bypasses constructors, and it does not play well with final fields. Prefer a copy constructor or a copy() method.
Singleton
The problem. Some things should exist exactly once per process — a configuration snapshot, a registry, a connection pool — and every part of the code needs to reach the same one.
The idea. The class controls its own construction: a private constructor and one shared instance. Like a country's central bank — there is exactly one, and everyone knows how to reach it.
classDiagram
class FxRateCache {
-INSTANCE FxRateCache$
-FxRateCache()
+getInstance() FxRateCache$
+rate(Currency from, Currency to) BigDecimal
}
In Java. Four thread-safe options, from simplest to most robust.
// 1. Eager: created when the class is loaded. Simple and thread-safe; fine if construction is cheap.
final class FxRateCache {
private static final FxRateCache INSTANCE = new FxRateCache();
private FxRateCache() {}
static FxRateCache getInstance() { return INSTANCE; }
}
// 2. Double-checked locking: lazy, locks only on first creation. 'volatile' is essential.
final class AuditLog {
private static volatile AuditLog instance;
private AuditLog() {}
static AuditLog getInstance() {
AuditLog local = instance;
if (local == null) {
synchronized (AuditLog.class) {
local = instance;
if (local == null) instance = local = new AuditLog();
}
}
return local;
}
}
// 3. Holder idiom: lazy and lock-free. The JVM loads Holder (and builds the instance) on first use.
final class HolidayCalendar {
private HolidayCalendar() {}
private static final class Holder { static final HolidayCalendar INSTANCE = new HolidayCalendar(); }
static HolidayCalendar getInstance() { return Holder.INSTANCE; }
}
// 4. Enum: the JVM guarantees one instance, even against reflection and deserialisation.
enum IdGenerator {
INSTANCE;
private final AtomicLong next = new AtomicLong();
long nextId() { return next.incrementAndGet(); }
}
Without volatile in option 2, another thread could see a non-null reference to an object whose constructor has not finished. The enum form is the most robust: Constructor.newInstance refuses to create enum instances, and deserialising an enum returns the existing constant rather than a new object.
Why Spring beans usually replace it. A Spring bean's default scope is called singleton, but it means one instance per application context, created and injected by the container. The class itself stays an ordinary class with a public constructor, so tests can create their own instance or a mock. You get "only one" without the global access point.
The testability cost. A hand-rolled singleton is global state. Callers reach it through a static getInstance(), which hides the dependency from the constructor, cannot be replaced by a normal mock (you need static mocking), and leaks state from one test into the next.
A singleton — including a Spring singleton bean — is shared by every thread; any mutable field in it must be thread-safe, or the bean must hold no request state at all.
Where you have seen it. Runtime.getRuntime(), java.awt.Desktop.getDesktop(). Spring's singleton-scoped beans (one per container, not a GoF singleton).
Use it when / avoid it when.
- Use it when there is truly one of something per process and no container to manage it — a small library, a CLI tool. Prefer the enum or holder form.
- Avoid it when you have a DI container (use a singleton-scoped bean), or when "only one" is really "only one for now".
In an interview. A frequent follow-up is "is your singleton unique across the cluster?" No — it is one per class loader in one JVM. Ten instances of the service mean ten singletons. If you need exactly one active worker across a cluster, that is leader election with a lease and fencing token (R8), not a design pattern.
What's being probed: whether you know the cost of global state, not just the syntax.
Concept — Singleton vs singleton scope. A GoF singleton enforces "one" inside the class and is reached statically; a Spring singleton bean is "one" because the container builds it once and hands it to whoever needs it. Like a company with one CFO who is introduced to each team, versus a CFO whose phone number is painted on every wall.
Takeaway: in a Spring codebase, say "singleton-scoped bean, constructor-injected" and mention the enum form only if asked for a hand-rolled one.
Creational patterns at a glance
| Pattern | Problem it solves | One-line Java hint |
|---|---|---|
| Simple factory | One place decides which class to instantiate | static PaymentGateway forMethod(PaymentMethod m) { return switch (m) { ... }; } |
| Factory Method | Base workflow, subclass picks the product | protected abstract StatementWriter createWriter(OutputStream out); |
| Abstract Factory | Families of related objects must match | interface RailFactory { AccountValidator validator(); MessageBuilder builder(); } |
| Builder | Many optional fields, validate before creation | PaymentRequest.builder(key, amount).payee(vpa).build() |
| Prototype | Copying a configured object is cheaper than building one | registry.get("SALARY").copy() with a deep-copying constructor |
| Singleton | Exactly one instance per process | enum IdGenerator { INSTANCE; } — or a Spring singleton bean |
Structural patterns
Structural patterns are about how objects are put together: mostly, one object wrapping or containing another. Several of them look identical in code — a class that implements an interface and holds a reference to something else — and differ only in why the wrapper exists. With structural patterns, name the intent, because the code shape alone does not tell them apart.
Adapter
The problem. You have a component with a useful but incompatible interface — a legacy card switch, a vendor SDK — and your code expects a different one.
The idea. Write a small class that implements the interface you want and translates each call into the one the component understands. Like a travel plug adapter: the socket and the plug stay as they are; the adapter sits between them.
classDiagram
class PaymentGateway {
<<interface>>
+charge(ChargeRequest req) ChargeResult
}
class LegacySwitchAdapter {
-legacy LegacyCardSwitch
+charge(ChargeRequest req) ChargeResult
}
class LegacyCardSwitch {
+authorise(String token, String amount, String currencyNum) String
}
PaymentGateway <|.. LegacySwitchAdapter
LegacySwitchAdapter --> LegacyCardSwitch : delegates
CheckoutService --> PaymentGateway
In Java. An older card switch takes the amount as a rupee string and the currency as an ISO 4217 numeric code, and replies with an ISO 8583-style response code where "00" means approved and "51" means insufficient funds. The rest of the system speaks Money and enums.
// The interface the application owns
interface PaymentGateway {
ChargeResult charge(ChargeRequest req);
}
// The incompatible component we cannot change
class LegacyCardSwitch {
String authorise(String cardToken, String amount, String currencyNumeric) { /* ... */ return "00"; }
}
final class LegacySwitchAdapter implements PaymentGateway {
private final LegacyCardSwitch legacy;
LegacySwitchAdapter(LegacyCardSwitch legacy) { this.legacy = legacy; }
@Override
public ChargeResult charge(ChargeRequest req) {
String amount = BigDecimal.valueOf(req.amount().minorUnits())
.movePointLeft(2).toPlainString(); // 49900 -> "499.00"
String currency = String.valueOf(req.amount().currency().getNumericCode()); // INR -> "356"
String code = legacy.authorise(req.cardToken(), amount, currency);
return switch (code) {
case "00" -> ChargeResult.approved(req.id());
case "51" -> ChargeResult.declined(req.id(), DeclineReason.INSUFFICIENT_FUNDS);
default -> ChargeResult.declined(req.id(), DeclineReason.OTHER);
};
}
}
Every provider-specific detail — formats, codes, quirks — stays inside the adapter. In domain-driven design this boundary is called an anti-corruption layer: the provider's model never leaks into yours.
Where you have seen it. Arrays.asList(array) makes an array look like a List. InputStreamReader makes a byte InputStream look like a character Reader. Collections.list(enumeration) adapts the old Enumeration to a List. In Spring MVC, a HandlerAdapter lets the DispatcherServlet invoke very different handler types through one interface; MessageListenerAdapter lets a plain object receive JMS messages.
Use it when / avoid it when.
- Use it when integrating anything you do not control: payment providers, bank files, legacy services, third-party SDKs.
- Avoid it when you own both sides — change one interface instead of adding a translation layer.
In an interview. Say: "Each provider gets an adapter behind our PaymentGateway interface, so adding a provider is one new class and the checkout code never sees provider formats." A common follow-up is the difference between a class adapter (inherit from the adaptee) and an object adapter (hold it); in Java prefer the object adapter, since you can only extend one class.
Bridge
The problem. A class varies along two independent dimensions — say, what notification you send and which channel sends it — and subclassing for every combination multiplies classes.
The idea. Split the two dimensions into two hierarchies and let one hold a reference to the other. Like a TV remote and the TV: any remote can work with any TV through an agreed signal, so you do not need a special remote for each model.
classDiagram
class Notification {
<<abstract>>
#channel Channel
+send(Customer c)
}
class PaymentReceipt
class OtpAlert
class FraudAlert
class Channel {
<<interface>>
+deliver(Customer to, String subject, String body)
}
class SmsChannel
class EmailChannel
class PushChannel
Notification <|-- PaymentReceipt
Notification <|-- OtpAlert
Notification <|-- FraudAlert
Channel <|.. SmsChannel
Channel <|.. EmailChannel
Channel <|.. PushChannel
Notification o-- Channel : bridge
In Java. Three notification types and three channels would be nine subclasses (SmsPaymentReceipt, EmailOtpAlert ...). With a bridge it is six classes, and a fourth channel is one more class, not three.
interface Channel {
void deliver(Customer to, String subject, String body);
}
final class SmsChannel implements Channel {
public void deliver(Customer to, String subject, String body) { smsApi.send(to.mobile(), body); }
}
final class EmailChannel implements Channel {
public void deliver(Customer to, String subject, String body) { mailer.send(to.email(), subject, body); }
}
abstract class Notification {
protected final Channel channel;
protected Notification(Channel channel) { this.channel = channel; }
abstract void send(Customer c);
}
final class PaymentReceipt extends Notification {
private final Money amount;
private final String merchant;
PaymentReceipt(Channel channel, Money amount, String merchant) {
super(channel); this.amount = amount; this.merchant = merchant;
}
void send(Customer c) {
channel.deliver(c, "Payment successful", "Paid " + amount + " to " + merchant);
}
}
new PaymentReceipt(new SmsChannel(), Money.inr(49_900), "Book Store").send(customer);
Where you have seen it. JDBC: application code is written against the java.sql API, and each vendor's driver supplies the implementation; the two sides evolve independently. SLF4J has the same split: one logging API, several backends (Logback, Log4j 2) plugged in underneath.
Use it when / avoid it when.
- Use it when you can name two dimensions that genuinely vary independently and both will grow.
- Avoid it when only one dimension varies — that is just an interface with implementations.
In an interview. A common follow-up: "Isn't this just Strategy?" The shape is similar. Bridge is a structural decision made up front to split a hierarchy in two, and both sides are long-lived; Strategy swaps an algorithm, often at runtime. And unlike Adapter, which is retrofitted to make existing things fit, Bridge is designed in from the start.
Composite
The problem. You have a tree — accounts inside sub-groups inside groups — and callers want to treat a single item and a whole group the same way ("what is your balance?").
The idea. Leaves and groups implement one interface; a group's implementation simply asks each child and combines the answers. Like a company org chart: ask a department for its headcount and it asks each team, which asks each person.
classDiagram
class AccountNode {
<<interface>>
+name() String
+balance() Money
}
class BankAccount {
-balance Money
+balance() Money
}
class AccountGroup {
-children List
+add(AccountNode n)
+balance() Money
}
AccountNode <|.. BankAccount
AccountNode <|.. AccountGroup
AccountGroup o-- AccountNode : children
In Java. A corporate client's treasury view: "Acme Group" contains "Acme India" (which contains three accounts) and one escrow account.
interface AccountNode {
String name();
Money balance();
}
record BankAccount(String name, Money balance) implements AccountNode {}
final class AccountGroup implements AccountNode {
private final String name;
private final List<AccountNode> children = new ArrayList<>();
AccountGroup(String name) { this.name = name; }
AccountGroup add(AccountNode child) { children.add(child); return this; }
public String name() { return name; }
public Money balance() {
return children.stream()
.map(AccountNode::balance) // leaf or group — same call
.reduce(Money.inr(0), Money::plus);
}
}
AccountNode acme = new AccountGroup("Acme Group")
.add(new AccountGroup("Acme India")
.add(new BankAccount("Operating", Money.inr(12_00_000_00)))
.add(new BankAccount("Payroll", Money.inr(4_50_000_00)))
.add(new BankAccount("Tax", Money.inr(80_000_00))))
.add(new BankAccount("Escrow", Money.inr(25_00_000_00)));
Money total = acme.balance();
This assumes one currency; a multi-currency group would convert each child's balance through an FX service before adding.
Where you have seen it. java.awt.Container is itself a Component and holds other components, so a whole panel can be laid out or painted like one widget. In Spring, CompositeCacheManager tries several cache managers in turn, and CompositePropertySource combines several property sources into one.
Use it when / avoid it when.
- Use it when the domain is genuinely a tree — account hierarchies, org units, product bundles, nested menus, expression trees.
- Avoid it when leaves and groups need very different operations; forcing one interface leads to methods that make no sense on half the nodes.
In an interview. The classic follow-up is "where do add and remove go?" Put them on the shared interface and leaves must throw (uniform but breaks Liskov); put them only on the group and callers must know which they hold (safe but less uniform). Prefer the safe version, as above. Also mention guarding against cycles and caching totals for very large trees.
Decorator
The problem. You want to add behaviour — retries, logging, metrics — to an object without changing its class, and you want to mix and match those additions.
The idea. A decorator implements the same interface as the object it wraps, does its extra work, and forwards the call. Decorators can be stacked in any order. Like adding a phone case, then a screen guard, then a grip: each layer adds something, and it is still a phone.
classDiagram
class PspClient {
<<interface>>
+charge(ChargeRequest req) ChargeResult
}
class HttpPspClient
class RetryingPspClient {
-delegate PspClient
}
class MeteredPspClient {
-delegate PspClient
}
class LoggingPspClient {
-delegate PspClient
}
PspClient <|.. HttpPspClient
PspClient <|.. RetryingPspClient
PspClient <|.. MeteredPspClient
PspClient <|.. LoggingPspClient
RetryingPspClient o-- PspClient
MeteredPspClient o-- PspClient
LoggingPspClient o-- PspClient
In Java.
interface PspClient {
ChargeResult charge(ChargeRequest req);
}
final class RetryingPspClient implements PspClient {
private final PspClient delegate;
private final int maxAttempts;
RetryingPspClient(PspClient delegate, int maxAttempts) { this.delegate = delegate; this.maxAttempts = maxAttempts; }
public ChargeResult charge(ChargeRequest req) {
for (int attempt = 1; ; attempt++) {
try {
return delegate.charge(req); // same idempotency key on every attempt
} catch (PspTimeoutException e) {
if (attempt == maxAttempts) throw e;
sleep(100L << attempt); // simple exponential backoff
}
}
}
}
final class MeteredPspClient implements PspClient {
private final PspClient delegate;
private final LongAdder calls = new LongAdder(), failures = new LongAdder();
MeteredPspClient(PspClient delegate) { this.delegate = delegate; }
public ChargeResult charge(ChargeRequest req) {
calls.increment();
try { return delegate.charge(req); }
catch (RuntimeException e) { failures.increment(); throw e; }
}
}
final class LoggingPspClient implements PspClient {
private static final Logger log = LoggerFactory.getLogger(LoggingPspClient.class);
private final PspClient delegate;
LoggingPspClient(PspClient delegate) { this.delegate = delegate; }
public ChargeResult charge(ChargeRequest req) {
log.info("charge start id={} amount={}", req.id(), req.amount());
ChargeResult r = delegate.charge(req);
log.info("charge end id={} status={}", req.id(), r.status());
return r;
}
}
PspClient client = new LoggingPspClient(
new MeteredPspClient(
new RetryingPspClient(new HttpPspClient(config), 3)));
The order is a design decision: with metrics outside retry, three attempts count as one logical call; move it inside and you count every attempt. Both are useful; say which you want.
Retrying a charge is only safe if the provider deduplicates by an idempotency key; otherwise a timeout followed by a retry can debit the customer twice. R3 covers idempotency and R14 covers retries, backoff and circuit breakers.
Where you have seen it. java.io: new BufferedReader(new InputStreamReader(new FileInputStream(file))) — BufferedReader and BufferedInputStream are decorators that add buffering (while InputStreamReader in the middle is an adapter, from bytes to characters). Collections.unmodifiableList and Collections.synchronizedList wrap a list and change its behaviour behind the same interface. The Servlet API's HttpServletRequestWrapper, and Spring's ContentCachingRequestWrapper built on it. Spring's TransactionAwareCacheDecorator. Resilience4j's Retry.decorateSupplier(...) does the retry decorator for you.
Use it when / avoid it when.
- Use it when cross-cutting behaviour should be optional and combinable per use, especially around clients of external systems.
- Avoid it when the interface is wide — a decorator must forward every method, and twenty forwarding methods are a maintenance burden; Spring AOP (a proxy) handles that better.
In an interview. The one thing to say: "Decorators let me add retry, metrics and logging as separate classes and compose them, instead of one client class that knows about all three — and the order of wrapping is deliberate."
Facade
The problem. Completing one business action needs calls to many subsystems in the right order, and every caller is re-implementing that sequence.
The idea. Provide one simple entry point that runs the sequence; the subsystems stay available for anyone who needs finer control. Like a hotel concierge: you ask for "dinner and a taxi at eight", and the concierge calls the restaurant and the cab company.
classDiagram
class CheckoutFacade {
+placeOrder(Cart cart, PaymentMethod m) OrderConfirmation
}
CheckoutFacade --> InventoryService
CheckoutFacade --> PricingService
CheckoutFacade --> FraudService
CheckoutFacade --> PaymentService
CheckoutFacade --> OrderService
CheckoutFacade --> NotificationSender
CheckoutController --> CheckoutFacade
In Java.
@Service
public class CheckoutFacade {
private final InventoryService inventory;
private final PricingService pricing;
private final FraudService fraud;
private final PaymentService payments;
private final OrderService orders;
private final NotificationSender notifier;
// constructor injection omitted for brevity
public OrderConfirmation placeOrder(Cart cart, PaymentMethod method) {
Hold hold = inventory.hold(cart.items(), Duration.ofMinutes(10));
try {
Money total = pricing.total(cart);
fraud.check(cart.customerId(), total);
PaymentResult paid = payments.pay(cart.customerId(), total, method, cart.checkoutId());
Order order = orders.create(cart, hold, paid);
notifier.send(cart.customerId(), Message.orderPlaced(order));
return OrderConfirmation.of(order);
} catch (RuntimeException e) {
inventory.release(hold);
throw e;
}
}
}
The controller, the mobile API and the batch re-order job all call placeOrder, and none of them needs to know the order of steps. In a real distributed checkout this sequence becomes a saga with compensations (R5, and the Amazon walkthrough in R19); the facade is still the single entry point.
Where you have seen it. Spring's JdbcTemplate hides connections, statements, result sets, resource cleanup and exception translation behind calls like query and update. RestTemplate and RestClient do the same for HTTP. SLF4J is literally the Simple Logging Facade for Java.
Use it when / avoid it when.
- Use it when many callers need the same common path through a complex set of classes or services.
- Avoid it when the facade starts gaining business rules of its own and grows into a god class; it should coordinate, not decide.
In an interview. If asked how a facade differs from an adapter: an adapter converts one interface into another that a client already expects; a facade invents a new, simpler interface over many. At the network level, an API gateway or a backend-for-frontend (R1) plays a facade's role.
Flyweight
The problem. You hold millions of small objects that each carry the same heavy, unchanging data, and memory runs out.
The idea. Split the state in two: the intrinsic part that is identical across many objects (shared, immutable), and the extrinsic part that is unique to each (kept outside or passed in). Share one instance of each intrinsic value through a factory. Like a library: a thousand readers do not each own a dictionary; they share the copy on the shelf.
classDiagram
class Instrument {
+isin String
+name String
+exchange String
+lotSize int
+tickSizePaise long
}
class InstrumentRegistry {
-cache Map
+of(String isin) Instrument
}
class Trade {
+tradeId long
+instrument Instrument
+quantity long
+pricePaise long
}
InstrumentRegistry o-- Instrument : shares
Trade --> Instrument : references
In Java. A risk engine holds the day's trades in memory. Each trade refers to its instrument's metadata instead of copying it.
record Instrument(String isin, String name, String exchange, int lotSize, long tickSizePaise) {}
final class InstrumentRegistry {
private final Map<String, Instrument> cache = new ConcurrentHashMap<>();
private final InstrumentRepository repository;
InstrumentRegistry(InstrumentRepository repository) { this.repository = repository; }
Instrument of(String isin) {
return cache.computeIfAbsent(isin, repository::load); // one shared instance per ISIN
}
}
record Trade(long tradeId, Instrument instrument, long quantity, long pricePaise) {} // extrinsic state
Assumed numbers, to show why it matters:
Assume 10,000,000 trades in memory and about 5,000 distinct instruments.
Instrument metadata (strings plus fields) ~ 200 bytes per copy.
Without sharing: 10,000,000 x 200 B = 2,000,000,000 B ~ 2 GB of metadata
With sharing: 5,000 x 200 B = 1,000,000 B ~ 1 MB of metadata
+ 10,000,000 references x 4 B = 40,000,000 B ~ 40 MB (compressed pointers)
Therefore sharing cuts metadata memory from about 2 GB to about 41 MB.
Where you have seen it. Integer.valueOf(int) returns cached objects for values from -128 to 127 (similar caches exist for Short, Byte, Long and Character); Boolean.valueOf returns one of two shared objects. String literals are pooled, and String.intern() adds to the pool. java.util.Currency.getInstance("INR") always returns the same Currency object for a given code. Enum constants are the purest flyweights.
Comparing boxed numbers with == works for small values because of the cache and silently fails above 127 — always use equals for Integer, Long and money amounts.
Use it when / avoid it when.
- Use it when you have very many objects, a large shared part, and memory pressure you have actually measured.
- Avoid it when the shared part is mutable (sharing would spread every change), or object counts are modest — the factory adds a lookup and some complexity.
In an interview. Say: "Flyweights must be immutable, and the factory must be thread-safe — computeIfAbsent on a ConcurrentHashMap gives both." If the interviewer pushes on an unbounded cache, mention eviction or a bounded cache (R12).
Proxy
The problem. You need to control access to an object — check permissions, load it lazily, cache its results, or reach it over the network — without its callers knowing.
The idea. A proxy implements the same interface as the real object and stands in front of it, deciding whether, when and how to forward each call. Like a bank's security guard at the vault door: same vault, but not everyone gets through, and not at every hour.
classDiagram
class AccountService {
<<interface>>
+transfer(String from, String to, Money m) TransferResult
+statement(String accountId) Statement
}
class AccountServiceImpl
class SecuredAccountService {
-target AccountService
-entitlements EntitlementService
}
AccountService <|.. AccountServiceImpl
AccountService <|.. SecuredAccountService
SecuredAccountService --> AccountServiceImpl : forwards if allowed
PaymentsController --> AccountService
In Java. A protection proxy checks entitlements before any money moves:
final class SecuredAccountService implements AccountService {
private final AccountService target;
private final EntitlementService entitlements;
private final CurrentUser user;
SecuredAccountService(AccountService target, EntitlementService e, CurrentUser user) {
this.target = target; this.entitlements = e; this.user = user;
}
public TransferResult transfer(String from, String to, Money amount) {
if (!entitlements.canDebit(user.id(), from, amount))
throw new AccessDeniedException("user " + user.id() + " cannot debit " + from);
return target.transfer(from, to, amount);
}
public Statement statement(String accountId) {
if (!entitlements.canView(user.id(), accountId)) throw new AccessDeniedException(accountId);
return target.statement(accountId);
}
}
A virtual proxy delays an expensive load until it is first needed:
final class LazyStatement implements Statement {
private final Supplier<Statement> loader;
private Statement real; // loaded on first use
LazyStatement(Supplier<Statement> loader) { this.loader = loader; }
public List<Txn> transactions() {
if (real == null) real = loader.get(); // not thread-safe; guard it if shared
return real.transactions();
}
}
Writing a proxy class per interface gets tedious, so Java can generate them at runtime. This is, in miniature, how Spring adds behaviour around your beans:
@SuppressWarnings("unchecked")
static <T> T timed(T target, Class<T> iface) {
return (T) Proxy.newProxyInstance(iface.getClassLoader(), new Class<?>[]{iface},
(proxy, method, args) -> {
long start = System.nanoTime();
try { return method.invoke(target, args); }
finally { log.info("{} took {} us", method.getName(), (System.nanoTime() - start) / 1_000); }
});
}
Where you have seen it. Spring AOP: @Transactional, @Cacheable, @Async and @PreAuthorize work because Spring injects a proxy instead of your bean — a JDK dynamic proxy (java.lang.reflect.Proxy) for interfaces, or a CGLIB-generated subclass; Spring Boot defaults to CGLIB. Hibernate returns proxies for lazy associations and from getReference, loading the row only when a field is touched. RMI stubs are remote proxies.
A call from one method of a bean to another method of the same bean (this.save()) does not go through the proxy, so @Transactional or @Cacheable on the inner method is silently ignored. Also, CGLIB cannot override final methods, so annotations on them have no effect.
Use it when / avoid it when.
- Use it when access needs a gate (security, rate limits), a delay (lazy loading), a shortcut (caching) or a hop (remote calls), and callers should not care.
- Avoid it when the hidden behaviour surprises callers — a proxy that turns a cheap-looking getter into a network call causes the N+1 query problem (R11).
In an interview. The common follow-up is "Proxy vs Decorator — same code, what is the difference?" A decorator adds behaviour and is usually stacked explicitly by the client; a proxy controls access to the subject, often manages its lifecycle (creating it lazily, or reaching it remotely), and the client usually does not know it is there.
What's being probed: whether you understand what Spring does to your beans at runtime.
Concept — Proxy-based AOP. Spring wraps each annotated bean in a proxy; the proxy runs the extra behaviour (open a transaction, check the cache) and then calls your method. Like a receptionist who signs in every visitor before showing them to your desk: visitors who are already inside the building, walking from desk to desk, are never signed in. That is why self-invocation skips the advice.
Takeaway: annotations such as
@Transactionalonly apply to calls that come in through the proxy — from another bean.
Four wrappers that look the same
All four hold a reference to another object and forward calls. The intent is what differs.
| Pattern | Interface it exposes | Why it exists | Typical example |
|---|---|---|---|
| Adapter | A different interface from the wrapped object | Make an incompatible thing fit | Legacy card switch behind PaymentGateway |
| Decorator | The same interface | Add behaviour, stackable | Retry, metrics and logging around a PSP client |
| Proxy | The same interface | Control access or lifecycle | Entitlement check, lazy loading, Spring @Transactional |
| Facade | A new, simpler interface over many objects | Hide a multi-step sequence | CheckoutFacade.placeOrder over six services |
Structural patterns at a glance
| Pattern | Problem it solves | One-line Java hint |
|---|---|---|
| Adapter | An existing class has the wrong interface | class LegacySwitchAdapter implements PaymentGateway { LegacyCardSwitch legacy; } |
| Bridge | Two dimensions vary independently | abstract class Notification { protected final Channel channel; } |
| Composite | Treat one item and a tree of items alike | AccountGroup.balance() sums children.balance() |
| Decorator | Add optional, combinable behaviour | new LoggingPspClient(new RetryingPspClient(http, 3)) |
| Facade | Simplify a multi-step subsystem | checkoutFacade.placeOrder(cart, method) |
| Flyweight | Too many objects carrying the same data | cache.computeIfAbsent(isin, repository::load) |
| Proxy | Control access, loading or location | Proxy.newProxyInstance(...) — or let Spring AOP do it |
Behavioural patterns
The creational and structural patterns earlier in this section decide how objects are made and how they fit together. Once the objects exist, they have to share work: one decides, another acts, a third needs to hear about it. Behavioural patterns are named ways of dividing that work. Each one answers three questions: who makes which decision, who knows about whom, and what can change without touching the rest.
Most of them make the same move. They take a decision or an action that would otherwise be an if/else buried inside a method, and turn it into an object. A behavioural pattern turns a decision or an action into an object, so it can be swapped, queued, chained or undone without editing the code that uses it.
The eleven patterns below fall into five families. Reading them in this order, each one builds on an idea from the one before:
| Family | Patterns | The shared idea |
|---|---|---|
| Passing work along | Chain of Responsibility, Command | A request becomes something you can hand on, store or replay |
| Walking a structure | Interpreter, Iterator | Code moves through a set of objects without knowing how they are stored |
| Coordinating many objects | Mediator, Memento, Observer | Objects stop holding direct references to each other |
| Changing behaviour | State, Strategy, Template Method | The varying part of an algorithm lives in its own class |
| Adding operations | Visitor | New operations are added to a fixed set of types without editing them |
Every example is in Java 17 and uses money as long paise, as the rest of the pack does.
Chain of Responsibility
The problem. A request must pass several checks or handlers, and the set and order of those handlers changes over time. Writing them as one long method means every new rule edits the same code.
The idea. Put each handler in its own object and link them in a line. Each handler either deals with the request, rejects it, or passes it to the next one. The sender only knows the first link. Think of a bank loan application: the branch officer approves small amounts, passes bigger ones to the regional manager, who passes the biggest to the credit committee. You hand your form to the officer and never need to know who signs it.
There are two common flavours, and it helps to name which one you mean:
- Stop at the first handler that takes it — the loan example; exactly one handler deals with the request.
- Every handler may reject or pass — a validation pipeline; the request succeeds only if it gets through every link. Servlet filters work this way.
classDiagram
class PaymentCheck {
<<abstract>>
-next PaymentCheck
+then(next) PaymentCheck
+check(payment) CheckResult
#doCheck(payment) CheckResult
}
PaymentCheck <|-- FormatCheck
PaymentCheck <|-- SanctionsCheck
PaymentCheck <|-- LimitCheck
PaymentCheck <|-- FraudCheck
class PaymentService
PaymentService --> PaymentCheck : first link
In Java. Before a payment is accepted it goes through format, sanctions, daily limit and fraud checks. The first rejection stops the chain.
public record Payment(String id, String debtorAccount, String creditorAccount,
long amountPaise, String currency, String channel) {}
public record CheckResult(boolean ok, String code, String reason) {
public static CheckResult pass() { return new CheckResult(true, null, null); }
public static CheckResult reject(String code, String why) { return new CheckResult(false, code, why); }
}
public abstract class PaymentCheck {
private PaymentCheck next;
public PaymentCheck then(PaymentCheck next) { // returns the new link so calls can be chained
this.next = next;
return next;
}
public final CheckResult check(Payment p) {
CheckResult result = doCheck(p);
if (!result.ok() || next == null) return result; // stop on the first rejection
return next.check(p);
}
protected abstract CheckResult doCheck(Payment p);
}
final class FormatCheck extends PaymentCheck {
protected CheckResult doCheck(Payment p) {
if (p.amountPaise() <= 0) return CheckResult.reject("FORMAT", "amount must be positive");
if (!"INR".equals(p.currency())) return CheckResult.reject("FORMAT", "currency not supported");
return CheckResult.pass();
}
}
final class LimitCheck extends PaymentCheck {
private final LimitService limits;
LimitCheck(LimitService limits) { this.limits = limits; }
protected CheckResult doCheck(Payment p) {
long remaining = limits.remainingTodayPaise(p.debtorAccount());
return p.amountPaise() <= remaining
? CheckResult.pass()
: CheckResult.reject("LIMIT", "daily limit exceeded");
}
}
// SanctionsCheck and FraudCheck follow the same shape.
PaymentCheck chain = new FormatCheck();
chain.then(new SanctionsCheck(sanctionsList))
.then(new LimitCheck(limitService))
.then(new FraudCheck(fraudScorer)); // the slowest check runs last
CheckResult result = chain.check(payment);
Order the links cheapest first. A malformed payment should be rejected by a string check, not after a call to a fraud-scoring model.
In a Spring service you rarely hand-link the chain. You inject every rule as a list and loop. Spring sorts an injected List by @Order, so the order lives next to each rule:
public interface PaymentRule { CheckResult check(Payment p); }
@Component @Order(10) class FormatRule implements PaymentRule { /* ... */ }
@Component @Order(40) class FraudRule implements PaymentRule { /* ... */ }
@Service
public class PaymentValidator {
private final List<PaymentRule> rules; // every PaymentRule bean, sorted by @Order
public PaymentValidator(List<PaymentRule> rules) { this.rules = rules; }
public CheckResult validate(Payment p) {
for (PaymentRule rule : rules) {
CheckResult r = rule.check(p);
if (!r.ok()) return r;
}
return CheckResult.pass();
}
}
Adding a new rule is now a new class and nothing else, which is the open/closed principle at work.
Where you have seen it.
javax.servlet.Filter(nowjakarta.servlet.Filter): each filter does its work, then callschain.doFilter(request, response)to pass on — or does not call it, and the request stops there.- Spring Security's
SecurityFilterChain: authentication, CSRF, authorisation and others are filters in one ordered chain. - Netty's
ChannelPipelinepasses inbound and outbound events through a line of handlers. java.util.logging.Loggerpasses a log record up to its parent loggers' handlers.
Use it when:
- Several independent checks or handlers apply to one request, and the list changes as rules are added.
- You want to reorder, enable or disable steps by configuration.
- The sender should not know which handler finally deals with the request.
Avoid it when:
- There are two fixed checks that never change. Two
ifstatements are clearer. - Every handler must always run and none can stop the flow. That is a plain loop over a list, not a chain.
- Debugging matters more than flexibility and nobody can tell which link swallowed a request. Log the link that decided.
In an interview. Say which flavour you mean — first handler wins, or every handler may veto — and say how you order the links. A common follow-up is "what if a request falls off the end with nobody handling it?" The answer: in the first-handler flavour, put a default handler at the end that rejects or logs, so nothing is silently dropped.
Command
The problem. You want to queue an action, run it later, retry it, log it or undo it. A plain method call cannot do any of that: once it is called, it is gone.
The idea. Wrap the action and everything it needs into an object with one method, execute(). Now the action is data: you can put it in a queue, write it to a log, send it to another thread, and keep it on a history stack to undo it. A restaurant order slip is a command. The waiter writes it, pins it on the rail, and the cook executes it when free. The slip can be queued, reordered or torn up, and the waiter never needs to know how to cook.
Chain of Responsibility passed a request along a line of handlers. Command goes one step further and turns the request itself into a thing you can keep.
classDiagram
class LedgerCommand {
<<interface>>
+key() String
+execute(ledger)
+inverse() LedgerCommand
}
class PostTransfer {
+key String
+from String
+to String
+amountPaise long
}
class LedgerProcessor {
-queue
-history
+submit(command)
+drain()
+undoLast()
}
class Ledger {
+transfer(key, from, to, amount)
}
LedgerCommand <|.. PostTransfer
LedgerProcessor --> LedgerCommand : queues and runs
PostTransfer --> Ledger : acts on
In Java. Ledger postings are submitted as commands, applied by a single consumer, and undone by posting their inverse.
public interface LedgerCommand {
String key(); // idempotency key: the same command applied twice is applied once
void execute(Ledger ledger);
LedgerCommand inverse(); // the compensating command
}
public record PostTransfer(String key, String from, String to, long amountPaise)
implements LedgerCommand {
public void execute(Ledger ledger) {
ledger.transfer(key, from, to, amountPaise); // debit and credit in one DB transaction
}
public LedgerCommand inverse() {
return new PostTransfer(key + ":reversal", to, from, amountPaise);
}
}
public final class LedgerProcessor {
private final BlockingQueue<LedgerCommand> queue = new LinkedBlockingQueue<>();
private final Deque<LedgerCommand> history = new ArrayDeque<>();
private final Set<String> applied = new HashSet<>();
private final Ledger ledger;
public LedgerProcessor(Ledger ledger) { this.ledger = ledger; }
public void submit(LedgerCommand command) { queue.add(command); }
public void drain() { // called by one consumer thread
LedgerCommand c;
while ((c = queue.poll()) != null) {
if (!applied.add(c.key())) continue; // duplicate submission: skip
c.execute(ledger);
history.push(c);
}
}
public void undoLast() {
if (history.isEmpty()) return;
submit(history.pop().inverse()); // never delete an entry: post its reversal
}
}
Two details are worth saying out loud. First, the key makes execute idempotent, which matters the moment commands are retried (R3). Second, undo in a ledger is never a delete. You post a reversing entry, which is exactly the compensation step of a saga (R5). In production the queue is a durable table or a Kafka topic (R9), and the applied set is a unique constraint on the key, not an in-memory HashSet.
Where you have seen it.
RunnableandCallable: a unit of work wrapped as an object and handed to anExecutorService, which queues it and runs it on some thread later.java.util.TimerandScheduledExecutorServiceschedule aRunnableto run at a time.- Swing's
Actioninterface: one command object shared by a menu item, a toolbar button and a keyboard shortcut. - Every message on a queue that says "do X" — a
CapturePaymentmessage on Kafka is a command sent across the network.
Use it when:
- An action must be queued, scheduled, retried or run on another thread.
- You need undo and redo, or an audit log of exactly what was done.
- Many triggers (a button, an API call, a batch job) should run the same action.
Avoid it when:
- The action runs once, immediately, in the same place. A method call is enough.
- Undo is impossible in the real world — a sent SMS cannot be unsent. Do not pretend with an
undo()that lies; model a follow-up action instead.
In an interview. The follow-up is almost always "how do you undo?" Give the two options: store the inverse command (as above), or store a snapshot of the state before the command ran (Memento, below). For money, say that undo is a compensating entry, never a deletion, and that commands must be idempotent because queues redeliver.
Interpreter
The problem. Business users want to write simple rules — "flag UPI payments over ₹10,000" — without a code release each time.
The idea. Define a tiny language, represent each rule as a tree of objects, and give every node an evaluate method. A leaf compares one field; an And node evaluates its two children. A recipe card is a small language: "if the dough is sticky, add flour" is a rule the cook interprets each time.
classDiagram
class Rule {
<<interface>>
+matches(context) boolean
}
Rule <|.. GreaterThan
Rule <|.. EqualTo
Rule <|.. And
And --> Rule : left and right
In Java.
public sealed interface Rule {
boolean matches(Map<String, Object> ctx);
}
record GreaterThan(String field, long value) implements Rule {
public boolean matches(Map<String, Object> ctx) {
return ((Number) ctx.get(field)).longValue() > value;
}
}
record EqualTo(String field, String value) implements Rule {
public boolean matches(Map<String, Object> ctx) { return value.equals(ctx.get(field)); }
}
record And(Rule left, Rule right) implements Rule {
public boolean matches(Map<String, Object> ctx) { return left.matches(ctx) && right.matches(ctx); }
}
// amountPaise > 10,00,000 (that is, ₹10,000) AND channel == "UPI"
Rule highValueUpi = new And(new GreaterThan("amountPaise", 1_000_000), new EqualTo("channel", "UPI"));
boolean flag = highValueUpi.matches(Map.of("amountPaise", 2_500_000L, "channel", "UPI")); // true
A parser that turns the text amountPaise > 1000000 AND channel = 'UPI' into this tree is the other half of the pattern. It is usually the harder half.
Where you have seen it. Spring Expression Language (SpEL) parses expressions such as @PreAuthorize("hasRole('ADMIN')") into a tree of nodes and evaluates them. java.util.regex.Pattern compiles a regular expression into an internal tree of matcher nodes.
Use it when the language is genuinely small and stable. Avoid it when the grammar grows: then use an existing engine (SpEL, a rules engine such as Drools, or a scripting language) rather than writing your own parser.
In an interview. It rarely comes up in LLD rounds. If it does, say you would reuse SpEL or a rules engine before hand-writing a grammar — that is the senior answer.
Iterator
The problem. Callers need to walk through every item in a collection, but the collection might be an array, a tree, a database cursor or a paginated API. Each has a different way of getting "the next one".
The idea. Give every collection the same two questions: hasNext() and next(). The caller walks the items without knowing how they are stored or fetched. A TV remote's "next channel" button is an iterator: you do not need to know the frequency of each channel, only that pressing the button gets you the next one.
Interpreter walked a tree of rule objects. Iterator generalises the walk: any structure, one uniform way through it.
classDiagram
class Iterator {
<<interface>>
+hasNext() boolean
+next() T
}
class PagedIterator {
-fetch
-current
-cursor
-lastPageFetched
+hasNext() boolean
+next() T
}
class TransactionsApi {
+list(account, cursor) Page
}
Iterator <|.. PagedIterator
PagedIterator --> TransactionsApi : fetches next page
In Java. A bank's transactions API returns 100 items per page with a cursor to the next page. The caller should simply loop over all transactions.
public record Page<T>(List<T> items, String nextCursor) {} // nextCursor == null on the last page
public final class PagedIterator<T> implements Iterator<T> {
private final Function<String, Page<T>> fetch; // cursor -> page; null cursor = first page
private Iterator<T> current = Collections.emptyIterator();
private String cursor;
private boolean lastPageFetched;
public PagedIterator(Function<String, Page<T>> fetch) { this.fetch = fetch; }
@Override
public boolean hasNext() {
while (!current.hasNext() && !lastPageFetched) { // loop also skips empty pages
Page<T> page = fetch.apply(cursor);
current = page.items().iterator();
cursor = page.nextCursor();
lastPageFetched = (cursor == null);
}
return current.hasNext();
}
@Override
public T next() {
if (!hasNext()) throw new NoSuchElementException();
return current.next();
}
}
// The caller neither knows nor cares about pages.
Iterable<Txn> all = () -> new PagedIterator<>(cursor -> api.list(accountId, cursor));
for (Txn t : all) {
ledgerImporter.importOne(t);
}
long creditsPaise = StreamSupport.stream(all.spliterator(), false)
.filter(Txn::isCredit)
.mapToLong(Txn::amountPaise)
.sum();
Pages are fetched lazily, one at a time, so memory stays flat even for a million transactions. Prefer a cursor over page numbers: if new transactions arrive while you read, page numbers shift and you skip or repeat rows; a cursor does not (R11 covers keyset pagination).
Where you have seen it.
java.util.IteratorandIterable. AnyIterableworks in a for-each loop.SpliteratorandStream: the iterator idea, extended to split work across threads.java.util.ScannerimplementsIterator<String>.java.sql.ResultSet'snext()is a cursor in the same spirit.- Spring Data's
SliceandPage, and the AWS SDK v2 paginators, which hide page tokens behind anIterable.
Use it when:
- The storage of a collection should be hidden, or may change.
- The data is large or remote and should be fetched lazily.
- You need several independent walks over the same data at once.
Avoid it when:
- It is a
Listalready. Use the built-in iterator; do not write your own. - Callers need random access by index. An iterator only goes forward.
In an interview. A good follow-up: "what happens if the collection changes during iteration?" For JDK collections, ArrayList's iterator is fail-fast: it throws ConcurrentModificationException. CopyOnWriteArrayList's iterator works on a snapshot and never throws. For a paginated API, say cursor-based pagination gives a stable walk.
Mediator
The problem. Many objects need to react to each other. If each holds references to all the others, the number of links grows with the square of the objects, and changing one means touching many.
The idea. Put one object in the middle. Everyone talks only to the mediator, and the mediator decides who hears what. An auctioneer is a mediator. Bidders do not shout at each other; they raise a paddle, and the auctioneer accepts the bid, announces the new price and tells the previous leader they have been outbid. An airport control tower does the same for planes.
classDiagram
class AuctionMediator {
<<interface>>
+join(bidder)
+placeBid(bidder, amount)
}
class AuctionRoom {
-bidders
-leader
-highestPaise
+close() Bidder
}
class Bidder {
+bid(amount)
+onLeading(amount)
+onOutbid(amount)
+onRejected(reason)
}
AuctionMediator <|.. AuctionRoom
Bidder --> AuctionMediator : talks only to
AuctionRoom --> Bidder : notifies
In Java. An online auction for a repossessed asset. The auction room holds all the rules; the bidders hold none.
public interface AuctionMediator {
void join(Bidder bidder);
void placeBid(Bidder bidder, long amountPaise);
}
public class Bidder {
private final String name;
private final AuctionMediator auction;
public Bidder(String name, AuctionMediator auction) { this.name = name; this.auction = auction; }
public void bid(long amountPaise) { auction.placeBid(this, amountPaise); }
public void onLeading(long amountPaise) { System.out.println(name + " leads at " + amountPaise); }
public void onOutbid(long newHighPaise) { System.out.println(name + " outbid at " + newHighPaise); }
public void onNewHighBid(long highPaise) { /* update this bidder's screen */ }
public void onRejected(String reason) { System.out.println(name + " rejected: " + reason); }
}
public final class AuctionRoom implements AuctionMediator {
private final List<Bidder> bidders = new ArrayList<>();
private final long minIncrementPaise;
private Bidder leader;
private long highestPaise;
private boolean closed;
public AuctionRoom(long reservePaise, long minIncrementPaise) {
this.highestPaise = reservePaise - minIncrementPaise; // first valid bid is the reserve
this.minIncrementPaise = minIncrementPaise;
}
public synchronized void join(Bidder b) { bidders.add(b); }
public synchronized void placeBid(Bidder b, long amountPaise) {
if (closed) { b.onRejected("auction closed"); return; }
if (amountPaise < highestPaise + minIncrementPaise) { b.onRejected("bid too low"); return; }
Bidder previous = leader;
leader = b;
highestPaise = amountPaise;
b.onLeading(amountPaise);
if (previous != null && previous != b) previous.onOutbid(amountPaise);
for (Bidder other : bidders) {
if (other != b && other != previous) other.onNewHighBid(amountPaise);
}
}
public synchronized Optional<Bidder> close() {
closed = true;
return Optional.ofNullable(leader);
}
}
All the rules — minimum increment, reserve price, closing — live in one class. A new rule, such as extending the auction by two minutes when a bid lands in the final minute, changes AuctionRoom alone. synchronized makes the bid check and the update one atomic step, which is the in-memory version of R19's conditional update.
Where you have seen it.
- Spring MVC's
DispatcherServletacts as a mediator: handler mappings, handler adapters, view resolvers and exception resolvers never call each other; the dispatcher coordinates them. - A chat server: clients send to the room, and the room fans out to members (R18's WhatsApp design is this idea at scale).
- An orchestrator in a saga (R5) mediates between services that never call each other directly.
Use it when:
- Many objects interact in tangled ways and the rules for their interaction keep changing.
- You want to reuse the participants in other settings without dragging their links along.
Avoid it when:
- Only two or three objects talk. A mediator adds a class for no gain.
- The mediator starts to know everything and do everything. A "god object" in the middle is just the tangle moved to one file. Split it by concern.
In an interview. Expect "how is this different from Observer?" Observer is one-to-many broadcasting: the subject does not care who listens or what they do. Mediator is many-to-many coordination: the mediator holds the rules and decides who should be told what. In practice a mediator often uses observer-style callbacks to notify participants, as AuctionRoom does.
Memento
The problem. You need to save an object's state so you can restore it later — for undo, a draft, or a rollback — but you do not want to expose its private fields to the code that stores the snapshot.
The idea. The object makes a sealed snapshot of itself, a memento. Someone else, the caretaker, keeps the snapshots in a stack but cannot open them. Only the original object can read a snapshot back. A cloakroom token is the analogy: the attendant keeps your coat and hands you a numbered token; the token tells you nothing about the coat, but handing it back gets the exact coat returned.
Command undid an action by running its inverse. Memento offers the other route to undo: put back a saved copy.
classDiagram
class PaymentDraft {
-beneficiaryId
-amountPaise
-remarks
+save() Memento
+restore(memento)
}
class Memento {
<<interface>>
}
class Snapshot {
<<record>>
}
class DraftHistory {
-undo
-redo
+beforeEdit()
+undo()
+redo()
}
Memento <|.. Snapshot
PaymentDraft ..> Snapshot : creates and reads
DraftHistory o-- Memento : stores, cannot read
DraftHistory --> PaymentDraft
In Java. A user editing a bulk-payment draft in a corporate banking portal wants undo and redo.
public final class PaymentDraft {
public interface Memento {} // opaque to everyone outside this class
private record Snapshot(String beneficiaryId, long amountPaise, String remarks)
implements Memento {} // private: only PaymentDraft can read it
private String beneficiaryId;
private long amountPaise;
private String remarks;
public void setBeneficiary(String id) { this.beneficiaryId = id; }
public void setAmount(long paise) { this.amountPaise = paise; }
public void setRemarks(String text) { this.remarks = text; }
public Memento save() {
return new Snapshot(beneficiaryId, amountPaise, remarks);
}
public void restore(Memento m) {
Snapshot s = (Snapshot) m;
this.beneficiaryId = s.beneficiaryId();
this.amountPaise = s.amountPaise();
this.remarks = s.remarks();
}
}
public final class DraftHistory { // the caretaker
private final Deque<PaymentDraft.Memento> undo = new ArrayDeque<>();
private final Deque<PaymentDraft.Memento> redo = new ArrayDeque<>();
private final PaymentDraft draft;
public DraftHistory(PaymentDraft draft) { this.draft = draft; }
public void beforeEdit() { // call before every change
undo.push(draft.save());
redo.clear(); // a new edit invalidates the redo path
}
public void undo() {
if (undo.isEmpty()) return;
redo.push(draft.save());
draft.restore(undo.pop());
}
public void redo() {
if (redo.isEmpty()) return;
undo.push(draft.save());
draft.restore(redo.pop());
}
}
The snapshot is an immutable record, so a stored memento can never be changed by accident. The Memento interface has no methods, so DraftHistory can hold snapshots but never read the amount inside one.
Where you have seen it.
- JDBC savepoints:
connection.setSavepoint()returns an opaqueSavepoint, andconnection.rollback(savepoint)restores the transaction to that point. You hold the token; only the database understands it. - Undo in any editor, and "restore previous version" in document tools.
- Snapshots in event sourcing: a saved state of an aggregate so you do not replay every event from the beginning.
Use it when:
- You need undo, drafts or checkpoints, and the state is small enough to copy.
- Encapsulation matters: the code that stores snapshots should not see inside them.
Avoid it when:
- The state is large and changes often. Copying it on every edit wastes memory; store the commands (deltas) instead and replay them.
- The object holds live resources — open connections, locks. A snapshot of a socket cannot be restored.
In an interview. Be ready for "Command or Memento for undo?" Command stores the change and needs a correct inverse for every action. Memento stores the whole state and needs no inverse, but costs memory. Small state with complex actions favours Memento; large state with simple, reversible actions favours Command. Chess often uses Command, because a move is tiny and the board is not.
Observer
The problem. When something happens to one object, several others need to react — and the list of those others keeps growing. If the object calls each one directly, it must change every time a new reaction is added.
The idea. The object (the subject) keeps a list of listeners and, when something happens, tells every listener on the list. It does not know or care what they do. Listeners subscribe and unsubscribe themselves. A newspaper subscription works this way: the paper is printed once and delivered to whoever has subscribed, and the editor never needs to know who the readers are.
Mediator kept all the coordination rules in the middle. Observer is looser: the subject announces, and each listener decides for itself what to do.
classDiagram
class PaymentEvents {
-listeners
+subscribe(listener)
+publish(event)
}
class PaymentListener {
<<interface>>
+onStatusChanged(event)
}
PaymentEvents o-- PaymentListener : notifies all
PaymentListener <|.. CustomerNotifier
PaymentListener <|.. LedgerProjection
PaymentListener <|.. AuditLogger
In Java. When a payment's status changes, the customer gets an SMS, the dashboard's read model updates, and an audit row is written. First the plain-Java version:
public record PaymentStatusChanged(String paymentId, String from, String to, Instant at) {}
public interface PaymentListener {
void onStatusChanged(PaymentStatusChanged event);
}
public final class PaymentEvents {
private static final Logger log = LoggerFactory.getLogger(PaymentEvents.class);
private final List<PaymentListener> listeners = new CopyOnWriteArrayList<>(); // safe to subscribe while publishing
public void subscribe(PaymentListener l) { listeners.add(l); }
public void publish(PaymentStatusChanged event) {
for (PaymentListener l : listeners) {
try {
l.onStatusChanged(event);
} catch (RuntimeException e) { // one broken listener must not stop the rest
log.warn("listener {} failed for {}", l, event.paymentId(), e);
}
}
}
}
In a Spring application you do not write the subject at all. ApplicationEventPublisher is the subject, and any bean method annotated as a listener subscribes:
@Service
public class PaymentService {
private final PaymentRepository repo;
private final ApplicationEventPublisher events;
public PaymentService(PaymentRepository repo, ApplicationEventPublisher events) {
this.repo = repo;
this.events = events;
}
@Transactional
public void markSettled(String paymentId) {
Payment p = repo.findById(paymentId).orElseThrow();
String before = p.status();
p.settle();
events.publishEvent(new PaymentStatusChanged(paymentId, before, p.status(), Instant.now()));
}
}
@Component
class CustomerNotifier {
@TransactionalEventListener // default phase: AFTER_COMMIT
public void on(PaymentStatusChanged e) {
if ("SETTLED".equals(e.to())) sms.send(e.paymentId(), "Your payment has been credited");
}
}
@Component
class AuditLogger {
@EventListener // runs synchronously, inside the same transaction
public void on(PaymentStatusChanged e) { auditRepo.save(AuditRow.from(e)); }
}
The choice of annotation matters. A plain @EventListener runs on the same thread inside the publisher's transaction: if it throws, the settlement rolls back. @TransactionalEventListener waits until the transaction commits, so the customer is never told "credited" about a payment that then rolled back.
In-process observers vanish if the process crashes after the commit but before the listener runs. If a reaction must happen — a ledger posting, a notification another team relies on — use the outbox (R4) and a message broker, not an in-memory event.
Observer versus pub/sub messaging. They are the same idea at two different scales, and interviewers like to check that you know where one ends:
| Observer (in-process) | Pub/sub messaging (R9) | |
|---|---|---|
| Where | Inside one JVM | Across services, over the network |
| Who holds the list | The subject holds references to listeners | A broker (Kafka, SNS, SQS) sits in the middle; publisher and subscribers never meet |
| Delivery | A method call, usually synchronous | Asynchronous; usually at-least-once, so consumers must be idempotent (R3) |
| Durability | Lost if the process dies | Persisted by the broker, replayable |
| Slow listener | Slows down the publisher | Builds a backlog; publisher unaffected |
| Typical use | UI updates, local side effects, decoupling modules | Integrating services, fan-out, audit streams |
What's being probed: whether you can tell a design pattern from a distributed-systems choice.
Concept — Observer versus pub/sub. Observer is a list of callbacks inside one process; pub/sub puts a durable broker between processes. The idea is the same — the publisher does not know its subscribers — but the guarantees are not. Think of telling people in the room versus posting a registered letter. R9 covers delivery semantics, and R4 covers getting the event out reliably with the database change.
Takeaway: use Observer to decouple code inside a service; use outbox plus a broker when the reaction must survive a crash or cross a service boundary.
Where you have seen it.
- Spring's
ApplicationEvent,ApplicationEventPublisher,@EventListenerand@TransactionalEventListener. java.beans.PropertyChangeListenerandPropertyChangeSupport.- Swing and JavaFX listeners:
ActionListeneron a button. java.util.concurrent.Flow(Java 9) and reactive libraries such as Reactor: Observer with backpressure added.java.util.ObserverandObservable— the original JDK version, deprecated since Java 9 because it was not thread-safe and its event model was too thin.
Use it when:
- One change triggers many independent reactions, and new reactions keep being added.
- The subject should not depend on the modules that react to it.
Avoid it when:
- The order of reactions matters, or one reaction depends on another's result. Observer gives no ordering guarantees; call them explicitly.
- You need to know the reaction succeeded. Fire-and-forget hides failures.
- Chains of events trigger further events until nobody can trace the flow. Keep event chains shallow.
In an interview. Two common follow-ups. "What about memory leaks?" — a listener that subscribes and never unsubscribes is kept alive by the subject's list; unsubscribe on shutdown or use weak references. "What if a listener is slow?" — a synchronous observer slows the publisher; make the listener asynchronous (@Async) or move it behind a queue.
State
The problem. An object behaves differently depending on its current status, and every method begins with a switch on that status. Adding a new status means editing every method, and illegal moves — refunding a payment that was never captured — slip through.
The idea. Make each status its own class. The object (the context) holds a reference to its current state object and hands every request to it. Each state class knows which actions are legal in that state and which state comes next. A vending machine is the classic picture: pressing "dispense" does nothing in the waiting state, asks for more money in the "partly paid" state, and drops the snack in the "paid" state. The button is the same; the machine's state decides what it does.
The previous three patterns were about objects reacting to each other. State and Strategy are about one object whose behaviour changes — and they look so alike that they need a careful comparison, which follows the code.
stateDiagram-v2
[*] --> CREATED
CREATED --> AUTHORISED : authorise ok
CREATED --> FAILED : authorise declined
CREATED --> CANCELLED : cancel
AUTHORISED --> CAPTURED : capture
AUTHORISED --> CANCELLED : cancel and void
CAPTURED --> REFUNDED : refund
FAILED --> [*]
CANCELLED --> [*]
REFUNDED --> [*]
In Java. A card payment is authorised (money is held on the card), then captured (the money is taken), and may be refunded. Each state does real work and chooses the next state.
public interface PaymentState {
String name();
default void authorise(Payment p) { throw illegal(p, "authorise"); }
default void capture(Payment p) { throw illegal(p, "capture"); }
default void cancel(Payment p) { throw illegal(p, "cancel"); }
default void refund(Payment p) { throw illegal(p, "refund"); }
private IllegalStateException illegal(Payment p, String action) { // private interface method, Java 9+
return new IllegalStateException("Cannot " + action + " payment " + p.id() + " in state " + name());
}
}
final class Created implements PaymentState {
public String name() { return "CREATED"; }
public void authorise(Payment p) {
boolean approved = p.gateway().authorise(p.id(), p.amountPaise());
p.moveTo(approved ? new Authorised() : new Failed());
}
public void cancel(Payment p) { p.moveTo(new Cancelled()); }
}
final class Authorised implements PaymentState {
public String name() { return "AUTHORISED"; }
public void capture(Payment p) {
p.gateway().capture(p.id(), p.amountPaise());
p.moveTo(new Captured());
}
public void cancel(Payment p) {
p.gateway().voidAuthorisation(p.id()); // release the hold on the customer's card
p.moveTo(new Cancelled());
}
}
final class Captured implements PaymentState {
public String name() { return "CAPTURED"; }
public void refund(Payment p) {
p.gateway().refund(p.id(), p.amountPaise());
p.moveTo(new Refunded());
}
}
final class Failed implements PaymentState { public String name() { return "FAILED"; } }
final class Cancelled implements PaymentState { public String name() { return "CANCELLED"; } }
final class Refunded implements PaymentState { public String name() { return "REFUNDED"; } }
// Terminal states override nothing, so every action on them throws.
public final class Payment {
private final String id;
private final long amountPaise;
private final PaymentGateway gateway;
private PaymentState state = new Created();
public Payment(String id, long amountPaise, PaymentGateway gateway) {
this.id = id;
this.amountPaise = amountPaise;
this.gateway = gateway;
}
public void authorise() { state.authorise(this); }
public void capture() { state.capture(this); }
public void cancel() { state.cancel(this); }
public void refund() { state.refund(this); }
void moveTo(PaymentState next) { this.state = next; } // package-private: only states call it
public String status() { return state.name(); }
String id() { return id; }
long amountPaise() { return amountPaise; }
PaymentGateway gateway() { return gateway; }
}
Payment has no switch anywhere. Calling refund() on a payment that is still AUTHORISED throws, because Authorised does not override refund. Adding a PARTIALLY_REFUNDED state means one new class and a change to Captured; no other code moves.
When states differ only in which transitions they allow, with no other behaviour, a Java enum with constant-specific methods is a lighter way to write the same thing — each constant overrides the transitions it allows and the rest throw.
Two notes connect this to the rest of the pack. In a real service the state is a database column, and each transition is a conditional update — UPDATE payment SET status = 'CAPTURED' WHERE id = ? AND status = 'AUTHORISED' — so two concurrent requests cannot both capture (R8, R19). And a gateway call that times out leaves the payment in an unknown state (R0): add a CAPTURE_PENDING state rather than guessing.
Where you have seen it.
- Spring Statemachine is a library built around this pattern: states, events, transitions and guards declared in configuration.
java.lang.Thread.Statenames a thread's states (NEW,RUNNABLE,BLOCKED,WAITING,TIMED_WAITING,TERMINATED), and the JVM enforces which transitions are legal — the idea, though not the class-per-state form.- A TCP connection (listen, established, closed) is the classic textbook example.
- Order lifecycles, KYC workflows and loan applications in almost every fintech codebase.
Use it when:
- An object has a clear lifecycle, and its behaviour depends on where it is in that lifecycle.
- The same
switch (status)appears in more than one method. - Illegal transitions must be impossible, not just discouraged.
Avoid it when:
- There are two or three states and one or two methods. A
switchon an enum is easier to read. - The rules are better as data: a transition table loaded from configuration (state, event → next state) can be simpler than many classes, especially when operations staff change the workflow.
State versus Strategy. Both patterns have a context object that delegates to an interface with several implementations. The class diagrams are nearly identical. The difference is who changes the object, and when.
State versus Strategy
| State | Strategy | |
|---|---|---|
| What the implementations represent | Where the object is in its lifecycle | A way of doing one job |
| Who picks the implementation | The states themselves, as a result of actions | The client or configuration, from outside |
| Does it change during the object's life? | Yes, constantly — that is the point | Usually fixed once chosen |
| Do implementations know each other? | Yes: Authorised creates Captured |
No: FlatFee has never heard of TieredFee |
| Question it answers | "What am I allowed to do now?" | "How should I do this?" |
| Fintech example | Payment: CREATED → AUTHORISED → CAPTURED | Fee: flat, percentage or tiered |
Strategy is chosen from outside and stays put; State is changed from inside, by the object's own actions.
What's being probed: whether you understand patterns by intent, not by the shape of their class diagram.
Concept — State versus Strategy. In State, calling
capture()changes what the next call will do — the object moves itself along its lifecycle. In Strategy, the fee calculation is plugged in by whoever builds the calculator and nothing inside swaps it. Analogy: a traffic light changes its own colour on a timer (State); a driver chooses a route on a map app (Strategy). The elevator in R24 uses both: State for door and motion, Strategy for which floor to serve next.Takeaway: if the implementations hand control to each other, it is State; if someone outside picks one, it is Strategy.
In an interview. Draw the state diagram before writing any class — interviewers check that you covered the failure and cancel paths, not only the happy path. Then say how a transition is made safe under concurrency: a conditional update on the status column.
Strategy
The problem. One job can be done in several ways — fees, routing, pricing, sorting — and the way changes per customer, per region or per release. An if/else on the type grows with every new variant.
The idea. Put each way of doing the job behind one interface. The caller holds an implementation and calls it, without knowing which one it has. A map app's "fastest", "shortest" and "avoid tolls" options are strategies: the trip is the same, the routing method is swapped.
State changed itself from inside. Strategy is handed in from outside, as the comparison above showed.
classDiagram
class FeeStrategy {
<<interface>>
+compute(amountPaise) long
}
class FeeCalculator {
-byPlan
+feeFor(merchant, amountPaise) long
}
FeeStrategy <|.. FlatFee
FeeStrategy <|.. PercentageFee
FeeStrategy <|.. TieredFee
FeeCalculator --> FeeStrategy : uses
In Java. A payment gateway charges merchants a fee that depends on their plan. The numbers below are illustrative, not any real provider's pricing.
@FunctionalInterface
public interface FeeStrategy {
long compute(long amountPaise);
}
public record FlatFee(long feePaise) implements FeeStrategy {
public long compute(long amountPaise) { return feePaise; }
}
public record PercentageFee(int basisPoints, long capPaise) implements FeeStrategy {
public long compute(long amountPaise) {
long fee = (amountPaise * basisPoints + 5_000) / 10_000; // 1 bp = 0.01%; rounds half up
return Math.min(fee, capPaise);
}
}
public record TieredFee(List<Tier> tiers) implements FeeStrategy { // tiers sorted by upToPaise
public record Tier(long upToPaise, long feePaise) {}
public long compute(long amountPaise) {
return tiers.stream()
.filter(t -> amountPaise <= t.upToPaise())
.findFirst()
.map(Tier::feePaise)
.orElseThrow(() -> new IllegalArgumentException("no tier for " + amountPaise));
}
}
public final class FeeCalculator {
private final Map<MerchantPlan, FeeStrategy> byPlan;
public FeeCalculator(Map<MerchantPlan, FeeStrategy> byPlan) { this.byPlan = Map.copyOf(byPlan); }
public long feeFor(Merchant merchant, long amountPaise) {
FeeStrategy strategy = byPlan.get(merchant.plan());
if (strategy == null) throw new IllegalStateException("no fee strategy for " + merchant.plan());
return strategy.compute(amountPaise);
}
}
FeeCalculator calculator = new FeeCalculator(Map.of(
MerchantPlan.STARTER, new PercentageFee(200, 50_000), // 2%, capped at ₹500
MerchantPlan.GROWTH, new PercentageFee(150, 25_000), // 1.5%, capped at ₹250
MerchantPlan.ENTERPRISE, new FlatFee(1_000), // ₹10 per transaction
MerchantPlan.NGO, amount -> 0L)); // a lambda is a strategy too
The arithmetic for a ₹2,000 payment on the starter plan, to show the rounding works in paise:
amount = ₹2,000 = 2,00,000 paise
fee = 2,00,000 × 200 bp / 10,000 = 4,000 paise = ₹40
cap = 50,000 paise (₹500), so the fee stays ₹40
Because FeeStrategy has one method, it is a functional interface and a lambda is a valid strategy. In Spring you can also make each strategy a bean and inject Map<String, FeeStrategy>, which Spring fills keyed by bean name. The same shape fits routing a payment to an acquirer (cheapest, highest success rate, or round robin) and retry policies (R14).
Where you have seen it.
java.util.Comparatorpassed toList.sortorCollections.sort: the sorting algorithm is fixed; the comparison is the strategy.RejectedExecutionHandleronThreadPoolExecutor: abort, caller-runs, discard or discard-oldest.- Spring Security's
PasswordEncoder(BCryptPasswordEncoder,Argon2PasswordEncoderand others), andDelegatingPasswordEncoderchoosing among them. - Spring's
TaskExecutorandCacheManagerimplementations swapped by configuration.
Use it when:
- The same job has several interchangeable methods, chosen by configuration, customer or experiment.
- You see
if (type == A) … else if (type == B) …in a calculation, and new types keep arriving. - You want to test each variant alone.
Avoid it when:
- There is one way of doing the job, and a second is only imagined. Write it when it arrives.
- The variants are simple values, not behaviour. "2% or 1.5%" is a number in a table, not two classes — notice that
PercentageFeeabove is one class with different parameters, not one class per rate.
In an interview. The common follow-up is "how do you pick the strategy at runtime?" Answer: a map from key to strategy (as above), filled by Spring or a small factory; never an if/else that brings back the problem you just removed. Mention that a strategy should be stateless, so one instance can be shared safely across threads.
Template Method
The problem. Several jobs follow the same steps in the same order, but some steps differ. Copying the skeleton into each job means a fix to the skeleton must be made in every copy.
The idea. Write the skeleton once, in a base class, as a final method that calls a series of steps. The steps that differ are abstract and filled in by subclasses. Optional extension points, called hooks, have an empty default. A franchise recipe is the analogy: every outlet follows the same steps — prepare base, add filling, bake, pack — but the filling is local.
Strategy swapped a whole algorithm by composition. Template Method fixes the algorithm and swaps only some steps, by inheritance.
classDiagram
class ReconciliationJob {
<<abstract>>
+run(date) ReconReport
#loadInternal(date)
#loadExternal(date)
#internalKey(record)
#externalKey(record)
#amountsMatch(mine, theirs)
#onBreaks(report)
}
ReconciliationJob <|-- UpiSettlementRecon
ReconciliationJob <|-- CardSettlementRecon
In Java. Every reconciliation job (R6, R7) loads our records, loads the counterparty's file, matches on a key, and reports breaks. Only the sources, keys and match rules differ.
public abstract class ReconciliationJob<I, E> {
public final ReconReport run(LocalDate businessDate) { // final: subclasses cannot reorder steps
List<I> internal = loadInternal(businessDate);
List<E> external = loadExternal(businessDate);
Map<String, I> unmatched = internal.stream()
.collect(Collectors.toMap(this::internalKey, r -> r)); // keys must be unique
ReconReport report = new ReconReport(name(), businessDate);
for (E theirs : external) {
String key = externalKey(theirs);
I mine = unmatched.remove(key);
if (mine == null) report.missingInternally(key);
else if (!amountsMatch(mine, theirs)) report.amountBreak(key);
else report.matched(key);
}
unmatched.keySet().forEach(report::missingExternally);
onBreaks(report); // hook
return report;
}
protected abstract String name();
protected abstract List<I> loadInternal(LocalDate date);
protected abstract List<E> loadExternal(LocalDate date);
protected abstract String internalKey(I record);
protected abstract String externalKey(E record);
protected abstract boolean amountsMatch(I mine, E theirs);
protected void onBreaks(ReconReport report) { } // default: do nothing
}
public final class UpiSettlementRecon extends ReconciliationJob<LedgerEntry, SettlementRow> {
private final LedgerRepository ledger;
private final SettlementFileReader files;
private final AlertService alerts;
// constructor omitted
protected String name() { return "UPI settlement"; }
protected List<LedgerEntry> loadInternal(LocalDate d) { return ledger.upiEntriesFor(d); }
protected List<SettlementRow> loadExternal(LocalDate d) { return files.read("upi", d); }
protected String internalKey(LedgerEntry e) { return e.rrn(); } // retrieval reference number
protected String externalKey(SettlementRow r) { return r.rrn(); }
protected boolean amountsMatch(LedgerEntry e, SettlementRow r) {
return e.amountPaise() == r.amountPaise();
}
@Override
protected void onBreaks(ReconReport report) {
if (report.breakCount() > 0) alerts.raise("UPI recon: " + report.breakCount() + " breaks");
}
}
Loading every record into a map is fine for a sketch; R7 covers matching files too large for memory.
Where you have seen it.
HttpServlet.service()reads the HTTP method and callsdoGet,doPostand so on, which you override.java.util.AbstractList: implementgetandsize, and you inherititerator,indexOf,contains,equalsand the rest.java.io.InputStream: the bulkread(byte[], int, int)is written in terms of the single abstractread().- Spring's
OncePerRequestFilter(you implementdoFilterInternal) andAbstractRoutingDataSource(you implementdetermineCurrentLookupKey). JdbcTemplate,RestTemplateandTransactionTemplatekeep the same idea — a fixed skeleton with a varying middle — but deliver the varying part as a callback (RowMapper,ResponseExtractor,TransactionCallback) instead of a subclass. The template opens the connection, handles errors and closes resources; you supply only the bit that differs. This "template plus callback" form is the modern Java default because it uses composition rather than inheritance.
Use it when:
- Several jobs share a fixed sequence of steps, and the sequence itself must not drift between them.
- You want to enforce invariants — logging, timing, error handling, resource cleanup — around the varying steps.
Avoid it when:
- The variations are few and small. Pass a lambda (the callback form) instead of forcing a class hierarchy.
- Subclasses need to change the order of the steps. Then it is not one template; split the jobs.
- The hierarchy gets deep. Inheritance couples the subclass to every detail of the base class; prefer composition (Strategy) once there are more than one or two levels.
In an interview. Say why run is final — the skeleton is the whole point, and a subclass must not be able to skip the reporting step. A common follow-up is "Template Method or Strategy?" Answer: Template Method varies steps inside a fixed algorithm by inheritance; Strategy swaps the whole algorithm by composition. JdbcTemplate shows you can get the template's benefit with strategy-style callbacks.
Visitor
The problem. You have a fixed set of types — equity, bond, FX forward — and a growing list of operations over them: fees, tax, risk, reporting. Putting every operation as a method on every type bloats the domain classes and mixes concerns. A chain of instanceof checks is fragile: add a type and the compiler will not tell you which checks you missed.
The idea. Put each operation in its own visitor class, with one visit method per type. Each type has a single accept(visitor) method that calls back the right visit for itself. This trick is called double dispatch: the method that finally runs depends on both the type of the instrument and the type of the visitor. A tax inspector visiting different businesses is the picture: the inspector brings the rules for shops, factories and farms, and each business simply lets the inspector in. A new inspector (a new operation) needs no change to the businesses.
Template Method varied steps within one algorithm. Visitor goes the other way: it keeps the types fixed and lets operations vary freely.
classDiagram
class Instrument {
<<interface>>
+accept(visitor) R
}
class InstrumentVisitor {
<<interface>>
+visit(Equity) R
+visit(Bond) R
+visit(FxForward) R
}
Instrument <|.. Equity
Instrument <|.. Bond
Instrument <|.. FxForward
InstrumentVisitor <|.. TransactionFeeVisitor
InstrumentVisitor <|.. TaxVisitor
Instrument ..> InstrumentVisitor : accept calls visit
In Java. A portfolio holds several instrument types. Transaction fees are computed per type. The rates are illustrative only.
public interface Instrument {
<R> R accept(InstrumentVisitor<R> visitor);
}
public record Equity(String isin, long quantity, long pricePaise) implements Instrument {
public <R> R accept(InstrumentVisitor<R> v) { return v.visit(this); }
}
public record Bond(String isin, long faceValuePaise, int couponBps, LocalDate maturity) implements Instrument {
public <R> R accept(InstrumentVisitor<R> v) { return v.visit(this); }
}
public record FxForward(String currencyPair, long notionalPaise, LocalDate valueDate) implements Instrument {
public <R> R accept(InstrumentVisitor<R> v) { return v.visit(this); }
}
public interface InstrumentVisitor<R> {
R visit(Equity e);
R visit(Bond b);
R visit(FxForward f);
}
public final class TransactionFeeVisitor implements InstrumentVisitor<Long> {
public Long visit(Equity e) { return bps(e.quantity() * e.pricePaise(), 10); } // 0.10% of value
public Long visit(Bond b) { return bps(b.faceValuePaise(), 5); } // 0.05% of face
public Long visit(FxForward f) { return 2_500L; } // flat ₹25
private static long bps(long basePaise, int bps) { return basePaise * bps / 10_000; }
}
long totalFeesPaise = portfolio.stream()
.mapToLong(i -> i.accept(new TransactionFeeVisitor()))
.sum();
A TaxVisitor or RiskVisitor is one new class each; Equity, Bond and FxForward never change. The price: adding a new instrument type, say Option, means adding a visit(Option) method to every visitor. The compiler flags every visitor you forgot — which is the safety net that instanceof chains lack.
Since Java 21, sealed interfaces with pattern-matching switch give the same compiler-checked safety with far less ceremony. It is not standard in Java 17, but worth knowing:
// Java 21+, with: public sealed interface Instrument permits Equity, Bond, FxForward {}
long fee = switch (instrument) {
case Equity e -> e.quantity() * e.pricePaise() * 10 / 10_000;
case Bond b -> b.faceValuePaise() * 5 / 10_000;
case FxForward f -> 2_500L;
}; // no default: the compiler fails the build if a permitted type is missing
Where you have seen it.
java.nio.file.FileVisitorandSimpleFileVisitor, driven byFiles.walkFileTree: you overridevisitFile,preVisitDirectoryand so on.javax.lang.model.element.ElementVisitorin annotation processing.- Compilers and bytecode tools (the ASM library's
ClassVisitor) walk syntax trees and class files with visitors.
Use it when:
- The set of types is stable but new operations over them keep arriving.
- The operations do not belong in the domain classes — reporting, export, tax — and you want them kept apart.
- You are walking a tree of mixed node types (an expression tree, a file system, a document).
Avoid it when:
- New types are added more often than new operations. Every new type edits every visitor.
- You are on Java 21 or later with sealed types. Pattern-matching
switchis usually simpler. - There is only one operation. Put a method on the interface.
In an interview. Be ready to explain double dispatch in one line: instrument.accept(visitor) picks the instrument's method at runtime, and inside it visitor.visit(this) picks the visitor's overload by the static type of this. Then name the trade-off: Visitor makes adding operations easy and adding types hard; plain polymorphism is the reverse.
Summary of behavioural patterns
| Pattern | Problem it solves | One-line Java hint |
|---|---|---|
| Chain of Responsibility | Several checks or handlers apply to a request, and the list changes | Inject List<PaymentRule> ordered by @Order; stop at the first rejection |
| Command | An action must be queued, retried, logged or undone | A record with execute() and inverse(); Runnable is the JDK version |
| Interpreter | Users need small rules evaluated without a release | A sealed Rule interface with And, GreaterThan records; reuse SpEL first |
| Iterator | Walk any collection or paginated source the same way | Implement Iterator<T> that fetches the next page in hasNext() |
| Mediator | Many objects interact in tangled ways | Participants hold only an AuctionMediator; rules live in one class |
| Memento | Save and restore state without exposing it | save() returns an opaque interface backed by a private record |
| Observer | One change triggers many independent reactions | ApplicationEventPublisher plus @TransactionalEventListener |
| State | Behaviour depends on lifecycle status; illegal transitions must fail | One class per state; context delegates and states call moveTo |
| Strategy | One job has several interchangeable methods | A functional interface, chosen from a Map<Key, Strategy> |
| Template Method | Several jobs share a fixed sequence with varying steps | A final skeleton method calling abstract steps; or a callback like RowMapper |
| Visitor | Fixed set of types, growing set of operations | accept(visitor) calling visitor.visit(this); sealed switch on Java 21 |
Choosing a pattern
In an LLD round nobody asks "please use the Visitor pattern". You meet a problem — a growing if/else, a requirement for undo, five things that must react to one event — and the pattern is the name for the fix. So learn them backwards: from the smell to the pattern. The table covers patterns from both parts of this section.
| Problem or smell in the design | Pattern to reach for | Why it fits |
|---|---|---|
A long if/else or switch on a type, in a calculation |
Strategy, or plain polymorphism | Each branch becomes a class behind one interface |
The same switch (status) in several methods; illegal moves slip through |
State | Each status becomes a class that allows only its own transitions |
| An object built in many optional steps; a constructor with eight parameters | Builder | Named steps, validation at build(), immutable result |
| Which concrete class to create depends on input or configuration | Factory method or simple factory | Creation logic lives in one place; callers use the interface |
| Exactly one shared instance (a config, a registry) | Singleton — in Spring, a singleton-scoped bean | One instance without hand-written static state |
| Several parties must react when something happens | Observer (in-process), pub/sub (across services, R9) | The subject does not know who reacts |
| A request must pass a series of checks that keeps changing | Chain of Responsibility | Each check is a link; add or reorder without editing others |
| Undo, redo, retry, queue or audit an action | Command | The action becomes data you can store and replay |
| Undo by restoring a saved state | Memento | Snapshot without exposing private fields |
| Wrap a third-party API whose interface does not match yours | Adapter | Translate their interface to yours at one boundary |
| Add behaviour (logging, caching, retries) without changing the class | Decorator | Wrap the object in another that implements the same interface |
| Simplify a messy subsystem for callers | Facade | One simple entry point over many classes |
| Control access to an object: lazy loading, security, remoting | Proxy | Same interface, extra control in front; Spring AOP works this way |
| Treat a single item and a group of items the same way | Composite | A tree where leaves and branches share an interface |
| Many objects talk to each other in a tangle | Mediator | Everyone talks to the middle; rules live in one place |
| Several jobs share the same steps with small differences | Template Method, or a callback | Skeleton once, varying steps filled in |
| Walk a remote or lazy collection | Iterator | hasNext()/next() hide paging and storage |
| A fixed set of types needs many unrelated operations | Visitor, or sealed switch on Java 21 |
Operations live apart from the types |
| Many fine-grained objects share most of their data | Flyweight | Share the common part, pass the varying part in |
How patterns combine in the case studies
Real designs rarely use one pattern alone. The case studies in R24 each lean on two or three that work together. The table below is a map of where each pattern earns its place — useful as a checklist when you read them, and as a starting point when a new problem looks like one of them.
| Case study (R24) | Patterns that fit | Where they appear |
|---|---|---|
| ATM | State, Chain of Responsibility | State for the machine: idle, card inserted, PIN verified, dispensing, out of service. Chain of Responsibility for dispensing cash: a ₹500 handler gives as many notes as it can and passes the remainder to the ₹200 handler, then the ₹100 handler. |
| Elevator | State, Strategy, Observer, Command | State per car: idle, moving up, moving down, doors open. Strategy for scheduling: which car answers a hall call (nearest car, least busy, or a sweep algorithm). Observer for floor displays. Each button press is a request object (Command) placed in a queue. |
| Chess | Command, Memento, Strategy or polymorphism, State | Command for each move, with undo; Memento as the alternative for undo or saved games; each piece's move rules through polymorphism (a Strategy per piece type); State for game status: in progress, check, checkmate, stalemate. |
| Blackjack | State, Strategy, Iterator | State for the round: betting, dealing, player turns, dealer turn, settlement. Strategy for the dealer's policy (stand on 17) and for automated players. An Iterator over a shuffled shoe of cards. |
| Snake and ladder | Strategy, Iterator — and restraint | Strategy for the dice (one die, two dice, a fixed sequence for tests); a circular Iterator over players for turns. Snakes and ladders are just a map from square to square. Resist adding more. |
| Splitwise | Strategy, Observer | Strategy for split types: equal, exact amounts, percentages, shares — each validates its inputs and produces per-person amounts in paise. Observer to notify members when an expense is added. |
| Meeting scheduler | Strategy, Observer, Builder | Strategy for picking a room or a slot (smallest room that fits, earliest common slot); Observer for invitations and reminders; Builder for a meeting with many optional fields. |
| Stack Overflow | State, Observer, Strategy | State for a question: open, closed, deleted, protected. Observer to notify followers of new answers and comments. Strategy for reputation rules and for search ranking. |
| Car rental | State, Strategy, Factory | State for a reservation and for each vehicle (available, reserved, rented, in maintenance). Strategy for pricing (daily, weekly, peak season). A Factory for vehicle types. |
| Airline management | State, Strategy, Observer, Builder | State for a booking and for a flight (scheduled, boarding, departed, cancelled). Strategy for fares and seat allocation. Observer to tell passengers about delays and gate changes. Builder for a multi-leg itinerary. |
A pattern shows up where something varies or something reacts:
- Something has a lifecycle → State (ATM, elevator, booking, reservation).
- The same job can be done several ways → Strategy (scheduling, pricing, splitting).
- Others need to know → Observer (displays, notifications).
- An action must be undone or queued → Command (chess moves, elevator requests).
- A request moves through a sequence of handlers → Chain of Responsibility (cash dispensing).
If you can say "this varies, so it gets an interface", or "these react, so they subscribe", you have found the pattern without needing to remember its name first.
When not to use a pattern
Patterns are a vocabulary, not a goal. Every pattern adds at least one interface and one level of indirection: a reader who wants to know what happens must now jump from the caller, to the interface, to a factory or a map, to the implementation. That price is worth paying when the indirection buys something real — a variant that genuinely changes, a test that becomes possible, a rule that can be added without editing old code. When it buys nothing, the code is just harder to read. A pattern earns its place by absorbing a change you can name; if you cannot name the change, you do not need the pattern.
Over-engineering. The commonest failure is building for flexibility nobody asked for. A PaymentProcessorFactory that returns the only PaymentProcessor there is. A NotificationStrategy interface with a single EmailNotificationStrategy. An abstract base class with one subclass. Each feels tidy, and each makes the code longer and slower to change in the direction the business actually moves, because the business rarely moves in the direction you guessed. Write the simple version; introduce the pattern when the second real case arrives. Moving from an if to a Strategy is a twenty-minute refactor, and by then you will know the right interface.
Pattern-matching the interviewer's question too early. In an LLD round, hearing "parking lot" and immediately announcing "I'll use Singleton, Factory, Strategy and Observer" is a red flag. It shows you recognise the question, not that you can design. The interviewer wants to watch you find the entities, the responsibilities and the points of change, and then see a pattern fall out of a real need. Lead with requirements and the core classes; name a pattern only when you hit the problem it solves, and say which problem that is: "pricing will vary by vehicle type and by season, so pricing gets its own interface — that is Strategy."
Do not open an LLD answer with a list of patterns. Find the entities and the points of change first; let the patterns follow from them.
The cost of indirection. Indirection has real costs beyond reading time:
- Debugging. A stack trace through a chain of decorators, proxies and listeners is hard to follow. "Which of the nine filters rejected this?" should never need a debugger.
- Hidden control flow. Observer and event listeners make it impossible to see, from the publisher, what will happen when it publishes. Three listeners that each publish further events become a system nobody can trace.
- Performance. Usually negligible in Java — the JIT inlines most small calls — but object-per-request patterns on a hot path (a Command per market-data tick, a Memento per keystroke on a large object) add allocation and garbage-collection pressure. Measure before and after.
- Onboarding. A new team member has to learn your abstractions before they can fix a bug. Every abstraction is a small tax on everyone who reads the code later.
Patterns the language already gives you. Many classic patterns were written for languages without lambdas, enums or records. In modern Java:
- A Strategy or Command with one method is a lambda or method reference; you rarely need a named class.
- A Singleton is a Spring bean, or an
enumwith one constant; hand-written double-checked locking is almost never right. - A small State machine is an enum with constant-specific methods.
- A Visitor over a closed set of types is a sealed interface plus pattern-matching
switchon Java 21. - An Iterator is usually an
Iterableor aStream; Template Method is often a method that takes a callback.
Using the language feature is not "skipping the pattern". It is the same pattern, written in the fewest lines the language allows.
Rules of thumb for using patterns
- Start with the simplest code that meets today's requirement. Add a pattern when a second real case arrives, not when you imagine one.
- Name the change a pattern absorbs. "New fee plans every quarter" justifies Strategy; "we might need it" does not.
- Prefer composition to inheritance. Strategy and callbacks age better than deep Template Method hierarchies.
- Reach for the language first: lambdas, enums, records and sealed types replace many hand-written patterns.
- One interface with one implementation is a smell, unless it marks a real boundary — an external system you need to fake in tests, or a module boundary.
- Keep the indirection visible. Log which strategy, which state transition, which chain link made the decision.
- In an interview, explain the problem before naming the pattern, and the trade-off after: what it makes easy, and what it makes harder.
- Draw before you code: a class diagram for structure, a state diagram for anything with a lifecycle.
- Make state transitions and command execution safe under concurrency and retries — conditional updates and idempotency keys (R3, R8) matter more than which pattern holds them.
- When a design starts to feel clever, stop and ask whether a junior engineer could change it safely next month. If not, simplify.
The patterns in this section are tools for the case studies that follow. In R24, each design starts with requirements and core entities, and the patterns appear only where the design needs them — which is how they should appear in your answers too.
What to say in the interview
"I start an LLD round by pinning down the requirements and the few use cases that matter, then I find the nouns and verbs: nouns become classes, verbs become methods, and I give each class one reason to change. I keep the core free of frameworks and hardware behind interfaces, so the rules can be tested on their own. I reach for a pattern only when I can name the problem: Strategy when a policy will change, State when behaviour depends on a lifecycle, Builder for objects with many optional parts, Adapter at a third-party boundary, Decorator to add retries or logging without touching a class, Observer when several parties must react to one event, Command when actions must be queued, logged or undone. Then I show the design absorbing a new requirement by adding a class rather than editing old ones — that is the open/closed principle, and it is what the interviewer is really testing."