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:

  1. How to run the round — what the interviewer grades and a method that fits 45–60 minutes.
  2. Principles — SOLID and a handful of others. They explain why a design is good, and every pattern below is one of them made concrete.
  3. 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:

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.

Simple factory — one method chooses the concrete gateway
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.

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.

Factory Method — the subclass decides which writer the export uses
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.

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.

Abstract Factory — each payment rail supplies a matching validator and message builder
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.

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".

Builder — named steps, then one validated build
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.

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.

Prototype — copy a configured template, then adjust the copy
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.

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.

Singleton — private constructor, one shared instance
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.

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.

Adapter — the legacy switch is made to look like a PaymentGateway
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.

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.

Bridge — notification type and delivery channel vary independently
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.

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.

Composite — an account group answers balance by asking its children
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.

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.

Decorator — retry, metrics and logging wrap the real client
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.

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.

Facade — one checkout call coordinates several subsystems
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.

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.

Flyweight — trades share one immutable Instrument per ISIN
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.

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.

Proxy — an entitlement check stands in front of the real service
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.

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 @Transactional only 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:

Chain of Responsibility — each check holds a link to the next and decides whether to pass the payment on
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.

Use it when:

Avoid it when:

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.

Command — the processor queues and replays ledger commands without knowing what each one does
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.

Use it when:

Avoid it when:

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.

Interpreter — a rule is a tree of small expression objects, each able to evaluate itself
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.

Iterator — callers see a plain Iterator while it quietly fetches pages from the API
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.

Use it when:

Avoid it when:

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.

Mediator — bidders know only the auction room, never each other
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.

Use it when:

Avoid it when:

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.

Memento — the draft makes and reads snapshots; the history keeps them without being able to look inside
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.

Use it when:

Avoid it when:

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.

Observer — the payment publishes one event; each listener reacts in its own way
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.

Use it when:

Avoid it when:

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.

State — a card payment's lifecycle; each box is a state class that allows only its outgoing transitions
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.

Use it when:

Avoid it when:

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.

Strategy — the calculator picks a fee strategy by merchant plan; the strategies know nothing of each other
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.

Use it when:

Avoid it when:

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.

Template Method — the base class owns the reconciliation steps; each subclass supplies only the parts that differ
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.

Use it when:

Avoid it when:

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.

Visitor — each instrument accepts a visitor; each visitor holds one operation for every instrument type
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.

Use it when:

Avoid it when:

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:

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:

Patterns the language already gives you. Many classic patterns were written for languages without lambdas, enums or records. In modern Java:

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

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."