Low-Level Design · pure Java, pattern by pattern

Learn LLD by building a coffee shop, one pattern at a time.

A build-along reference that grows a real ordering-and-fulfillment engine in plain Java, no framework. Each milestone starts from a code smell, reaches for the Gang-of-Four pattern that dissolves it, and leaves a seam that absorbs the next change — all under the discipline of SOLID. Read it, code it, run the test, tick it off.

DomainBuildCreateStructureBehaveAssemble
14milestones
14GoF patterns
SOLIDthe through-line
noframework — just Java + JUnit
M00

The domain & SOLID: the rubric before the patterns

Why a shared vocabulary + the five laws every pattern serves  ·  Write the value objects + one enum  ·  New immutability, SOLID, tests-first

We are building Brewline — the ordering and fulfillment core of a coffee shop, in pure Java, no framework. It is small enough to hold in your head and rich enough that every Gang-of-Four pattern earns its place for a real reason, not as a demo. You will feel the smell first — the if/else ladder, the constructor with nine arguments, the class that edits five subsystems — and then reach for the pattern that dissolves it.

Ubiquitous language

Fix the nouns before any code. A Beverage is something the shop can make. An Order is a customer's request for one or more line items. A Barista fulfills orders. Money is an amount + currency — never a raw double (floating point and cents do not mix).

bash
mkdir brewline && cd brewline
# Gradle (or Maven — either is fine; no framework, just JUnit for tests)
gradle init --type java-application --test-framework junit-jupiter
# package layout
mkdir -p src/main/java/com/brewline/{domain,menu,order,pricing,payment,fulfillment,app}
mkdir -p src/test/java/com/brewline

Money — an immutable value object

Value objects are equal by their fields, are immutable, and validate themselves at construction. A Java record gives you equality, hashCode, and finality for free; add the invariants in a compact constructor.

java
package com.brewline.domain;

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Currency;
import java.util.Objects;

public record Money(BigDecimal amount, Currency currency) {

    public Money {                                   // compact constructor = invariants
        Objects.requireNonNull(amount);
        Objects.requireNonNull(currency);
        amount = amount.setScale(2, RoundingMode.HALF_EVEN);
    }

    public static Money of(String amount, String code) {
        return new Money(new BigDecimal(amount), Currency.getInstance(code));
    }

    public Money plus(Money other) {
        require(other);
        return new Money(amount.add(other.amount), currency);
    }

    public Money times(int n) { return new Money(amount.multiply(BigDecimal.valueOf(n)), currency); }

    public Money percentOff(int pct) {
        BigDecimal keep = BigDecimal.valueOf(100 - pct).movePointLeft(2);
        return new Money(amount.multiply(keep), currency);
    }

    private void require(Money other) {
        if (!currency.equals(other.currency))
            throw new IllegalArgumentException("currency mismatch: " + currency + " vs " + other.currency);
    }
}

SOLID, as a checklist you actually use

You do not need to recite these. You need to notice when you are breaking one, because each break is where a pattern is about to help:

  • Single responsibility — a class changes for one reason. When Order starts computing tax and printing receipts, split it.
  • Open/closed — open to extension, closed to modification. Adding a drink should never mean editing a switch. (Factory, Strategy.)
  • Liskov — a subtype must be usable wherever its base is. A DecafEspresso that throws on brew() breaks this.
  • Interface segregation — small, role-focused interfaces beat one fat one. A Priceable is not a Brewable.
  • Dependency inversion — depend on abstractions. CafeService depends on a PaymentGateway interface, never on Stripe's SDK class.
M01

Builder: a customized drink without a telescoping constructor

Smell a constructor with 8 optional args  ·  Pattern Builder (creational)  ·  Fix readable, validated, immutable construction

A latte can be small/medium/large, hot or iced, 1–3 shots, with a milk choice and an optional flavor. Model that with constructors and you get the telescoping constructor — a dozen overloads, or one constructor whose call site reads new Beverage("Latte", 2, true, false, MED, OAT, null). Nobody can read the fourth boolean.

java
package com.brewline.domain;

public final class Beverage {
    public enum Size { SMALL, MEDIUM, LARGE }
    public enum Milk { WHOLE, OAT, ALMOND, NONE }

    private final String name;
    private final Size size;
    private final int shots;
    private final boolean iced;
    private final Milk milk;

    private Beverage(Builder b) {                 // only the Builder can construct it
        this.name = b.name; this.size = b.size; this.shots = b.shots;
        this.iced = b.iced; this.milk = b.milk;
    }

    public String name() { return name; }
    public Size size()   { return size; }
    public int shots()   { return shots; }
    public boolean iced(){ return iced; }
    public Milk milk()   { return milk; }

    public static Builder named(String name) { return new Builder(name); }

    public static final class Builder {
        private final String name;
        private Size size = Size.MEDIUM;          // sensible defaults
        private int shots = 1;
        private boolean iced = false;
        private Milk milk = Milk.WHOLE;

        Builder(String name) { this.name = name; }
        public Builder size(Size s)   { this.size = s;  return this; }  // fluent: return this
        public Builder shots(int n)   { this.shots = n; return this; }
        public Builder iced(boolean b){ this.iced = b;  return this; }
        public Builder milk(Milk m)   { this.milk = m;  return this; }

        public Beverage build() {
            if (shots < 1 || shots > 4) throw new IllegalStateException("shots 1..4");
            if (name == null || name.isBlank()) throw new IllegalStateException("name required");
            return new Beverage(this);            // validate once, at the boundary
        }
    }
}

Now the call site is self-documenting and the object is immutable once built:

java
Beverage latte = Beverage.named("Latte")
        .size(Beverage.Size.LARGE)
        .shots(2)
        .milk(Beverage.Milk.OAT)
        .iced(true)
        .build();
M02

Factory Method & Abstract Factory: create by intent, not by switch

Smell switch(type) repeated wherever a drink is made  ·  Pattern Factory Method + Abstract Factory  ·  Fix one place knows how to build

The menu grows: espresso, latte, cold brew, matcha. If every caller writes switch(type){ case ESPRESSO: ... }, adding a drink means editing every switch — the open/closed violation. A factory centralizes creation so callers ask for what they want, not how it is built.

Factory Method — a family of standard recipes

java
package com.brewline.menu;

import com.brewline.domain.Beverage;
import java.util.Map;
import java.util.function.Supplier;

public final class BeverageFactory {
    // registry of recipes: adding a drink = adding one entry, not editing a switch
    private final Map<String, Supplier<Beverage>> recipes = Map.of(
        "espresso", () -> Beverage.named("Espresso").shots(2).size(Beverage.Size.SMALL).build(),
        "latte",    () -> Beverage.named("Latte").shots(1).milk(Beverage.Milk.WHOLE).build(),
        "coldbrew", () -> Beverage.named("Cold Brew").iced(true).build()
    );

    public Beverage create(String key) {
        Supplier<Beverage> recipe = recipes.get(key.toLowerCase());
        if (recipe == null) throw new IllegalArgumentException("no such drink: " + key);
        return recipe.get();
    }
}

Registering a Supplier keyed by name is the modern, data-driven face of Factory Method — new drinks are new map entries. The classic OO form (an abstract createBeverage() overridden per subclass) is equivalent; use whichever reads cleaner for your team.

Abstract Factory — a whole regional menu at once

Now the shop has a Milan menu and a Tokyo menu — different drinks and different default cup, default milk. An Abstract Factory produces a family of related objects meant to be used together, so you can never accidentally mix a Milan cup with a Tokyo lid.

java
public interface MenuFactory {                     // the abstract factory
    Beverage signatureDrink();
    Beverage.Milk defaultMilk();
    String cupStyle();
}

public final class MilanMenu implements MenuFactory {
    public Beverage signatureDrink() { return Beverage.named("Ristretto").shots(2).build(); }
    public Beverage.Milk defaultMilk() { return Beverage.Milk.WHOLE; }
    public String cupStyle() { return "ceramic-demitasse"; }
}

public final class TokyoMenu implements MenuFactory {
    public Beverage signatureDrink() { return Beverage.named("Hojicha Latte").milk(Beverage.Milk.OAT).build(); }
    public Beverage.Milk defaultMilk() { return Beverage.Milk.OAT; }
    public String cupStyle() { return "paper-8oz"; }
}
M03

Singleton: exactly one menu registry — done safely

Smell the menu re-loaded on every call  ·  Pattern Singleton (creational)  ·  Fix one instance, thread-safe, testable

The menu and shop config should be loaded once and shared. The naive singleton (if (instance == null) instance = new ...) is a race under threads. Two idioms are correct and boring; prefer them.

The enum singleton — Joshua Bloch's default

java
package com.brewline.menu;

import com.brewline.domain.Money;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

public enum MenuRegistry {
    INSTANCE;                                       // the single instance, created safely by the JVM

    private final Map<String, Money> priceBook = new ConcurrentHashMap<>();

    public void setPrice(String drink, Money price) { priceBook.put(drink, price); }
    public Money priceOf(String drink) {
        Money p = priceBook.get(drink);
        if (p == null) throw new IllegalStateException("unpriced drink: " + drink);
        return p;
    }
}
// use:  MenuRegistry.INSTANCE.priceOf("latte")

The lazy-holder idiom — when you need lazy init

java
public final class ShopConfig {
    private ShopConfig() { /* load from file/env here */ }

    private static final class Holder {            // loaded only on first access, thread-safe by classloading
        static final ShopConfig INSTANCE = new ShopConfig();
    }
    public static ShopConfig get() { return Holder.INSTANCE; }
}
M04

Decorator: stack add-ons without a class explosion

Smell a LatteWithOatAndExtraShot class per combo  ·  Pattern Decorator (structural)  ·  Fix wrap to add price + description at runtime

Add-ons combine explosively: extra shot, oat upgrade, vanilla, whip. Modeling each combination as a subclass is thousands of classes. Decorator wraps a base drink in thin layers, each adding to the price and description — the canonical coffee-shop pattern for a reason.

java
package com.brewline.menu;

import com.brewline.domain.Money;

public interface Drink {                            // the component
    String description();
    Money cost();
}

// a concrete base drink
public final class BaseDrink implements Drink {
    private final String name; private final Money price;
    public BaseDrink(String name, Money price) { this.name = name; this.price = price; }
    public String description() { return name; }
    public Money cost() { return price; }
}

// the decorator base: IS-A Drink and HAS-A Drink
public abstract class AddOn implements Drink {
    protected final Drink inner;
    protected AddOn(Drink inner) { this.inner = inner; }
}

public final class ExtraShot extends AddOn {
    public ExtraShot(Drink d) { super(d); }
    public String description() { return inner.description() + " + extra shot"; }
    public Money cost() { return inner.cost().plus(Money.of("0.80", "USD")); }
}

public final class OatMilk extends AddOn {
    public OatMilk(Drink d) { super(d); }
    public String description() { return inner.description() + " + oat"; }
    public Money cost() { return inner.cost().plus(Money.of("0.60", "USD")); }
}
java
Drink order = new OatMilk(new ExtraShot(new BaseDrink("Latte", Money.of("4.50", "USD"))));
order.description();  // "Latte + extra shot + oat"
order.cost();         // 4.50 + 0.80 + 0.60 = 5.90 USD
M05

Composite: a combo priced like a single item

Smell callers branch on ‘one drink or a bundle?’  ·  Pattern Composite (structural)  ·  Fix a bundle IS-A line item

A meal deal (latte + croissant) should be orderable, discountable, and priceable exactly like a single drink. Composite lets a group of items implement the same interface as a leaf, so client code treats one and many uniformly — no instanceof branching.

java
package com.brewline.order;

import com.brewline.domain.Money;
import java.util.ArrayList;
import java.util.List;

public interface LineItem {                         // the common component
    String label();
    Money price();
}

public final class SingleItem implements LineItem {  // leaf
    private final String label; private final Money price;
    public SingleItem(String label, Money price) { this.label = label; this.price = price; }
    public String label() { return label; }
    public Money price() { return price; }
}

public final class Combo implements LineItem {       // composite
    private final String label;
    private final List<LineItem> items = new ArrayList<>();
    public Combo(String label) { this.label = label; }
    public Combo add(LineItem i) { items.add(i); return this; }

    public String label() { return label; }
    public Money price() {                           // recursion is transparent to callers
        Money total = Money.of("0.00", "USD");
        for (LineItem i : items) total = total.plus(i.price());
        return total.percentOff(10);                 // combos get 10% off
    }
}
java
LineItem meal = new Combo("Morning Deal")
        .add(new SingleItem("Latte", Money.of("4.50", "USD")))
        .add(new SingleItem("Croissant", Money.of("3.00", "USD")));
meal.price();   // (4.50 + 3.00) * 0.90 = 6.75 USD — plugs into an Order like any item
M06

Adapter & Facade: bolt on a payment gateway, hide the mess

Smell the domain imports Stripe's SDK directly  ·  Pattern Adapter + Facade (structural)  ·  Fix depend on your own port; one entry point

A third-party charge SDK speaks its own language (StripeClient.createCharge(cents, "usd")). If your domain calls it directly, you have inverted dependency inversion — your core now depends on a vendor. An Adapter translates the vendor's shape into your interface (a ‘port’), so swapping vendors touches one class.

java
package com.brewline.payment;

import com.brewline.domain.Money;

public interface PaymentGateway {                   // your port — the domain depends on THIS
    boolean charge(Money amount, String token);
}

// vendor SDK (imagine it comes from a jar you don't control)
class StripeClient {
    boolean createCharge(long amountCents, String currency, String source) { /* ... */ return true; }
}

// the adapter: makes StripeClient look like a PaymentGateway
public final class StripeAdapter implements PaymentGateway {
    private final StripeClient stripe = new StripeClient();
    public boolean charge(Money amount, String token) {
        long cents = amount.amount().movePointRight(2).longValueExact();
        return stripe.createCharge(cents, amount.currency().getCurrencyCode().toLowerCase(), token);
    }
}

Facade — one door into the subsystem

Placing an order really means: validate, price, charge, enqueue, notify. A Facade gives callers (a CLI, a web handler, a test) a single, simple method and hides the wiring behind it. This is also your composition root — the one place that knows the concrete classes.

java
package com.brewline.app;

import com.brewline.domain.Money;
import com.brewline.order.Order;
import com.brewline.payment.PaymentGateway;

public final class CafeService {                    // the facade
    private final PaymentGateway payments;          // injected abstractions (dependency inversion)
    // ... pricing, validation, fulfillment collaborators wired in the constructor

    public CafeService(PaymentGateway payments /*, ... */) { this.payments = payments; }

    public String placeOrder(Order order, String paymentToken) {
        // 1. validate  2. price  3. charge  4. enqueue  5. notify
        Money total = order.total();
        if (!payments.charge(total, paymentToken)) throw new IllegalStateException("payment declined");
        // enqueue + notify happen here (M08–M10)
        return order.id();
    }
}
M07

Strategy: swap discounts and payment methods at runtime

Smell a growing if (promo == ...) in the pricer  ·  Pattern Strategy (behavioral)  ·  Fix the algorithm is a plug-in object

Pricing rules multiply: happy-hour 20% off, student 10%, buy-one-get-one, none. Baking these into the order class with a switch violates open/closed and mixes concerns. Strategy makes each rule an object implementing a common interface, chosen at runtime.

java
package com.brewline.pricing;

import com.brewline.domain.Money;

@FunctionalInterface
public interface DiscountStrategy {
    Money apply(Money subtotal);
}

public final class NoDiscount implements DiscountStrategy {
    public Money apply(Money s) { return s; }
}

public final class PercentOff implements DiscountStrategy {
    private final int pct;
    public PercentOff(int pct) { this.pct = pct; }
    public Money apply(Money s) { return s.percentOff(pct); }
}

// because the interface is functional, a strategy can also be a lambda:
DiscountStrategy happyHour = subtotal -> subtotal.percentOff(20);
java
public final class Pricer {
    private DiscountStrategy discount = new NoDiscount();
    public void use(DiscountStrategy s) { this.discount = s; }   // swap at runtime
    public Money finalPrice(Money subtotal) { return discount.apply(subtotal); }
}
M08

State: a legal order lifecycle, enforced by types

Smell a status field guarded by scattered ifs  ·  Pattern State (behavioral)  ·  Fix each state owns its transitions

An order moves PLACED → BREWING → READY → COLLECTED, or is CANCELLED — but only from certain states. If you guard this with if (status == PLACED && ...) everywhere, the rules smear across the codebase and illegal jumps slip in. State makes each state an object that knows only its own legal moves.

java
package com.brewline.order;

public interface OrderState {
    OrderState brew();
    OrderState ready();
    OrderState collect();
    OrderState cancel();
    String name();
    default OrderState illegal(String action) {
        throw new IllegalStateException("cannot " + action + " while " + name());
    }
}

public final class Placed implements OrderState {
    public OrderState brew()    { return new Brewing(); }
    public OrderState ready()   { return illegal("ready"); }
    public OrderState collect() { return illegal("collect"); }
    public OrderState cancel()  { return new Cancelled(); }
    public String name() { return "PLACED"; }
}

public final class Brewing implements OrderState {
    public OrderState brew()    { return illegal("brew"); }
    public OrderState ready()   { return new Ready(); }
    public OrderState collect() { return illegal("collect"); }
    public OrderState cancel()  { return illegal("cancel"); }  // too late to cancel
    public String name() { return "BREWING"; }
}
// Ready.collect() -> Collected;  Collected & Cancelled are terminal (all moves illegal)
java
public final class Order {
    private OrderState state = new Placed();
    public void brew()    { state = state.brew(); }
    public void ready()   { state = state.ready(); }
    public void collect() { state = state.collect(); }
    public void cancel()  { state = state.cancel(); }
    public String status(){ return state.name(); }
    // ... id(), total(), items() from earlier milestones
}
M09

Observer: broadcast status to displays, kitchen, and customer

Smell the order poking three subsystems directly  ·  Pattern Observer (behavioral)  ·  Fix publish an event; listeners subscribe

When an order becomes READY, the barista display, the customer's SMS, and the kitchen ticket printer all need to know. If Order calls each of them, it now depends on three subsystems and changing one edits the order. Observer inverts that: the order publishes, and anyone interested subscribes.

java
package com.brewline.order;

import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;

public interface OrderListener {                    // the observer
    void onStatusChanged(String orderId, String newStatus);
}

public abstract class Observable {                  // the subject
    private final List<OrderListener> listeners = new CopyOnWriteArrayList<>();
    public void subscribe(OrderListener l)   { listeners.add(l); }
    public void unsubscribe(OrderListener l) { listeners.remove(l); }
    protected void publish(String orderId, String status) {
        for (OrderListener l : listeners) l.onStatusChanged(orderId, status);
    }
}
java
// Order extends Observable; each transition publishes:
public void ready() { state = state.ready(); publish(id(), status()); }

// listeners are tiny and independent:
OrderListener baristaDisplay = (id, status) -> System.out.println("[display] " + id + " -> " + status);
OrderListener customerSms    = (id, status) -> { if (status.equals("READY")) sendSms(id); };

order.subscribe(baristaDisplay);
order.subscribe(customerSms);
M10

Command & Memento: queue actions, and undo them

Smell ‘undo’ and ‘retry’ hard-coded per action  ·  Pattern Command + Memento (behavioral)  ·  Fix actions are objects; snapshots restore state

The cashier wants to place, modify, and undo orders, and the kitchen wants a queue it can process and replay. Command turns each action into an object with execute() and undo(), so a history stack gives you undo/redo for free. Memento captures a snapshot to restore.

java
package com.brewline.app;

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

// captures the cart's state so a command can restore it (Memento)
public record CartMemento(java.util.List<String> lines) { }

public final class AddItemCommand implements Command {
    private final Cart cart; private final String item; private CartMemento before;
    public AddItemCommand(Cart cart, String item) { this.cart = cart; this.item = item; }
    public void execute() { before = cart.snapshot(); cart.add(item); }
    public void undo()    { cart.restore(before); }
}
java
public final class CommandBus {                     // invoker + history
    private final java.util.Deque<Command> history = new java.util.ArrayDeque<>();
    public void run(Command c) { c.execute(); history.push(c); }
    public void undo() { if (!history.isEmpty()) history.pop().undo(); }
}
// bus.run(new AddItemCommand(cart, "latte"));  bus.undo();  // latte removed
M11

Chain of Responsibility: order validation as a pipeline

Smell one method with five nested validation ifs  ·  Pattern Chain of Responsibility (behavioral)  ·  Fix independent, reorderable checks

Before an order is accepted: is the shop open? is every item in stock? is the payment token valid? Cramming these into one method makes each rule tangled and un-testable in isolation. Chain of Responsibility makes each check a link that either passes the request along or rejects it.

java
package com.brewline.order;

public abstract class Validator {
    private Validator next;
    public Validator linkTo(Validator next) { this.next = next; return next; }  // fluent chaining

    public final void validate(Order order) {
        check(order);
        if (next != null) next.validate(order);
    }
    protected abstract void check(Order order);     // each link's single rule
}

public final class ShopOpenValidator extends Validator {
    protected void check(Order o) {
        if (!com.brewline.app.ShopClock.isOpen()) throw new IllegalStateException("shop closed");
    }
}
public final class StockValidator extends Validator {
    protected void check(Order o) { /* assert every item in inventory */ }
}
public final class PaymentTokenValidator extends Validator {
    protected void check(Order o) { /* assert token present + well-formed */ }
}
java
Validator chain = new ShopOpenValidator();
chain.linkTo(new StockValidator()).linkTo(new PaymentTokenValidator());
chain.validate(order);   // runs each in order; first failure throws
M12

Template Method & Visitor: fixed recipe, open reports

Smell duplicated brew steps; report logic scattered across item types  ·  Pattern Template Method + Visitor (behavioral)  ·  Fix skeleton with hooks; operations that visit the tree

Every drink is prepared by the same skeleton — grind, extract, add milk, finish — but the milk step differs per drink. Template Method puts the invariant sequence in a base class and leaves the varying steps as overridable hooks.

java
package com.brewline.fulfillment;

public abstract class Preparation {
    public final void prepare() {                   // the template: fixed order, do not override
        grind();
        extract();
        addMilk();                                  // the varying step (hook)
        finish();
    }
    protected void grind()   { /* shared */ }
    protected void extract() { /* shared */ }
    protected abstract void addMilk();              // subclasses fill this in
    protected void finish()  { /* shared */ }
}

public final class LattePrep extends Preparation {
    protected void addMilk() { /* steam + pour microfoam */ }
}
public final class EspressoPrep extends Preparation {
    protected void addMilk() { /* none */ }
}

Visitor — add operations without touching the items

You want a receipt, then analytics, then a kitchen ticket — all walking the same LineItem tree from M05. Adding these as methods on every item class bloats them. Visitor puts each new operation in its own class that visits the items.

java
public interface ItemVisitor {
    void visit(SingleItem item);
    void visit(Combo combo);
}
// LineItem gains:  void accept(ItemVisitor v);
//   SingleItem.accept(v){ v.visit(this); }   Combo.accept(v){ v.visit(this); items.forEach(i->i.accept(v)); }

public final class ReceiptVisitor implements ItemVisitor {
    private final StringBuilder sb = new StringBuilder();
    public void visit(SingleItem i) { sb.append(i.label()).append("  ").append(i.price()).append('\n'); }
    public void visit(Combo c)      { sb.append("-- ").append(c.label()).append(" --\n"); }
    public String receipt() { return sb.toString(); }
}
M13

Assemble: wire it up, then make it concurrent

Why patterns pay off in composition, not isolation  ·  Write the fulfillment queue + workers  ·  New thread-safe hand-off

Now the pieces click together. CafeService (Facade) validates via the Chain, prices via a Strategy, charges via an Adapter, enqueues a Command, and the order publishes state changes via Observer as baristas move it through its State machine. A real shop fulfills orders on worker threads, so the queue must be thread-safe.

java
package com.brewline.fulfillment;

import com.brewline.order.Order;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;

public final class BaristaStation {
    private final BlockingQueue<Order> queue = new LinkedBlockingQueue<>();

    public void enqueue(Order o) { queue.add(o); }  // producer: CafeService, on any thread

    public void runBarista() {                       // consumer: one per worker thread
        while (!Thread.currentThread().isInterrupted()) {
            try {
                Order o = queue.take();              // blocks until work arrives
                o.brew(); o.ready();                 // State transitions -> Observer fires
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt(); break;
            }
        }
    }
}
java
BaristaStation station = new BaristaStation();
var pool = java.util.concurrent.Executors.newFixedThreadPool(2);
pool.submit(station::runBarista);
pool.submit(station::runBarista);   // two baristas draining one shared, thread-safe queue
A

Choosing patterns & driving an LLD interview

Optional the meta-skill: when NOT to use a pattern, and how to talk about it

A one-line map of what you built

text
CREATIONAL   Builder ......... complex, validated construction
             Factory ......... create by intent, hide concrete classes
             Singleton ....... exactly one, shared (use sparingly)
STRUCTURAL   Decorator ....... add behavior by wrapping, at runtime
             Composite ....... treat one and many uniformly
             Adapter ......... translate a foreign interface to yours
             Facade .......... one simple door into a subsystem
BEHAVIORAL   Strategy ........ swap an algorithm
             State ........... swap behavior as the object transitions
             Observer ........ publish/subscribe to events
             Command ......... action-as-object -> queue, log, undo
             Chain ........... a pipeline of independent handlers
             Template Method . fixed skeleton, overridable steps
             Visitor ......... add operations over a stable type set

The most valuable skill: NOT reaching for one

  • A pattern that removes no real pressure is over-engineering. Two subclasses do not need an Abstract Factory. A field that never varies does not need a Strategy.
  • Prefer the smallest thing that works: a lambda over a Strategy class, a List<Predicate> over a Chain, a record over a Builder.
  • Patterns are a vocabulary for a design you already justified — not a shopping list to satisfy.

Driving an LLD interview with Brewline

  • Clarify & scope — nail the nouns and one core flow (place → pay → fulfill) before drawing classes. State your assumptions out loud.
  • Start with the domain — value objects and interfaces first (Money, LineItem, PaymentGateway). Patterns emerge from pressure; do not lead with them.
  • Name the smell, then the fix — ‘this switch will grow, so I will use a factory’ shows judgment, which is what is actually being graded.
  • Talk trade-offs — every pattern here has a cost (more classes, indirection, harder debugging). Saying the cost is a senior signal.
  • Leave seams for change — end by naming the axis you would extend next (loyalty points? multi-store inventory?) and which seam absorbs it.
B

The same patterns, as classic interview problems

Optional map Brewline's 14 patterns onto the LLD questions you actually get asked

Brewline is one coherent domain, which is the best way to learn the patterns. But interviews hand you a different problem each time. The reassuring truth: the same fourteen patterns cover almost all of them — only the nouns change. Here is where each pattern resurfaces across the canonical LLD questions.

text
PARKING LOT
  Factory ......... create Spot / Vehicle by type
  Strategy ........ fee calculation (hourly, flat, tiered)
  State ........... spot: FREE -> OCCUPIED -> RESERVED
  Composite ....... Lot -> Level -> Row -> Spot
  Observer ........ availability boards react to entry/exit
  Singleton ....... the lot registry / config

ELEVATOR SYSTEM
  State ........... IDLE / MOVING_UP / MOVING_DOWN / DOORS_OPEN
  Strategy ........ scheduling (SCAN, LOOK, nearest-car)
  Command ......... button presses queued as requests
  Observer ........ floor requests notify the controller
  Singleton ....... the elevator controller

RIDE-HAILING DISPATCH
  Strategy ........ matching + surge pricing
  State ........... ride: REQUESTED -> ASSIGNED -> ONGOING -> DONE
  Observer ........ driver location + status streams
  Command ......... request / cancel (with undo)
  Chain ........... rider + driver + payment validation
  Adapter ......... maps and payment providers

SPLITWISE
  Strategy ........ split: equal / exact / percentage / shares
  Builder ......... construct a multi-party Expense
  Command ......... add / edit / delete expense (undo)
  Composite ....... groups of expenses
  Visitor ......... settle-up + simplify-debts reports
  Observer ........ balance-changed notifications

VENDING MACHINE
  State ........... IDLE / HAS_MONEY / DISPENSING / SOLD_OUT
  Strategy ........ payment method
  Command ......... select / refund
  Observer ........ low-stock alerts
  Singleton ....... inventory

NOTIFICATION SERVICE
  Strategy ........ channel: email / SMS / push
  Factory ......... create the channel
  Decorator ....... retry / rate-limit / templating wrappers
  Chain ........... validation -> spam filter -> priority
  Template Method . the send() pipeline
  Observer ........ event -> subscribers

Read that vertically and the point lands: State shows up wherever a thing has a lifecycle, Strategy wherever a rule varies, Observer wherever something must react to change, Factory wherever creation branches on type. You are not memorizing thirty problems — you are recognizing the same handful of pressures.