Skip to content

Design Patterns - Extended

The Roadmap covers the common eight (Singleton, Factory, Builder, Adapter, Decorator, Strategy, Observer, State). This page adds the patterns that show up at mid-level and - more importantly - the trade-offs and "when NOT to use" judgement interviewers probe. For the common eight with Java examples, see the Roadmap → Design Patterns.

Language

Examples in Java - the language most LLD rounds expect (Java Cheat Sheet).

The meta-skill: justify, don't name-drop

At SDE-2 the pattern itself is table stakes. What distinguishes you:

  • Recognize the need before naming the pattern ("these are interchangeable algorithms → Strategy", not "let me apply Strategy").
  • State the trade-off: every pattern adds indirection = more classes = harder to read. Patterns pay for flexibility you actually need; don't pre-pay.
  • Know the alternative: Strategy vs a simple if/else; Builder vs a dataclass with defaults; Observer vs a direct call. Picking the simpler one with reasoning scores higher than forcing a pattern.

Red flag: "I'll use the Factory pattern" with no reason. Green flag: "Creation varies by input and I want callers decoupled from concrete classes, so a Factory earns its place."


Command - encapsulate a request as an object

Use for: undo/redo, queuing requests, logging/replaying operations, macro commands. Each action becomes an object with execute() (and often undo()).

interface Command {
    void execute();
    void undo();
}

class AddTextCommand implements Command {
    private final List<String> doc;
    private final String text;
    AddTextCommand(List<String> doc, String text) { this.doc = doc; this.text = text; }
    public void execute() { doc.add(text); }
    public void undo()    { doc.remove(doc.size() - 1); }
}

class Editor {                        // invoker - keeps history, doesn't know command internals
    private final Deque<Command> history = new ArrayDeque<>();
    void run(Command cmd) {
        cmd.execute();
        history.push(cmd);
    }
    void undo() {
        if (!history.isEmpty()) history.pop().undo();
    }
}
  • Decouples the invoker (who triggers) from the receiver (who does the work).
  • Don't use if there's no undo/queue/log need - it's just an extra indirection otherwise.

Chain of Responsibility - pass a request along a chain

Use for: middleware pipelines, approval chains, validation chains, event filters, logging levels. Each handler either handles the request or passes it on.

abstract class Handler {
    protected Handler next;
    Handler setNext(Handler h) {
        this.next = h;
        return h;                      // enables fluent chaining
    }
    abstract String handle(Request req);

    protected String passOn(Request req) {
        return next != null ? next.handle(req) : "OK";
    }
}

class AuthHandler extends Handler {
    String handle(Request req) {
        if (req.token == null) return "401 Unauthorized";
        return passOn(req);
    }
}

class RateLimitHandler extends Handler {
    String handle(Request req) {
        if (req.count > 100) return "429 Too Many Requests";
        return passOn(req);
    }
}

// wire the chain
Handler auth = new AuthHandler();
auth.setNext(new RateLimitHandler());
System.out.println(auth.handle(new Request("x", 5)));   // OK
  • Each handler has one responsibility (SRP); add/remove/reorder handlers freely (OCP).
  • Don't use for a fixed 2-step check - a chain is overkill.

Real LLD uses: expense approval (manager → director → VP by amount), request middleware, money-dispensing in an ATM (dispense ₹2000 notes → ₹500 → ₹100).


Template Method - fix the skeleton, vary the steps

Use for: algorithms with a fixed sequence but variable steps (data parsers, report generators, game turns). Base class defines the order; subclasses fill in steps.

abstract class DataExporter {
    final void export(List<String> data) {  // template method - fixed skeleton, final = DON'T override
        open();
        writeHeader();
        writeRows(data);               // varies
        close();
    }
    void open()  { System.out.println("open file"); }
    void close() { System.out.println("close file"); }
    abstract void writeHeader();
    abstract void writeRows(List<String> data);
}

class CsvExporter extends DataExporter {
    void writeHeader()                 { System.out.println("id,name"); }
    void writeRows(List<String> data)  { System.out.println(String.join("\n", data)); }
}
  • Strategy vs Template Method: Strategy varies an algorithm via composition (inject an object); Template Method varies steps via inheritance (override methods). Prefer Strategy when you want runtime swapping; Template Method when the skeleton is truly fixed.

Facade - one simple front for a complex subsystem

Use for: hiding a messy set of subsystems behind one clean entry point (e.g. an OrderFacade that coordinates inventory + payment + shipping + notification).

class OrderFacade {
    private final Inventory inv;
    private final Payment pay;
    private final Shipping ship;
    private final Notifier notify;
    OrderFacade(Inventory inv, Payment pay, Shipping ship, Notifier notify) {
        this.inv = inv; this.pay = pay; this.ship = ship; this.notify = notify;
    }
    void placeOrder(User user, Item item) {     // client calls ONE method
        inv.reserve(item);
        pay.charge(user, item.price);
        ship.schedule(user, item);
        notify.send(user, "order placed");
    }
}
  • Simplifies the client's view; doesn't restrict access to subsystems (unlike encapsulation).
  • Don't confuse with Adapter: Adapter changes an interface to match an expectation; Facade simplifies many interfaces into one.

Proxy - stand-in that controls access

Use for: lazy loading (virtual proxy), access control (protection proxy), caching, logging, remote calls. Same interface as the real object; adds a layer around it.

interface Image {
    void display();
}

class RealImage implements Image {    // expensive to create
    private final String path;
    RealImage(String path) {
        this.path = path;
        load();                       // heavy disk load
    }
    private void load() { System.out.println("loading " + path); }
    public void display() { System.out.println("showing " + path); }
}

class ImageProxy implements Image {   // defers creation until actually needed
    private final String path;
    private RealImage real;
    ImageProxy(String path) { this.path = path; }
    public void display() {
        if (real == null) {           // lazy load on first use
            real = new RealImage(path);
        }
        real.display();
    }
}
  • Proxy vs Decorator: both wrap and share the interface. Decorator adds behavior; Proxy controls access (may not even create the real object).

Composite - treat individual objects and groups uniformly

Use for: tree structures where a leaf and a container should be used the same way - file systems, UI components, org charts, menu + submenus.

interface FileSystemNode {
    int size();
}

class File implements FileSystemNode {    // leaf
    private final int size;
    File(int size) { this.size = size; }
    public int size() { return size; }
}

class Directory implements FileSystemNode {  // composite
    private final List<FileSystemNode> children = new ArrayList<>();
    void add(FileSystemNode node) { children.add(node); }
    public int size() {
        int total = 0;
        for (FileSystemNode c : children) total += c.size();  // recurse uniformly
        return total;
    }
}

Directory root = new Directory();
root.add(new File(100));
Directory sub = new Directory();
sub.add(new File(50));
root.add(sub);
System.out.println(root.size());      // 150 - same size() call on file or dir

Null Object - a do-nothing default instead of None/null

Use for: avoiding null checks everywhere. Provide a neutral implementation.

interface Logger {
    void log(String msg);
}

class ConsoleLogger implements Logger {
    public void log(String msg) { System.out.println(msg); }
}

class NullLogger implements Logger {  // does nothing - no caller null-checks needed
    public void log(String msg) { }
}

void service(Logger logger) {         // pass NullLogger when "no logger" was wanted
    logger.log("started");            // safe - no null check needed
}
  • Removes scattered if logger is not None checks. Don't use if "absent" must be distinguishable from "present but no-op".

Pattern selection cheat sheet

You see... Reach for Not if...
Interchangeable algorithms chosen at runtime Strategy there are only 2 fixed cases → just if/else
Create one of several subtypes by input Factory only one concrete type exists
Object with many optional fields Builder a dataclass + defaults suffices (esp. Python)
Exactly one shared instance Singleton you can pass the object in (DI) instead
Add stackable behavior at runtime Decorator behavior is fixed → subclass or compose
Incompatible third-party interface Adapter you control both sides → just align them
One change notifies many Observer one fixed listener → direct call
Behavior differs per internal state State 2 states → a boolean + if is fine
Undo / queue / log operations Command no undo/queue need
Sequential handlers, each may handle/pass Chain of Responsibility fixed 2-step check
Fixed skeleton, variable steps Template Method you want runtime swapping → Strategy
Simplify a complex subsystem Facade there's only one subsystem already
Control access / lazy-load / cache Proxy you want to add behavior → Decorator
Tree of leaves + containers Composite structure is flat
Avoid null checks Null Object absence must be detectable

Keep the judgement front-and-center: the simplest design that meets the requirements and stays open to the changes you actually expect. A pattern you can justify beats three you forced in.