I’ve spent years shipping software that uses most of the classic design patterns — often without knowing their names. These notes are me going back and formalizing that knowledge, and the thing that confused me most on the way back in is worth starting with: almost all of the patterns look identical.
Strategy, State, Command, Observer, Decorator, Adapter — open any patterns book and every one of them is “define an interface, write implementations, let callers depend on the interface.” That’s… just polymorphism. It’s the standard advice every senior engineer already follows: program to an interface, not an implementation. So why are there twenty-three named patterns for one trick?
Because patterns are intents, not shapes. The mechanism — an interface plus runtime polymorphism — is the same everywhere, the way every chess opening is “just moving pieces legally.” True at the mechanism level, useless at the level where decisions happen. What makes Strategy and State two different patterns is not their class diagrams (near-identical) but which problem the interface is aimed at. Learning patterns is not learning shapes; it’s acquiring a vocabulary of intents, so you can look at an if/else ladder over payment types and recognize “interchangeable algorithms → Strategy” — or look at status-dependent behavior and recognize “lifecycle → State.”
That’s also what the three famous Gang-of-Four buckets actually classify. Not mechanism — problem kind:
| Bucket | The problem is… | The usual suspects |
|---|---|---|
| Creational | making objects | Builder, Factory Method, Abstract Factory, Singleton |
| Structural | how objects fit together into bigger shapes — wrap it (Decorator, Proxy), translate it (Adapter), simplify it (Facade), nest it (Composite) | Decorator, Adapter, Proxy, Facade, Composite |
| Behavioral | how responsibility and communication flow between objects at runtime — who decides (Strategy, State), who gets told (Observer), who handles it (Chain of Responsibility) | Strategy, State, Command, Observer, Chain of Responsibility, Visitor |
And here is the whole behavioral core — the seven lines this series keeps coming back to, one per pattern:
- Strategy — interface over interchangeable algorithms; the caller picks one.
- State — same shape, but the object switches its own implementation as its lifecycle progresses.
- Command — interface over a request itself, so you can queue it, log it, undo it.
- Observer — interface over “whoever wants to be notified.”
- Visitor — interface over operations added to a fixed set of shapes, without editing the shapes.
- Decorator (structural, for contrast) — an implementation that wraps another of the same interface to add behavior.
- Adapter (structural, for contrast) — an interface used to make a foreign thing look native.
Mechanically near-identical. Seven different intents. Keep that list; the rest of this post makes the first five concrete, and later notes will do the others. Each one gets a concrete, production-shaped example — and ends with when not to use it.
01 · Strategy — the pattern you use every day without noticing#
Every pattern should be introduced by the pain it removes, so here’s the pain. I’m using an e-commerce order system as the running example for this entire series — same world, every pattern.
You’re building checkout. Orders can be paid by card, PayPal, or wallet. The code everyone writes first — I certainly have — looks like this:
public void chargeOrder(Order order, String method) {
if (method.equals("card")) {
Stripe stripe = new Stripe(config.stripeKey());
stripe.charges().create(order.total(), order.cardToken());
} else if (method.equals("paypal")) {
PayPalClient paypal = new PayPalClient(config.paypalClientId());
paypal.orders().create(order.total());
} else if (method.equals("wallet")) {
walletRepository.deduct(order.customerId(), order.total());
}
}
This works — until it doesn’t. Watch what six months of real product life does to it:
- Product adds “pay later” and gift cards → the ladder grows.
- Refunds arrive → you write a second if/else ladder with the same branches in
refundOrder. Then a third ingetPaymentStatus. Those ladders must now be kept in sync by human discipline — which is to say, they drift. - Testing
chargeOrderrequires mocking Stripe, PayPal, and the wallet DB, even when you only care about one path.
Name what’s wrong: the algorithm choice is tangled with the algorithm implementations. Every new payment method means editing a function that already works, re-risking every existing path.
Strategy’s core swap: instead of an if/else ladder deciding how to charge, every payment method becomes its own object behind one shared interface — and the caller just picks one from a table.
From ladder to lookup table#
Strategy says: define one interface for “a way of doing this thing,” implement each way as its own object, and let the caller hold any of them without knowing which.
public interface PaymentStrategy {
PaymentResult charge(Order order);
RefundResult refund(Payment payment);
}
public class CardPayment implements PaymentStrategy {
private final Stripe stripe;
public CardPayment(Stripe stripe) { this.stripe = stripe; }
@Override
public PaymentResult charge(Order order) { /* all Stripe logic lives here, and only here */ }
@Override
public RefundResult refund(Payment payment) { /* Stripe refund */ }
}
public class PayPalPayment implements PaymentStrategy { /* PayPal logic */ }
public class WalletPayment implements PaymentStrategy { /* internal ledger logic */ }
The selection collapses into a lookup table:
Map<PaymentMethod, PaymentStrategy> strategies = Map.of(
PaymentMethod.CARD, new CardPayment(stripe),
PaymentMethod.PAYPAL, new PayPalPayment(paypal),
PaymentMethod.WALLET, new WalletPayment(walletRepository)
);
public PaymentResult chargeOrder(Order order) {
return strategies.get(order.paymentMethod()).charge(order);
}
Three things changed structurally — and they’re the whole point, not side effects:
- Adding gift cards is now a new file, not an edit. Write
GiftCardPayment, register it in the table, never touch code that already works. This is the Open/Closed Principle (add new behavior without editing working code) actually earning its keep. - The charge/refund/status ladders can’t drift, because there are no ladders — the interface forces every payment method to answer all the questions in one place.
- Tests get small and focused. Test
PayPalPaymentagainst a fake PayPal client alone; testchargeOrderwith a stub strategy and zero external mocks.
Where you’ve already met it#
The reason Strategy comes first in these notes: it’s recognition, not learning.
Comparator<T>— everylist.sort(comparator)call injects a strategy. The sort algorithm is fixed; the “how do two items compare” part is the caller’s to swap. (JavaScript’sarray.sort(fn)is the same pattern in a different accent.)- Spring Security’s
AuthenticationProvider— one implementation per auth mechanism (DAO, LDAP, JWT, OAuth2), all behind one interface, picked at runtime. Node’s Passport.js does the identical thing and even calls them strategies:passport.use(new GoogleStrategy(...)). RowMapper<T>in Spring JDBC, Jackson’s serializers, Kafka’s compression codecs (gzip/snappy/zstdbehind one config knob), per-country tax calculators, shipping-rate calculators, pricing rules per customer tier. Same shape everywhere, rarely called by name.
The modern twist: sometimes the strategy is just a lambda#
Since Java 8, a one-method strategy doesn’t need a class per variant — a functional interface plus lambdas does the job:
@FunctionalInterface
public interface DiscountStrategy {
BigDecimal discountFor(Order order);
}
DiscountStrategy noDiscount = order -> BigDecimal.ZERO;
DiscountStrategy festivalSale = order -> order.total().multiply(new BigDecimal("0.15"));
DiscountStrategy loyaltyTier = order ->
order.customer().tier() == Tier.GOLD
? order.total().multiply(new BigDecimal("0.10"))
: BigDecimal.ZERO;
BigDecimal priceOrder(Order order, DiscountStrategy discount) { /* ... */ }
Comparator is the proof this was always the idea: it has been a one-method strategy interface since the JDK’s earliest days — the sort algorithm is fixed, the “how do two items compare” part is injected — and lambdas just stripped the ceremony off handing one in. My working rule: one method → a functional interface and lambdas; multiple related methods that must stay consistent (charge + refund + status) → a full interface with named implementations. A bag of loose lambdas can drift apart again; the interface is the glue.
When NOT to use it#
The half that separates knowing a pattern from name-dropping it:
- Two branches that will stay two branches. An
ifis simpler, and simpler wins. Strategy pays rent only when variants multiply or must stay in sync across several operations. - The variants aren’t actually independent. If every “strategy” needs to peek at which other strategy is active, you don’t have interchangeable algorithms — you have one algorithm wearing costumes. Fix the model, not the branching.
- Building for a future that may never come. Don’t build the interface for payment methods you don’t have yet. Wait for the second real variant — the refactor from if/else to Strategy is cheap and mechanical when the day comes.
02 · State — the object that changes its own rules#
Start with a traffic light. Nobody standing at an intersection “picks” the light’s color — the light cycles itself: green knows it becomes yellow, yellow knows it becomes red. And what you’re allowed to do depends entirely on which state it’s in. Same intersection, same car, same pedal — different legal moves. That’s the whole pattern: behavior that depends on where the object is in its life, with the object itself moving through those states.
Now the production version. Our order has a lifecycle, and here’s the code everyone writes first:
public class OrderService {
public void ship(Order order) {
if (!order.getStatus().equals("PAID")) {
throw new IllegalStateException("can only ship a paid order");
}
order.setStatus("SHIPPED");
courier.schedulePickup(order);
}
public void cancel(Order order) {
if (order.getStatus().equals("PLACED")) {
order.setStatus("CANCELLED");
} else if (order.getStatus().equals("PAID")) {
order.setStatus("CANCELLED");
payments.refund(order);
} else if (order.getStatus().equals("SHIPPED")) {
throw new IllegalStateException("recall the courier first");
}
}
}
Six months of product life does to this what it did to the payment ladder:
- The status checks multiply across
ship(),cancel(),refund(),remind(),updateAddress()— the same lifecycle knowledge re-implemented in every method, drifting apart. - Nothing stops
order.setStatus("DELIVERED")from being called anywhere, in any state. The lifecycle rules exist only as scatteredifs and team folklore. - Product adds
ON_HOLD→ you audit every status check in the codebase. Miss one, and an on-hold order ships.
The real problem is the same one Strategy had, wearing a different shirt: what an order is allowed to do depends on which stage of its life it’s in — but that knowledge is copy-pasted into every method that touches an order, instead of living in one place.
State’s whole idea: instead of checking status strings inside big if/else ladders, every state becomes an object that owns its own rules — and the order safely transitions itself through its lifecycle graph. If a second name helps it stick: this is an object-oriented state machine.
Here’s the lifecycle we actually want, as a picture. Every arrow is an allowed move; anything without an arrow should be impossible — and the code below makes it impossible:
stateDiagram-v2 [*] --> PLACED PLACED --> PAID: pay() PLACED --> CANCELLED: cancel() PAID --> SHIPPED: ship() PAID --> REFUND_PENDING: cancel() SHIPPED --> DELIVERED: deliver() REFUND_PENDING --> CANCELLED: refundSettled() DELIVERED --> [*] CANCELLED --> [*]
Each status becomes a class#
Make each state an object that knows its own rules. Java has the tidiest way to write this of any language: an enum where each constant carries its own methods:
public enum OrderStatus {
PLACED {
@Override public OrderStatus pay() { return PAID; }
@Override public OrderStatus cancel() { return CANCELLED; }
},
PAID {
@Override public OrderStatus ship() { return SHIPPED; }
@Override public OrderStatus cancel() { return REFUND_PENDING; }
},
SHIPPED {
@Override public OrderStatus deliver() { return DELIVERED; }
},
REFUND_PENDING {
@Override public OrderStatus refundSettled() { return CANCELLED; }
},
DELIVERED,
CANCELLED;
public OrderStatus pay() { throw illegal("pay"); }
public OrderStatus ship() { throw illegal("ship"); }
public OrderStatus deliver() { throw illegal("deliver"); }
public OrderStatus cancel() { throw illegal("cancel"); }
public OrderStatus refundSettled() { throw illegal("settle a refund for"); }
private IllegalStateException illegal(String action) {
return new IllegalStateException("cannot " + action + " an order in state " + this);
}
}
Read the enum out loud and it stops being “code” — it’s just the business rules, each rule finally living where it belongs:
PLACEDknows two moves.pay()takes the order toPAID;cancel()takes it toCANCELLED. Callship()on it and it refuses — not because some service remembered to check first, but becausePLACEDsimply has no ship move. You can’t ship what nobody paid for.PAIDknows different moves.ship()leads toSHIPPED. And notice itscancel()goes somewhere different thanPLACED’s — toREFUND_PENDING, because money has already moved, so cancelling now means refunding.REFUND_PENDINGknows one move — when the payment provider confirms the money went back,refundSettled()closes the order out toCANCELLED.DELIVEREDandCANCELLEDknow no moves at all. They’re the end of the road, so every action lands on the throwing defaults.
The order itself just delegates — and notice it never sets a status from outside; it asks the current state what comes next:
public class Order {
private OrderStatus status = OrderStatus.PLACED;
public void pay() { this.status = status.pay(); }
public void ship() { this.status = status.ship(); }
}
Three things just became true, and they’re the whole point:
- Every transition rule lives in exactly one place — on the state it belongs to. “What can a PAID order do?” now has one answer location, not a codebase-wide grep.
- Illegal transitions fail by default. The base methods throw; each state overrides only what’s legal for it. You can’t ship a
PLACEDorder becausePLACEDhas noship()override — compare the enum to the diagram above: they’re the same drawing. - Adding
ON_HOLDis additive — one new constant with its own overrides, no hunting through service methods.
One honest note: the enum trick above is Java-specific — most languages don’t let an enum constant carry its own method overrides. The good news is that the most portable version is also the easiest one to hold in your head: write the diagram down as data. Each legal arrow becomes one line in a lookup table, and any move that isn’t in the table gets refused — the same “illegal by default” guarantee, without any inheritance to decode.
How this looks in Python
Read the table against the diagram above: each line is exactly one arrow. Six arrows, six lines — you can check them off visually.
# each line below IS one arrow of the diagram
TRANSITIONS = {
("PLACED", "pay"): "PAID",
("PLACED", "cancel"): "CANCELLED",
("PAID", "ship"): "SHIPPED",
("PAID", "cancel"): "REFUND_PENDING",
("SHIPPED", "deliver"): "DELIVERED",
("REFUND_PENDING", "refund_settled"): "CANCELLED",
}
class Order:
def __init__(self):
self.status = "PLACED"
def _move(self, action):
next_state = TRANSITIONS.get((self.status, action))
if next_state is None:
raise ValueError(f"cannot {action} an order in state {self.status}")
self.status = next_state
def pay(self): self._move("pay")
def ship(self): self._move("ship")
def cancel(self): self._move("cancel")
def deliver(self): self._move("deliver")
def refund_settled(self): self._move("refund_settled")The whole machine is one question: is (current status, action) in the table? No entry → refuse, loudly. A missing dict entry plays the same role a missing override played in the Java enum.
Two notes before this travels into real code:
- Make the state names an
Enum(Python has those — just without Java’s per-constant overrides) so a typo like"PIAD"becomes an immediate error instead of silently inventing a new state. - The table only says where arrows go. The moment states need to do different things —
PAID’s cancel must trigger a refund,SHIPPED’s must recall a courier — graduate to one class per state with the moves as methods. That class-per-state form is the classic State pattern; the table is its lighter sibling, and knowing which one a situation needs is the actual skill.
Strategy vs State: peers vs a graph#
Put their class diagrams side by side and the two patterns look identical — both are “behavior behind an interface, swapped at runtime.” The difference is who does the swapping, and whether the variants know about each other:
- Strategy: the caller picks once, from outside — “customer chose PayPal.” The strategies are peers; none of them mentions another.
- State: the object moves itself from state to state as events happen —
PAID.ship()returnsSHIPPED. Each state knows which states can come after it. That’s how you tell them apart: strategies sit side by side; states point at each other, forming a graph.
State machines already in your stack#
- Stripe’s PaymentIntent is a public, documented state machine:
requires_payment_method → requires_confirmation → processing → succeeded / canceled— and its API errors are literally illegal-transition errors. Thread.Statein the JDK —NEW / RUNNABLE / BLOCKED / WAITING / TIMED_WAITING / TERMINATED. TCP connections, JPA entity lifecycles, Kafka consumer rebalance protocol — protocol code is state machines all the way down.- Every order, booking, or trip lifecycle you’ve ever built. If your domain has words like “pending,” “approved,” or “expired,” there’s a state machine hiding in it, acknowledged or not.
- Whole tools exist just to run this pattern for you: Spring Statemachine, XState, AWS Step Functions. And two things coming later in this series — the saga orchestrator and the circuit breaker (
CLOSED → OPEN → HALF_OPEN) — are exactly this pattern, running big systems.
When NOT to use it#
- The states don’t differ in behavior. If
statusis just a label reports filter by, and no operation acts differently per state, a plain enum field is enough. The pattern earns its keep only when behavior varies by state. - Two or three states, stable forever. A
boolean activewith one check beats building a whole state machine around it. Same rule as Strategy: wait until the variants multiply. - Transitions with heavy, long-running side effects — emails, refunds, retries that must survive restarts. The enum decides legality; orchestrating durable side effects per transition is where you graduate to a workflow engine. Knowing the pattern’s ceiling is part of knowing the pattern.
03 · Command — the request becomes a thing#
A restaurant doesn’t work by the waiter shouting your order at the chef and hoping. The waiter writes a ticket. The ticket can sit in a queue, be prioritized, be handed to whichever chef is free, be re-fired if a dish is dropped, and survive as a record after you’ve gone home. The moment the request became a physical thing instead of a shout, the kitchen gained queuing, retries, load balancing, and an audit trail — for free.
Command is that move, in code: turn “do this” from a method call into an object.
Here’s why you need it. When checkout completes, we must send a confirmation email, generate an invoice, and notify the warehouse. The code everyone writes first does it all inline, on the request thread:
public void completeCheckout(Order order) {
payments.charge(order);
emailService.sendConfirmation(order); // SMTP is slow today…
invoiceService.generate(order);
warehouseClient.notify(order); // …and this service is down
}
The customer’s checkout now waits on an SMTP handshake — and if the warehouse API is down, what exactly happens? Either the whole checkout fails after the card was charged, or you swallow the exception and the warehouse silently never hears about the order. Both are production incidents I’d bet you’ve met.
Here’s the problem underneath, in slow motion. warehouseClient.notify(order) throws. The exception bubbles up, the request ends — and now ask yourself: where in the system is it written that the warehouse still needs to hear about this order? Nowhere. Not in the database, not in a queue, not in any list of pending work. That to-do only ever existed inside the running method — on the call stack, the program’s short-term memory of what it’s doing right now — and that memory is wiped the moment the method ends.
That’s the root disease: a method call is an action, not a thing — and you can only store, retry, schedule, and count things. A method call is a phone call: it works only if the other side picks up right now, and if they don’t, it vanishes without a trace. A text message is a thing: it sits in the outbox, gets delivered when the network comes back, shows a red “failed” you can actually see, and can be resent with a tap. Command turns the phone call into the text message — the shout into the ticket.
The heart of Command: instead of calling the work directly, the request itself becomes an object — so it can be queued, stored, retried, logged, and undone.
The request becomes an object#
Three pieces, and they map one-to-one onto the restaurant: the ticket, the rail the tickets hang on, and the chef.
Piece 1 — the ticket format. One interface for “a thing that needs doing”:
public interface Command {
void execute();
}
Piece 2 — a filled-in ticket. Each kind of work becomes a small class carrying two kinds of things: the data that identifies the job (orderId) and the services it will need to actually do the job later (orders, mailer):
public final class SendOrderConfirmation implements Command {
private final OrderId orderId;
private final OrderRepository orders;
private final Mailer mailer;
public SendOrderConfirmation(OrderId orderId, OrderRepository orders, Mailer mailer) {
this.orderId = orderId;
this.orders = orders;
this.mailer = mailer;
}
@Override
public void execute() {
Order order = orders.load(orderId);
mailer.send(Emails.confirmation(order));
}
}
Watch what this class does not do: new SendOrderConfirmation(...) sends no email. The constructor only stores fields — it creates a little object that says “an email for this order needs to go out.” A written ticket, not a cooked dish. The email happens only when someone, later, calls execute().
Piece 3 — the rail and the chef. The queue is nothing magical: a shared to-do list both sides can see. The worker is a class whose entire life is a loop — take a ticket, cook it, repeat:
public final class Worker implements Runnable {
private final BlockingQueue<Command> queue;
public Worker(BlockingQueue<Command> queue) { this.queue = queue; }
@Override
public void run() {
while (true) {
try {
Command command = queue.take(); // sleeps here until a ticket arrives
command.execute();
} catch (InterruptedException stopped) {
return; // the app is shutting down
} catch (Exception e) {
// for now: log and move on — production adds retries, below
}
}
}
}
Both are created once, at application startup, and live as long as the app does:
BlockingQueue<Command> queue = new LinkedBlockingQueue<>();
new Thread(new Worker(queue), "command-worker").start();
With those pieces in place, checkout stops doing the slow work and merely records that it must happen — here it is in full, nothing hidden:
public class CheckoutService {
private final PaymentGateway payments;
private final OrderRepository orders;
private final Mailer mailer;
private final InvoiceService invoices;
private final WarehouseClient warehouse;
private final BlockingQueue<Command> queue;
public CheckoutService(PaymentGateway payments, OrderRepository orders, Mailer mailer,
InvoiceService invoices, WarehouseClient warehouse,
BlockingQueue<Command> queue) {
this.payments = payments;
this.orders = orders;
this.mailer = mailer;
this.invoices = invoices;
this.warehouse = warehouse;
this.queue = queue;
}
public void completeCheckout(Order order) {
payments.charge(order);
queue.add(new SendOrderConfirmation(order.id(), orders, mailer));
queue.add(new GenerateInvoice(order.id(), orders, invoices));
queue.add(new NotifyWarehouse(order.id(), warehouse));
}
}
(GenerateInvoice and NotifyWarehouse are built exactly like SendOrderConfirmation — an id plus the services they need.)
Read the flow in restaurant terms: completeCheckout writes three tickets and hangs them on the rail — microseconds of work — and returns to the customer. On the other side of the kitchen, the worker picks tickets up one by one and cooks them. The slow SMTP handshake still happens; it just happens on the worker’s time instead of the customer’s.
All the pieces on one map — who is-a what, who holds what, and who never meets whom:
classDiagram
class Command {
<<interface>>
+execute()
}
class SendOrderConfirmation {
-orderId: OrderId
-orders: OrderRepository
-mailer: Mailer
+execute()
}
class GenerateInvoice
class NotifyWarehouse
class CheckoutService {
-queue: BlockingQueue~Command~
+completeCheckout(Order)
}
class Worker {
-queue: BlockingQueue~Command~
+run()
}
Command <|.. SendOrderConfirmation : implements
Command <|.. GenerateInvoice : implements
Command <|.. NotifyWarehouse : implements
CheckoutService ..> Command : new + add()
Worker ..> Command : take() + execute() One wart to be honest about: CheckoutService now holds mailer, invoices, and warehouse for a single purpose — stuffing them into tickets. That’s the in-memory version’s tax, and the production version removes it.
That’s the whole mechanism. Production systems grow it in two directions. First, one worker becomes a fleet — the queue doesn’t care how many chefs pull from it — and failure handling gets teeth: a failed execute() goes back on the queue with a retry count, and a command that keeps failing lands in a dead-letter queue (a parking lot for tickets a human needs to look at). Second — the detail that makes it production-real — the queue stops holding Java objects and starts holding the command’s data: its type plus orderId, serialized to JSON in Redis or a database table. The worker side holds the services and rebuilds the command to execute it (there goes the wart), and that’s also why the request now survives process crashes and deploys: it’s a row, not a stack frame.
Three things just became true, and they’re the whole point:
- The request survives the process. It’s persisted data, so a crash, restart, or deploy between “checkout succeeded” and “email sent” no longer loses the email — the command is still sitting in the queue when the worker comes back.
- Execution is decoupled from the caller in both time and place. Checkout answers the customer now; the work happens later, on whichever of N workers is free. That one property is the foundation of every background-job system ever built.
- Failure handling becomes data operations. Retry = execute the same object again. Backoff = re-enqueue with a delay. Give up = move the row to the dead-letter table. Audit = query the table. None of these are possible on a method call — with one catch: retrying means a command may run more than once, so
execute()must be safe to repeat. That word — idempotent — gets its own post in the distributed series, because it’s the tax every retry system pays.
The same request-as-object trick also powers undo: give the interface a second method (undo()), have each command know how to reverse itself, keep a stack of executed commands — that’s the edit history in every editor and IDE you’ve used. And it scales up: a saga step in the distributed series is exactly a command paired with the command that reverses it.
Every job queue is this pattern#
RunnableandCallable— Command shipped as a JDK primitive: everyexecutor.submit(() -> ...)wraps work as an object, and aThreadPoolExecutor’s work queue is literally aBlockingQueue<Runnable>— a queue of commands.- Every job-queue system: Sidekiq, BullMQ, Spring’s
@Async+ broker setups, JMS/SQS messages that mean “do X.” The serialized job row is the command. - CQRS has the pattern in its name — Command Query Responsibility Segregation:
PlaceOrderCommandobjects routed to command handlers. Redux actions are the same idea in the front end. - Database write-ahead logs — each redo-log entry is a stored “do this to page N” that gets re-executed on crash recovery. Your database survives power loss because its writes are commands, not calls.
- Undo/redo history in every editor, spreadsheet, and design tool.
When NOT to use it#
- You need the return value, now, in this thread. Wrapping a synchronous call whose result the next line depends on just adds indirection. Command earns its keep when execution is deferred, distributed, retried, or recorded.
- Don’t hand-roll the queue. The pattern is the shape; in production you reach for the ecosystem’s queue (SQS, Redis-backed workers, a jobs table) rather than building
BlockingQueueplumbing yourself. - Command-class explosion. If every one-line service call gets its own
XxxCommandclass, you’ve traded an if/else ladder for a file ladder. For in-process, non-persisted work, a lambda (Runnable) is the command — save named classes for requests that need to be serialized, retried, or undone.
04 · Observer — the fact becomes an announcement#
Ask how a YouTube channel with ten million subscribers tells you a new video is out. The creator doesn’t keep a list of phone numbers and doesn’t call anyone. They upload the video once; the platform walks the subscriber list and notifies everyone on it. The creator has no idea you exist, you can subscribe or unsubscribe whenever you like, and the channel never changes either way. Nobody would build it the other way around — a creator personally tracking every viewer is absurd.
Yet that absurd version is exactly what most codebases do. Here’s “the order was paid” in ours, after four sprints of real product life:
public void markPaid(Order order) {
order.pay();
orders.save(order);
emailService.sendReceipt(order); // Payments team
loyaltyService.awardPoints(order); // Loyalty team
analytics.track("order_paid", order); // Data team
recommendations.refresh(order.customerId()); // Growth team, added last sprint
}
Watch what this does to the codebase:
- Every team’s feature lands as an edit to
markPaid. The method that moves money — the one you least want touched — is the most-edited method in the system, changed by people who don’t work on orders at all. OrderServicenow imports half the codebase. The class that marks payments knows about loyalty points, analytics, and recommendation caches. Ifrecommendations.refresh()throws, marking an order as paid fails — a recommendation bug just broke checkout.- Nothing can be removed safely. Is anyone still using the analytics call? Who knows — it’s welded into the payment path, so it stays forever.
Strip the story to its bones: the place where a fact happens is forced to personally know everyone who cares about it. And “the order was paid” is a fact — remember the note at the end of the Command section: an event, not an instruction. Facts don’t belong to their audience. The news doesn’t change depending on who reads it.
Observer, at its smallest: whoever cares subscribes, and the place where the thing happens just announces it down its list — without knowing or caring who’s listening.
Subscribers and announcements#
Give the fact a name, give the audience an interface, and let OrderService keep a subscriber list instead of a call list:
public interface OrderPaidListener {
void onOrderPaid(Order order);
}
public class OrderService {
private final List<OrderPaidListener> listeners = new ArrayList<>();
public void subscribe(OrderPaidListener listener) { listeners.add(listener); }
public void markPaid(Order order) {
order.pay();
orders.save(order);
for (OrderPaidListener listener : listeners) {
listener.onOrderPaid(order);
}
}
}
Each feature becomes its own small class that knows one thing — what it does when an order is paid:
public class LoyaltyPoints implements OrderPaidListener {
@Override public void onOrderPaid(Order order) {
loyalty.awardPoints(order.customerId(), order.total());
}
}
And the wiring happens once, at startup:
orderService.subscribe(new SendReceipt(mailer));
orderService.subscribe(new LoyaltyPoints(loyalty));
orderService.subscribe(new TrackAnalytics(analytics));
orderService.subscribe(new RefreshRecommendations(engine)); // Growth's feature — zero edits to OrderService
Read it back in plain words:
OrderServiceknows exactly one thing now: it holds a list of whoever asked to be told, and when an order is paid it goes down the list. It cannot name a single feature on that list — check its imports: loyalty, analytics, recommendations are gone.- Each listener knows exactly one thing: its own reaction.
LoyaltyPointsis small enough for its team to own outright and test with a fake order, in complete isolation. - Adding a feature is one new class plus one
subscribeline. Removing one is deleting that line. The busiest method in the system stops being everyone’s editing ground.
sequenceDiagram participant Checkout participant OrderService participant SendReceipt participant LoyaltyPoints participant TrackAnalytics Checkout->>OrderService: markPaid(order) Note over OrderService: save the order,<br/>then walk the list OrderService->>SendReceipt: onOrderPaid(order) OrderService->>LoyaltyPoints: onOrderPaid(order) OrderService->>TrackAnalytics: onOrderPaid(order)
The deepest change isn’t line count — it’s which way the arrows point. Before, the class that moves money depended on every leaf feature in the codebase. After, the arrows flip: the features depend on the event, and OrderService depends on nobody. The most important code in the system is no longer touched by the least important code in the system:
The honest catch — and the doorway to distributed systems#
Before you ship the code above, notice it has the same disease Command diagnosed. The subscriber list lives in memory, and the loop runs on the checkout thread. So: a slow listener slows every checkout. A throwing listener can take payment down with it. And if the process crashes after orders.save(order) but halfway through the loop, the remaining listeners just… never hear about the order — and nothing anywhere records that they should have.
Real systems fix this in stages: first run listeners in the background instead of on the caller’s thread, and then — the moment a different service cares about the fact — move the announcement out of the process entirely, onto a message broker (a separate server whose whole job is keeping subscriber lists and delivering announcements). The pattern doesn’t change. Only where the list lives changes:
| Scale | Where the subscriber list lives | The announcement is | You’ve seen it as |
|---|---|---|---|
| Inside one process | a List<Listener> field | a method call per listener | Spring @EventListener, GUI listeners |
| Between services | a message broker | a message on a topic | Kafka, RabbitMQ, SNS |
| Between companies | the other company’s servers | an HTTP POST to your URL | Stripe / GitHub / PayPal webhooks |
And moving the list out of the process raises one genuinely hard question: markPaid now has to save the order to its database and publish the event to the broker — two different systems, and no way to make “both or neither” happen with a try/catch. That is the dual-write problem, and it’s the opening post of the distributed series this whole section has been walking toward.
From button clicks to Kafka#
- Every UI you’ve ever touched.
button.addEventListener("click", ...)in the browser,ActionListenerin Swing,setOnClickListeneron Android. The button keeps a list; it has no idea what your app does on click. GUI toolkits are where the GoF book introduced the pattern. - React and the whole “reactive” family. A component re-renders when state it subscribed to changes; RxJS, LiveData, and signals are Observer with industrial plumbing.
- Spring’s
@EventListenerandApplicationEventPublisher— the hand-rolled list above, done for you. In real Spring code you publish anOrderPaidEventand annotate the listeners; nobody writes theforloop by hand. - Kafka and RabbitMQ topics. Producers publish facts, consumers subscribe, neither knows the other exists — the diagram above with a server in the middle.
- Webhooks. Stripe telling your server a payment succeeded is Observer where the subscriber list holds URLs instead of objects — you subscribed by pasting your endpoint into their dashboard.
When NOT to use it#
- You need an answer back. An announcement is fire-and-forget: the publisher can’t use a return value from listeners it doesn’t know exist. If the next line of
markPaidneeds a result, that’s a call, not an event. - The listeners depend on each other. If loyalty points must be awarded before the receipt is sent so the receipt can show them, that ordering is invisible inside a subscriber list — someone will reorder the wiring and break it. Real sequencing deserves to be explicit: a plain sequence of calls, or across services, a saga (coming in the distributed series).
- The “event” is secretly an instruction. “Warehouse, ship this” must be handled, by one specific handler, and can fail and be retried — that’s a command; give it the queue from section 03, not a broadcast.
- Two stable listeners, known forever. Observer has a real cost: “what happens when an order is paid?” is no longer answered by reading
markPaid— you have to hunt down every subscription. Don’t pay that cost to avoid two lines that were never going to change.
05 · Visitor — new operations over a fixed set of shapes#
You’ve met this pattern as a user, in the most ordinary place possible: the Export menu. A document in Notion or Google Docs is built from a few fixed kinds of blocks — paragraphs, images, tables. Now watch what the product ships over its life: Export as PDF. Then Export as HTML. Then Export as Markdown. Then word count, then an accessibility checker. None of these change what a paragraph is — but every one of them must know what to do with every kind of block.
And you already know what it looks like when this goes wrong, because it has happened to you: you export a document and the tables come out mangled — or just gone. Somewhere inside that app, an exporter met a block it didn’t know how to handle, and nothing had forced it to handle everything.
Here’s the same situation in code. The blocks are a small, genuinely stable family:
public sealed interface Block permits Paragraph, Image, Table { }
(sealed is Java’s way of saying: these are all the kinds there will ever be — no one can add a fourth behind your back.)
The blocks are stable, but the operations on them never stop arriving: PDF export, HTML export, Markdown export, word count. And both obvious homes for those operations go bad:
- Put each operation on
Blockitself —Paragraph.toPdf(),Paragraph.toHtml(),Paragraph.toMarkdown(),Paragraph.wordCount()… every new export format edits all three block classes, andTableslowly fills with knowledge of every file format on earth. - Write
if (block instanceof Table) ...ladders inside each exporter — and the day someone writes a new exporter and forgets theTablebranch, you’ve shipped the mangled-tables bug. It compiles. It even runs — right up until a document contains a table.
Seen from above: two things grow on different axes — kinds of blocks (almost never) and operations on them (constantly) — and both instincts weld the axes together.
| You keep adding… | Best tool | Why |
|---|---|---|
| new kinds of blocks | methods on each class (plain polymorphism) | a new class arrives carrying all its own behavior |
| new operations on stable blocks | Visitor (or a pattern-matching switch) | a new operation is one new class; the block classes never change |
Visitor’s bargain: each operation becomes one class with a method per block, and each block just points the visitor at its own method — so a new export format is one new file, and forgetting a block is a compile error instead of a mangled document.
One class per operation#
One interface describes “an operation that can handle every kind of block.” The <R> just means “whatever this operation produces” — String for an export format, Integer for a word count:
public interface BlockVisitor<R> {
R paragraph(Paragraph paragraph);
R image(Image image);
R table(Table table);
}
public sealed interface Block permits Paragraph, Image, Table {
<R> R accept(BlockVisitor<R> visitor);
}
public final class Table implements Block {
/* headers, rows, constructor */
@Override
public <R> R accept(BlockVisitor<R> visitor) { return visitor.table(this); }
}
And each export format becomes one self-contained class:
public final class MarkdownExport implements BlockVisitor<String> {
@Override public String paragraph(Paragraph p) { return p.text() + "\n"; }
@Override public String image(Image img) { return " + ")"; }
@Override public String table(Table t) { return MarkdownTables.render(t); } // the pipes-and-dashes grid
}
String markdown = doc.blocks().stream()
.map(block -> block.accept(new MarkdownExport()))
.collect(Collectors.joining("\n"));
Plain-words walkthrough:
MarkdownExportis the entire Markdown story in one file. How a paragraph, an image, and a table each look in Markdown — one place to read, one place to fix. Next quarter’sPdfExportis one new class; no block class gets touched.acceptis the odd-looking line, so read it out loud: the block answers the question “which of your methods am I?” — aTablecallsvisitor.table(this). That single hop is what replaces theinstanceofladder inside every exporter.- Forget
table()in the new exporter and it won’t compile. Theinstanceofladder shipped the mangled-tables bug silently; the visitor turns the exact same mistake into a red squiggle before the code can even run. That trade — silent bug becomes compile error — is the pattern’s whole sales pitch.
The structure is two small families facing each other, and drawing it makes the two growth axes visible:
classDiagram
class Block {
<<interface>>
+accept(visitor) R
}
class Paragraph
class Image
class Table
class BlockVisitor~R~ {
<<interface>>
+paragraph(p) R
+image(img) R
+table(t) R
}
class MarkdownExport {
+paragraph(p) String
+image(img) String
+table(t) String
}
class PdfExport
Block <|.. Paragraph : implements
Block <|.. Image : implements
Block <|.. Table : implements
BlockVisitor <|.. MarkdownExport : implements
BlockVisitor <|.. PdfExport : implements
Block ..> BlockVisitor : accept(visitor) And the accept hop itself in slow motion — one Table meeting one exporter:
sequenceDiagram participant E as export code participant T as Table participant M as MarkdownExport E->>T: accept(markdownExport) Note over T: only I know which block kind I am T->>M: visitor.table(this) Note over M: the Table-specific rendering runs M-->>T: the pipes-and-dashes grid T-->>E: rendered string
One modern, honest note: since Java 21, a pattern-matching switch over a sealed type gives you the same compile-time exhaustiveness with far less machinery:
String md = switch (block) {
case Paragraph p -> p.text() + "\n";
case Image img -> " + ")";
case Table t -> MarkdownTables.render(t);
}; // add a 4th Block kind and every such switch refuses to compile until it's handled
For most application code today, that switch is the visitor idea. The full interface form still earns its keep when operations are substantial classes with their own state and helpers — and in generated code, where it reigns.
Compilers, linters, exporters#
- Compilers and linters — the pattern’s home turf. Source code is a document too: a tree of a few dozen fixed node kinds, with tools walking it the way exporters walk your blocks. Every ESLint rule is literally a visitor — an object with one function per node type (
{ CallExpression(node) {...}, IfStatement(node) {...} }). Babel plugins are the same shape. If you’ve written a lint rule, you’ve written a visitor. - pandoc — the universal document converter (Markdown ⇄ HTML ⇄ PDF ⇄ Word) — is this section’s example running as an entire product: stable block kinds, an ever-growing shelf of format visitors.
- ANTLR (the parser generator) emits a ready-made
Visitorclass per grammar — you subclass it and override the node types you care about.javac’s own internals walk the Java AST the same way. - Serializers and static analyzers — anything that walks a tree of known node kinds applying an operation per kind.
When NOT to use it#
- The family of types is still growing. Every new type forces a new method into every visitor — the worst of both axes. Visitor wants shapes that are done growing (that’s why
sealedand Visitor are best friends). - One or two operations, forever. Methods on the classes are simpler and closer to the data.
- A
switchreads clearer. In modern Java, reach for the pattern-matching switch first; graduate to the interface form when the operations get big.
That’s the behavioral core. I’ve skipped a few classics on purpose — Template Method, Mediator, Iterator — because they live inside languages and frameworks already, not in code anyone writes by hand day to day (the creational notes end with the full list). From here these notes fork in two directions: the next set covers the patterns that wrap — Decorator, Chain of Responsibility, Adapter, Facade, Proxy — and the distributed-patterns notes start exactly where Observer’s honest catch left off: how do you save the order and publish the event so that both happen or neither does? That one question — the dual-write problem — is the doorway to outboxes, CDC, idempotent consumers, and sagas.
Comments
Signed in with GitHub. Be kind.