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.
The domain & SOLID: the rubric before the patterns
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).
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/brewlineMoney — 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.
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
Orderstarts 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
DecafEspressothat throws onbrew()breaks this. - Interface segregation — small, role-focused interfaces beat one fat one. A
Priceableis not aBrewable. - Dependency inversion — depend on abstractions.
CafeServicedepends on aPaymentGatewayinterface, never on Stripe's SDK class.
Builder: a customized drink without a telescoping constructor
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.
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:
Beverage latte = Beverage.named("Latte")
.size(Beverage.Size.LARGE)
.shots(2)
.milk(Beverage.Milk.OAT)
.iced(true)
.build();Factory Method & Abstract Factory: create by intent, not by switch
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
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.
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"; }
}Singleton: exactly one menu registry — done safely
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
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
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; }
}Decorator: stack add-ons without a class explosion
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.
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")); }
}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 USDComposite: a combo priced like a single 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.
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
}
}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 itemAdapter & Facade: bolt on a payment gateway, hide the mess
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.
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.
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();
}
}Strategy: swap discounts and payment methods at runtime
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.
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);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); }
}State: a legal order lifecycle, enforced by types
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.
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)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
}Observer: broadcast status to displays, kitchen, and customer
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.
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);
}
}// 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);Command & Memento: queue actions, and undo them
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.
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); }
}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 removedChain of Responsibility: order validation as a pipeline
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.
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 */ }
}Validator chain = new ShopOpenValidator();
chain.linkTo(new StockValidator()).linkTo(new PaymentTokenValidator());
chain.validate(order); // runs each in order; first failure throwsTemplate Method & Visitor: fixed recipe, open reports
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.
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.
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(); }
}Assemble: wire it up, then make it concurrent
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.
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;
}
}
}
}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 queueChoosing patterns & driving an LLD interview
A one-line map of what you built
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 setThe 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.
The same patterns, as classic interview problems
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.
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 -> subscribersRead 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.