System Design Road

LLD concepts · part 6 of 7

Behavioral patterns: Strategy, Observer, State, Command and six more

Most of the design decisions in an LLD interview are behavioral: who decides the price, who reacts when an order changes, what happens to a request that isn’t allowed, how undo works. The ten patterns in this chapter are the standard answers. The first four (Strategy, Observer, State, Command) come up in almost every interview problem. The rest come up often enough that you should know their names, their shapes and their traps. Every example compiles and runs.

15 min read11 diagrams13 sections

The short version

  • Behavioral patterns are about responsibility: which object decides, which one reacts, and how they talk without knowing too much about each other.
  • Strategy swaps an algorithm; State swaps behaviour as an object’s situation changes; Template Method fixes the steps and lets subclasses fill in details.
  • Observer announces changes to whoever listens. One failing listener must not stop the others, and listeners must not run inside your locks.
  • Command turns an action into an object you can queue, log and undo. Memento saves a snapshot you can go back to.
  • Chain of Responsibility passes a request along until someone handles or rejects it. Iterator walks a collection without exposing it. Mediator stops objects talking to each other directly. Visitor adds operations to a fixed set of types.

Before we start

The previous two chapters were about making objects and fitting them together. This one is about behaviour: how responsibilities are split up, and how objects cooperate without being tangled together.

PatternThe question it answers
StrategyHow do I swap one algorithm for another?
ObserverHow do many objects react to one change?
StateHow does behaviour change as an object’s situation changes?
CommandHow do I treat an action as data: queue it, log it, undo it?
Template MethodHow do I fix the steps but let details vary?
Chain of ResponsibilityHow do I pass a request through checks until one handles it?
IteratorHow do I walk through items without knowing how they’re stored?
MediatorHow do I stop many objects talking directly to each other?
MementoHow do I save and restore an object’s state?
VisitorHow do I add operations to a fixed set of types?

Each one below follows the same shape: the problem, the pattern, the code with its real output, mistakes and variations, when not to use it, and where it shows up in this track.

Strategy: swap the algorithm

The problem

A delivery app charges a fee. Today it’s flat. Next month it’s by distance. During rain, there’s a surge. Written directly:

Java
long fee(String mode, double km, boolean raining) {
    if (mode.equals("FLAT")) return 3000;
    else if (mode.equals("DISTANCE")) return 1500 + (long) (km * 800);
    else if (mode.equals("SURGE")) return raining ? 6000 : 3000;
    throw new IllegalArgumentException(mode);
}

Each new rule edits this method. Rules can’t be tested on their own, and the method’s arguments grow to fit every rule’s needs.

The pattern

Put each algorithm in its own class behind one interface. The object that needs a fee holds a strategy and calls it; which one is decided outside, and can change at runtime.

Checkout- fees: FeeStrategy+ total(itemsPaise, trip): long«interface»FeeStrategy+ fee(trip: Trip): longFlatFeeDistanceFeeSurgeFee

The code

Java
record Trip(double km, boolean raining) { }

interface FeeStrategy {
    long fee(Trip trip);                                     // paise
}

final class FlatFee implements FeeStrategy {
    public long fee(Trip t) { return 30_00; }
}

final class DistanceFee implements FeeStrategy {
    public long fee(Trip t) { return 15_00 + Math.round(t.km() * 8_00); }
}

final class SurgeFee implements FeeStrategy {
    private final FeeStrategy base;                          // (1)
    SurgeFee(FeeStrategy base) { this.base = base; }
    public long fee(Trip t) { return t.raining() ? base.fee(t) * 2 : base.fee(t); }
}

final class Checkout {
    private FeeStrategy fees;
    Checkout(FeeStrategy fees) { this.fees = fees; }
    void use(FeeStrategy fees) { this.fees = fees; } 2
    long total(long itemsPaise, Trip trip) { return itemsPaise + fees.fee(trip); }
}

class StrategyDemo {
    public static void main(String[] args) {
        Trip rainy = new Trip(4.2, true);
        Checkout c = new Checkout(new FlatFee());
        System.out.println("flat:           " + c.total(250_00, rainy));
        c.use(new DistanceFee());
        System.out.println("distance:       " + c.total(250_00, rainy));
        c.use(new SurgeFee(new DistanceFee()));
        System.out.println("distance+surge: " + c.total(250_00, rainy));
    }
}
  1. 2swap at runtime
  1. Here the surge rule wraps another strategy. That’s a decorator made of strategies, and it’s common.
  2. The checkout doesn’t know or care which rule it has.
Text
flat:           28000
distance:       29860
distance+surge: 34720

Mistakes and variations

  • *A strategy per if branch that never changes.* If there’s only ever one fee rule, an interface adds nothing.
  • Strategies that need different data. If one rule needs the weather and another needs the restaurant, pass a context object (Trip here) rather than growing the method’s argument list.
  • In Java, a one-method strategy can be a lambda: new Checkout(t -> 25_00).

When not to use it

When the behaviour is fixed, or differs only by a number. A fee that’s always “base + per km” just needs two fields, not a strategy.

Where it shows up

Everywhere in this track: pricing and spot allocation in the parking lot, dispatching in the elevator, the counting algorithm in the rate limiter, dice and house rules in snakes and ladders, debt simplification in Splitwise.

Observer: announce changes to whoever listens

The problem

When an order’s status changes, the customer gets an SMS, the restaurant’s screen updates, and analytics records it. If Order calls each of those directly, it has to know all of them, and every new reaction (loyalty points next quarter) means editing Order.

The pattern

The subject (Order) keeps a list of listeners that share one small interface. When something happens, it tells each of them. It doesn’t know what they are. New reactions subscribe; the subject never changes.

OrderEvents- listeners: List+ subscribe(l): void+ publish(orderId, status): void«interface»OrderListener+ changed(orderId, status): voidSmsListenerAnalyticsListenernotifies

Step through both designs:

An order changes status. Who needs to know?
new feature
Ordermust be edited againCustomer pushRestaurant screenAnalyticsLoyalty pointsneeds a 4th call

Product adds loyalty points. To tell the loyalty service, someone has to edit Order again, and retest everything it already does. Every new listener makes Order bigger and more fragile.

Design

The code

A naive version and a safe one, so you can see the trap:

Java
interface OrderListener {
    void changed(String orderId, String status);
}

final class NaiveEvents {
    private final List listeners = new ArrayList<>();
    void subscribe(OrderListener l) { listeners.add(l); }
    void publish(String id, String status) {
        for (OrderListener l : listeners) l.changed(id, status);      // (1)
    }
}

final class OrderEvents {
    private final List listeners = new CopyOnWriteArrayList<>();   // (2)
    void subscribe(OrderListener l) { listeners.add(l); }
    int publish(String id, String status) {
        int failed = 0;
        for (OrderListener l : listeners) {
            try { l.changed(id, status); }
            catch (RuntimeException e) { failed++; }                   // (3)
        }
        return failed;
    }
}

class ObserverDemo {
    public static void main(String[] args) {
        List log = new ArrayList<>();
        OrderListener sms = (id, s) -> log.add("sms " + id + " " + s);
        OrderListener broken = (id, s) -> {
            throw new IllegalStateException("analytics down");
        };
        OrderListener screen = (id, s) -> log.add("screen " + id + " " + s);

        NaiveEvents naive = new NaiveEvents();
        naive.subscribe(sms); naive.subscribe(broken); naive.subscribe(screen);
        try { naive.publish("o1", "PICKED_UP"); } catch (RuntimeException e) { }
        System.out.println("naive: " + log);

        log.clear();
        OrderEvents safe = new OrderEvents();
        safe.subscribe(sms); safe.subscribe(broken); safe.subscribe(screen);
        int failed = safe.publish("o1", "PICKED_UP");
        System.out.println("safe:  " + log + ", failed listeners: " + failed);
    }
}
  1. If one listener throws, the loop stops, and every listener after it never hears.
  2. A list that’s safe to loop over while another thread subscribes.
  3. Each listener’s failure is contained (and in real code, logged).
Text
naive: [sms o1 PICKED_UP]
safe:  [sms o1 PICKED_UP, screen o1 PICKED_UP], failed listeners: 1

In the naive version, the analytics outage meant the restaurant’s screen never updated. Nothing in the order code was wrong; one unrelated listener broke it.

Mistakes and variations

  • One listener breaks the rest, as shown.
  • Calling listeners while holding a lock. A slow listener then blocks everything behind the lock. Every problem in this track notifies after releasing its lock.
  • Forgetting to unsubscribe, so dead objects keep receiving events and can’t be garbage collected.
  • Push or pull: send the data with the event (as here), or just say “something changed” and let listeners fetch what they need. The file sync walkthrough uses the second style.
  • At system scale, Observer becomes publish-subscribe through a message queue (lesson 4.1).

When not to use it

When there’s exactly one reaction and it must happen as part of the same operation (a booking must be saved before the API returns). That’s just a method call.

Where it shows up

Display boards in the parking lot, the scoreboard in tic-tac-toe, booking listeners in movie booking, eviction listeners in the LRU cache.

State: let each state decide

The problem

A food order can be placed, accepted, picked up, delivered or cancelled, and what’s allowed depends on where it is. Cancelling is fine before pickup, not after. Written with a status field:

Java
void cancel() {
    if (status == PLACED || status == ACCEPTED) status = CANCELLED;
    else if (status == PICKED_UP) throw new IllegalStateException("too late");
    else if (status == DELIVERED) throw new IllegalStateException("already delivered");
    // ...and the same kind of chain in accept(), pickUp(), deliver()
}

Every method has a chain like this, and the rules for one state are spread across every method.

The pattern

Make each state a class that implements every event. The object holds its current state and passes events to it. Each state class answers “what happens if this event arrives now?”, and says which state comes next. All the rules for one state live in one place.

FoodOrder2- state: OrderState+ accept(): void+ pickUp(): void+ cancel(): void«interface»OrderState+ accept(o): OrderState+ pickUp(o): OrderState+ cancel(o): OrderStatePlacedAcceptedPickedUpcurrent

The code

Java
interface OrderState {
    default OrderState accept()  { throw refuse("accept"); }
    default OrderState pickUp()  { throw refuse("pick up"); }
    default OrderState deliver() { throw refuse("deliver"); }
    default OrderState cancel()  { throw refuse("cancel"); }
    String name();
    private IllegalStateException refuse(String event) {
        return new IllegalStateException(name() + ": can't " + event);
    }
}

final class Placed implements OrderState { 1
    public OrderState accept() { return new Accepted(); }
    public OrderState cancel() { return new Cancelled(); }
    public String name() { return "PLACED"; }
}
final class Accepted implements OrderState {
    public OrderState pickUp() { return new PickedUp(); }
    public OrderState cancel() { return new Cancelled(); }
    public String name() { return "ACCEPTED"; }
}
final class PickedUp implements OrderState {
    public OrderState deliver() { return new Delivered(); }
    public String name() { return "PICKED_UP"; }
}
final class Delivered implements OrderState {
    public String name() { return "DELIVERED"; }
}
final class Cancelled implements OrderState {
    public String name() { return "CANCELLED"; }
}

final class FoodOrder2 {
    private OrderState state = new Placed();
    void accept()  { state = state.accept(); } 2
    void pickUp()  { state = state.pickUp(); }
    void deliver() { state = state.deliver(); }
    void cancel()  { state = state.cancel(); }
    String status() { return state.name(); }
}

class StateDemo {
    public static void main(String[] args) {
        FoodOrder2 o = new FoodOrder2();
        o.accept();
        o.pickUp();
        System.out.println("now " + o.status());
        try { o.cancel(); }
        catch (IllegalStateException e) { System.out.println(e.getMessage()); }
        o.deliver();
        System.out.println("now " + o.status());
        FoodOrder2 early = new FoodOrder2();
        early.cancel();
        System.out.println("cancelled early: " + early.status());
        try { early.accept(); }
        catch (IllegalStateException e) { System.out.println(e.getMessage()); }
    }
}
  1. 1only what’s allowed
  2. 2just delegate
  1. A state class only overrides the events it allows. Everything else falls through to the default, which refuses. A new event added to the interface is refused everywhere until a state decides otherwise: a safe default.
  2. The order holds no rules at all.
Text
now PICKED_UP
PICKED_UP: can't cancel
now DELIVERED
cancelled early: CANCELLED
CANCELLED: can't accept

Mistakes and variations

  • State classes that know too much about each other. Returning the next state (as here) keeps them loosely tied; having each state reach into the order and set fields does not.
  • Concurrency. Two threads firing events at one order can both read the same state. Lock per object, or move between states with compare-and-set, as movie booking does for its hold states.
  • An enum with a transition table is a lighter alternative when states have no behaviour of their own, only allowed moves.

When not to use it

With two or three states and simple rules. An if is clearer, and the parking lot and tic-tac-toe walkthroughs both explain why they didn’t use State.

Where it shows up

The vending machine is the classic: idle, has money, dispensing and out of service, each a class, with a state-by-event table that turns into the code.

Command: an action as an object

The problem

A text editor needs undo and redo. If “type”, “delete” and “paste” are just method calls, they’re gone the moment they run: there’s nothing to undo.

The pattern

Turn each action into an object with execute() and undo(). Keep executed commands on a history stack. Undo pops one and calls its undo(); redo goes the other way. Because commands are objects, they can also be queued, logged, retried or sent over a network.

Editor- text: StringBuilder- done: Deque- undone: Deque+ run(c): void+ undo(): void+ redo(): void«interface»EditCommand+ execute(sb): void+ undo(sb): voidInsertDeletehistory

The code

Java
interface EditCommand {
    void execute(StringBuilder text);
    void undo(StringBuilder text);
}

record Insert(int at, String s) implements EditCommand {
    public void execute(StringBuilder t) { t.insert(at, s); }
    public void undo(StringBuilder t) { t.delete(at, at + s.length()); }
}

final class Delete implements EditCommand {
    private final int from, to;
    private String removed; 1
    Delete(int from, int to) { this.from = from; this.to = to; }
    public void execute(StringBuilder t) {
        removed = t.substring(from, to);
        t.delete(from, to);
    }
    public void undo(StringBuilder t) { t.insert(from, removed); }
}

final class Editor {
    private final StringBuilder text = new StringBuilder();
    private final Deque done = new ArrayDeque<>();
    private final Deque undone = new ArrayDeque<>();

    void run(EditCommand c) {
        c.execute(text);
        done.push(c);
        undone.clear(); 2
    }
    void undo() {
        if (done.isEmpty()) return;
        EditCommand c = done.pop();
        c.undo(text);
        undone.push(c);
    }
    void redo() {
        if (undone.isEmpty()) return;
        EditCommand c = undone.pop();
        c.execute(text);
        done.push(c);
    }
    String text() { return text.toString(); }
}

class CommandDemo {
    public static void main(String[] args) {
        Editor e = new Editor();
        e.run(new Insert(0, "Hello world"));
        e.run(new Insert(5, ","));
        e.run(new Delete(6, 12));
        System.out.println("after edits: '" + e.text() + "'");
        e.undo();
        System.out.println("undo:        '" + e.text() + "'");
        e.undo();
        System.out.println("undo:        '" + e.text() + "'");
        e.redo();
        System.out.println("redo:        '" + e.text() + "'");
        e.run(new Insert(6, " there"));
        e.redo();
        System.out.println("new edit, then redo does nothing: '" + e.text() + "'");
    }
}
  1. 1remembered for undo
  2. 2a new action ends redo
  1. A command stores whatever it needs to undo itself. Delete has to remember the text it removed.
  2. Once you do something new after undoing, the old redo history no longer makes sense.
Text
after edits: 'Hello,'
undo:        'Hello, world'
undo:        'Hello world'
redo:        'Hello, world'
new edit, then redo does nothing: 'Hello, there world'

Mistakes and variations

  • Commands that can’t undo exactly because they didn’t save enough. Chess’s Move plus Undo record in chess exists for exactly this reason: castling rights and the en passant square can’t be worked out backwards.
  • Unbounded history. Cap it, or memory grows forever in a long session.
  • Commands as messages. A queue of commands to run later, or a log of commands to replay after a crash, is the same pattern used across time instead of for undo.

When not to use it

When nothing needs undoing, queuing or logging. Then a method call is enough.

Where it shows up

Moves in tic-tac-toe and chess, and the history stacks behind every undo button.

Template Method: fix the steps, vary the details

The problem

Checkout runs the same steps for every customer: check the cart, apply discounts, charge, confirm. Members get a discount; some customers pay on delivery. Copying the whole checkout for each case means the copies drift apart: someone fixes a bug in one and not the others.

The pattern

Write the fixed steps once, in a base-class method that subclasses can’t change (final). Leave the parts that vary as small methods (“hooks”) that subclasses override.

«abstract»CheckoutFlow+ checkout(cartPaise): String# discount(cartPaise): long# pay(amount): StringRegularCheckoutMemberCheckoutCashOnDelivery

The code

Java
abstract class CheckoutFlow {
    final String checkout(long cartPaise) { 1
        if (cartPaise <= 0) return "empty cart";
        long due = cartPaise - discount(cartPaise);
        return pay(due) + ", confirmed";
    }
    protected long discount(long cartPaise) { return 0; } 2
    protected abstract String pay(long amount);              // (3)
}

final class RegularCheckout extends CheckoutFlow {
    protected String pay(long a) { return "card charged " + a; }
}

final class MemberCheckout extends CheckoutFlow {
    protected long discount(long c) { return c / 10; }
    protected String pay(long a) { return "card charged " + a; }
}

final class CashOnDelivery extends CheckoutFlow {
    protected String pay(long a) { return "collect " + a + " at door"; }
}

class TemplateDemo {
    public static void main(String[] args) {
        for (CheckoutFlow f : List.of(new RegularCheckout(), new MemberCheckout(),
                                      new CashOnDelivery()))
            System.out.println(f.getClass().getSimpleName() + ": " + f.checkout(500_00));
        System.out.println("empty: " + new MemberCheckout().checkout(0));
    }
}
  1. 1the fixed steps
  2. 2a hook with a default
  1. final: subclasses can’t change the order of steps or skip the empty-cart check.
  2. A hook: optional to override.
  3. A required step: every subclass must say how to pay.
Text
RegularCheckout: card charged 50000, confirmed
MemberCheckout: card charged 45000, confirmed
CashOnDelivery: collect 50000 at door, confirmed
empty: empty cart

Mistakes and variations

  • Too many hooks. A base class with ten overridable steps is hard to follow; at that point, Strategy objects for the varying parts are clearer.
  • Template Method versus Strategy: Template Method varies steps by subclassing, fixed when the object is created. Strategy varies them by holding an object, swappable at runtime. Modern code tends to prefer Strategy, but Template Method is fine for a small, stable set of variations.
  • Factory Method (chapter 4) is a Template Method whose varying step creates an object.

When not to use it

When the variations will mix and match (member and cash on delivery). Subclasses can’t combine; strategies can.

Where it shows up

Slider and Stepper in chess: the walking algorithm is fixed in the base class, and each piece supplies only its directions or offsets.

Chain of Responsibility: pass it along

The problem

Every API request must be checked in order: is the user logged in, are they within their rate limit, is the body valid? Only then does the real handler run. Writing those checks at the top of every handler means copying them, and forgetting one somewhere.

The pattern

Each check is a link in a chain. A link either handles the request (often by rejecting it) or passes it to the next link. The handler at the end only sees requests that passed every check. Links can be added, removed or reordered without touching the others.

«abstract»Step- next: Step+ then(n: Step): Step+ handle(r): StringAuthRateLimitValidateHandler

Each Step also holds a reference to the next one; that’s what makes it a chain.

The code

Java
record ApiRequest(String user, String body) { }

abstract class Step {
    private Step next;
    Step then(Step n) { this.next = n; return n; } 1
    String handle(ApiRequest r) { return next == null ? "no handler" : next.handle(r); }
}

final class Auth extends Step {
    String handle(ApiRequest r) {
        return r.user() == null ? "401 not logged in" : super.handle(r);
    }
}

final class RateLimit extends Step {
    private final Map counts = new HashMap<>();
    String handle(ApiRequest r) {
        int n = counts.merge(r.user(), 1, Integer::sum);
        return n > 2 ? "429 slow down" : super.handle(r); 2
    }
}

final class Validate extends Step {
    String handle(ApiRequest r) {
        return r.body().isBlank() ? "400 empty body" : super.handle(r);
    }
}

final class Handler extends Step {
    String handle(ApiRequest r) { return "200 saved '" + r.body() + "'"; }
}

class ChainDemo {
    public static void main(String[] args) {
        Step chain = new Auth();
        chain.then(new RateLimit()).then(new Validate()).then(new Handler());
        List reqs = List.of(
            new ApiRequest(null, "hi"), new ApiRequest("asha", ""),
            new ApiRequest("asha", "note 1"), new ApiRequest("asha", "note 2"));
        for (ApiRequest r : reqs) System.out.println(r.user() + ": " + chain.handle(r));
    }
}
  1. 1build the chain
  2. 2stop here, or pass on
  1. then returns the link it attached, so the chain reads left to right.
  2. Each link decides for itself whether to stop or pass the request on.
Text
null: 401 not logged in
asha: 400 empty body
asha: 200 saved 'note 1'
asha: 429 slow down

Mistakes and variations

  • Order matters. Rate limiting before authentication lets anonymous floods use up real users’ quotas; after it, they’re rejected first.
  • A request falls off the end. Decide what happens when nobody handles it (here, “no handler”).
  • Web frameworks are built on this. Servlet filters, Express and Spring middleware, and gRPC interceptors are all chains of responsibility.

When not to use it

When there’s exactly one check. Or when every check must always run (collect all validation errors at once): then loop over a list instead of stopping early.

Where it shows up

The API gateway in the rate limiter sits in a chain like this one, usually right after authentication.

Iterator: walk through items without seeing how they’re stored

The problem

A search API returns results 20 at a time. Code that wants “the first 30 results that are open now” shouldn’t need to know about pages, offsets or when to fetch the next page. And it shouldn’t fetch all 1,000 results up front just to use 30.

The pattern

An iterator hides how items are stored or fetched behind two methods: is there another item, and give me the next one. A lazy iterator fetches more only when the caller actually asks.

«interface»Iterator+ hasNext(): boolean+ next(): TPagedResults- page: List- pageNo: int+ hasNext(): boolean+ next(): StringSearchApi+ fetch(pageNo): Listfetches pages

The code

Java
final class SearchApi {
    int calls;
    List fetch(int pageNo) {                         // 50 pages of 20 results
        calls++;
        if (pageNo >= 50) return List.of();
        List out = new ArrayList<>();
        for (int i = 0; i < 20; i++) out.add("cafe-" + (pageNo * 20 + i));
        return out;
    }
}

final class PagedResults implements Iterator, Iterable {
    private final SearchApi api;
    private List page = List.of();
    private int pageNo = -1, pos;
    private boolean done;

    PagedResults(SearchApi api) { this.api = api; }

    public boolean hasNext() {
        if (pos < page.size()) return true;
        if (done) return false;
        page = api.fetch(++pageNo); 1
        pos = 0;
        if (page.isEmpty()) done = true;
        return !page.isEmpty();
    }

    public String next() {
        if (!hasNext()) throw new NoSuchElementException();
        return page.get(pos++);
    }

    public Iterator iterator() { return this; } 2
}

class IteratorDemo {
    public static void main(String[] args) {
        SearchApi api = new SearchApi();
        int taken = 0;
        for (String r : new PagedResults(api)) {
            if (++taken == 30) break;
        }
        System.out.println("took 30 results, API pages fetched: " + api.calls);
        SearchApi all = new SearchApi();
        int count = 0;
        for (String r : new PagedResults(all)) count++;
        System.out.println("walked all " + count + " results, API calls: " + all.calls);
    }
}
  1. 1only when needed
  2. 2works in for-each
  1. The next page is fetched only when the current one runs out.
  2. Implementing Iterable lets callers use a normal for loop and never see paging at all.
Text
took 30 results, API pages fetched: 2
walked all 1000 results, API calls: 51

Thirty results cost two API calls, not fifty. Walking everything costs 51: fifty pages plus one empty page that says “no more”.

Mistakes and variations

  • Changing a collection while iterating. Java’s lists throw ConcurrentModificationException; concurrent collections iterate over a snapshot or tolerate changes.
  • *hasNext() with side effects called twice.* Ours is safe to call repeatedly: it only fetches when the current page is used up.
  • Java’s Stream is the modern, richer form: results.stream().filter(...).limit(30) is lazy in the same way.

When not to use it

When you need random access or the size up front. An iterator only goes forward.

Where it shows up

Every collection in Java implements it. In the web crawler and anywhere that pages through an external API, a lazy iterator keeps memory and calls down.

Mediator: stop objects talking to each other directly

The problem

In a group chat, each member could keep a list of every other member and send them messages directly. With 50 members, that’s 50 × 49 connections to keep in sync, and every rule (muted users, blocked users, “only admins can post”) has to be enforced in 50 places.

The pattern

Members talk only to a mediator (the chat room). The mediator knows everyone and decides who receives what. Rules live in one place, and members don’t know about each other at all.

ChatRoom- members: Map- muted: Set+ join(m): void+ send(from, text): voidMember- name: String- room: ChatRoom+ say(text): void+ receive(from, text): voidmemberstalks only to

The code

Java
final class Member {
    final String name;
    final List inbox = new ArrayList<>();
    private ChatRoom room;
    Member(String name) { this.name = name; }
    void joined(ChatRoom r) { this.room = r; }
    void say(String text) { room.send(this, text); } 1
    void receive(String from, String text) { inbox.add(from + ": " + text); }
}

final class ChatRoom {
    private final Map members = new LinkedHashMap<>();
    private final Set muted = new HashSet<>();
    void join(Member m) { members.put(m.name, m); m.joined(this); }
    void mute(String name) { muted.add(name); }

    void send(Member from, String text) {
        if (muted.contains(from.name)) return; 2
        for (Member m : members.values())
            if (m != from) m.receive(from.name, text);
    }
}

class MediatorDemo {
    public static void main(String[] args) {
        ChatRoom flatmates = new ChatRoom();
        Member a = new Member("asha"), r = new Member("ravi"), m = new Member("meera");
        flatmates.join(a); flatmates.join(r); flatmates.join(m);
        a.say("rent is due friday");
        flatmates.mute("ravi");
        r.say("BUY CRYPTO NOW");
        m.say("paid!");
        System.out.println("asha:  " + a.inbox);
        System.out.println("ravi:  " + r.inbox);
        System.out.println("meera: " + m.inbox);
    }
}
  1. 1never to another member
  2. 2rules in one place
  1. Members only know the room.
  2. Muting is one line in one place, instead of a check in every member.
Text
asha:  [meera: paid!]
ravi:  [asha: rent is due friday, meera: paid!]
meera: [asha: rent is due friday]

Mistakes and variations

  • A mediator that becomes a god object. It should route and enforce rules, not hold every feature of the app.
  • Mediator versus Observer: an observer subject broadcasts to anyone listening and doesn’t decide much. A mediator actively coordinates: it decides who gets what.

When not to use it

With two or three objects whose interaction is simple. A mediator then is just an extra hop.

Where it shows up

The ElevatorSystem controller in the elevator walkthrough mediates between hall buttons, cars and the dispatcher: buttons never talk to cars directly. The chat server in the chat system design is a mediator at system scale.

Memento: save a snapshot, restore it later

The problem

A long form (a job application, a checkout with address and payment details) should let users go back to how it was before they started changing things, or recover a draft after the app restarts. The form’s fields are private, and they should stay private.

The pattern

The object creates a memento: a snapshot of its own state, in a form nobody else can read or change. A caretaker (the screen, the app) stores mementos without looking inside, and hands one back to restore.

ApplicationForm- name, city: String- skills: List+ save(): Snapshot+ restore(s: Snapshot): voidSnapshot- name, city: String- skills: Listcreates

The code

Java
final class ApplicationForm {
    private String name = "", city = "";
    private List skills = new ArrayList<>();

    record Snapshot(String name, String city, List skills) { }   // (1)

    Snapshot save() { return new Snapshot(name, city, List.copyOf(skills)); }   // (2)
    void restore(Snapshot s) {
        name = s.name(); city = s.city(); skills = new ArrayList<>(s.skills());
    }

    void fill(String name, String city) { this.name = name; this.city = city; }
    void addSkill(String s) { skills.add(s); }
    @Override public String toString() { return name + ", " + city + ", " + skills; }
}

class MementoDemo {
    public static void main(String[] args) {
        ApplicationForm f = new ApplicationForm();
        f.fill("Asha", "Pune");
        f.addSkill("Java");
        ApplicationForm.Snapshot before = f.save();
        f.fill("Asha R", "Bengaluru");
        f.addSkill("Kafka");
        System.out.println("edited:   " + f);
        f.restore(before);
        System.out.println("restored: " + f);
    }
}
  1. An immutable record. Callers can hold it, but can’t change the form through it.
  2. The list is copied. A snapshot that shared the live list would change as the form changed, the same shallow-copy bug as in Prototype.
Text
edited:   Asha R, Bengaluru, [Java, Kafka]
restored: Asha, Pune, [Java]

Mistakes and variations

  • Shallow snapshots, as noted.
  • Memento versus Command for undo: Command stores how to reverse each action (small, but every action must know its own undo). Memento stores whole snapshots (simple, but each one costs memory). Editors with small, frequent changes use commands; forms and game saves often use snapshots.
  • Chess uses both: the Move is a command, and the Undo record is a memento of the hidden state the move can’t reverse on its own.

When not to use it

When the state is huge and changes a little at a time. Saving the whole thing each time wastes memory; use commands instead.

Where it shows up

Game saves, draft recovery, transaction rollback, and the Undo record in chess.

Visitor: new operations on a fixed set of types

The problem

A shopping cart holds books, groceries and electronics. You need GST for each (books 0%, groceries 5%, electronics 18%), then shipping weight, then a printable invoice line. Adding a method to every item class for each new operation means every new report edits every item class.

The pattern

Put each operation in its own visitor class, with one method per item type. Items have one method, accept(visitor), which calls the right method on the visitor. Adding an operation means adding a visitor; the item classes never change. (Adding a new item type means changing every visitor: the opposite trade-off.)

«interface»CartItem+ accept(v): TBookGroceryGadget«interface»ItemVisitor+ book(b): T+ grocery(g): T+ gadget(g): TGstVisitoraccept

The code

The classic version, then the modern Java alternative.

Java
interface ItemVisitor {
    T book(Book b);
    T grocery(Grocery g);
    T gadget(Gadget g);
}

sealed interface CartItem permits Book, Grocery, Gadget {
     T accept(ItemVisitor v);
}

record Book(String title, long paise) implements CartItem {
    public  T accept(ItemVisitor v) { return v.book(this); }   // (1)
}
record Grocery(String name, long paise) implements CartItem {
    public  T accept(ItemVisitor v) { return v.grocery(this); }
}
record Gadget(String name, long paise) implements CartItem {
    public  T accept(ItemVisitor v) { return v.gadget(this); }
}

final class GstVisitor implements ItemVisitor {
    public Long book(Book b) { return 0L; }
    public Long grocery(Grocery g) { return g.paise() * 5 / 100; }
    public Long gadget(Gadget g) { return g.paise() * 18 / 100; }
}

final class Gst { 2
    static long of(CartItem item) {
        return switch (item) {
            case Book b -> 0;
            case Grocery g -> g.paise() * 5 / 100;
            case Gadget g -> g.paise() * 18 / 100;
        };
    }
}

class VisitorDemo {
    public static void main(String[] args) {
        List cart = List.of(new Book("Clean Code", 450_00),
            new Grocery("Basmati 5kg", 600_00), new Gadget("Earbuds", 1_999_00));
        long visitor = 0, modern = 0;
        for (CartItem i : cart) {
            visitor += i.accept(new GstVisitor());
            modern += Gst.of(i);
        }
        System.out.println("GST with a visitor: " + visitor
            + ", with a sealed switch: " + modern);
    }
}
  1. 2the modern alternative
  1. Double dispatch: the call goes to the item’s own accept, which knows its own type and calls the matching visitor method. No instanceof anywhere.
  2. Since Java 21, a switch over a sealed interface does the same job more simply. The compiler knows every possible type and refuses to compile if one is missing, which was Visitor’s main safety benefit.
Text
GST with a visitor: 38982, with a sealed switch: 38982

Mistakes and variations

  • Using Visitor when types change often. Every new item type means editing every visitor.
  • Visitor in a language with pattern matching. In modern Java, Kotlin or Scala, a sealed type plus switch usually replaces it. Knowing both, and why the newer one is simpler, is a strong interview answer.

When not to use it

When the set of types isn’t fixed, or there are only one or two operations.

Where it shows up

Compilers and query engines (operations on syntax trees) are the classic home. In this track, the sealed Split records in Splitwise and the sealed piece hierarchy in chess are the “fixed set of types” that visitors or sealed switches work best with.

Which one, when?

You hearReach forNot to be confused with
“The rule for X will change”StrategyTemplate Method (fixed when created; can’t mix)
“When X happens, several things should react”ObserverMediator (actively coordinates)
“What’s allowed depends on the current status”StateStrategy (chosen from outside, not by the object’s situation)
“We need undo, a queue, or a replay log”CommandMemento (whole snapshots instead of reversible actions)
“Same steps, slightly different details”Template MethodStrategy
“Several checks before the real work”Chain of ResponsibilityDecorator (adds behaviour; always passes on)
“Go through the results without loading them all”Iterator—
“Too many objects talking to each other”MediatorObserver, Facade
“Go back to how it was”MementoCommand
“A new report over a fixed set of types”Visitor, or a sealed switch—

The one other GoF behavioral pattern, Interpreter (building an evaluator for a small language from a class per grammar rule), almost never comes up in LLD interviews. Know the name.

How this shows up in interviews

Behavioral patterns appear in two moments of almost every LLD round:

  • When you find what will change. “Pricing will change” is Strategy; “boards should update” is Observer; “what’s allowed depends on the status” is State. The parking lot walkthrough shows that thinking from start to finish.
  • When the follow-ups arrive. Undo is Command or Memento. “Add a check before every request” is a chain. “React to this event too” is one more observer.
Interviewer

Interviewer: Orders go placed, accepted, picked up, delivered, and can be cancelled. How would you model that?

You

You: With five states I’d write the transitions as a table first, events across and states down, so we can agree which moves are allowed. If each state has real behaviour, like a refund rule that depends on the state, I’d make each state a class that returns the next state, with a default that refuses anything not listed. If it’s only “which moves are allowed”, an enum plus that table is simpler. Either way, two threads firing events at one order need a lock or a compare-and-set on the state, and listeners like SMS and analytics hear about changes after the state is saved, outside any lock.