Principle, Pattern, Idiom
Three rungs of the same ladder. A principle tells you what to protect, a pattern is a shape that protects it, and an idiom is what that shape collapses into once your language gets a say.
“Make it work, make it right, make it fast — in that order.” — Kent Beck
The ladder
Most arguments about design patterns are really arguments about which rung someone is standing on. “Just use a lambda” and “that’s the Strategy pattern” are not contradictory claims; they are the same claim, spoken one rung apart.
A principle is language-independent and unfalsifiable in the small — you can only violate it, never execute it. A pattern is a named arrangement of parts that satisfies a principle in a particular situation, and it comes with a cost you can name. An idiom is the shortest expression of that arrangement in one language; it may be a whole class hierarchy or it may be one function typedef.
Five principles, two of which have pictures
SOLID is the top rung. Three of the five are best stated and then left alone — a diagram of “one reason to change” would just be a box with a sad face. Two of them, Open/Closed and Dependency Inversion, are entirely about which way an arrow points, and those are worth drawing.
| Principle | One line | |
|---|---|---|
| S | Single Responsibility | One reason to change — and the reason is a person, not a topic. |
| O | Open/Closed | New behaviour should arrive as new code, not as edits to old code. |
| L | Liskov Substitution | A subtype may not tighten what the caller must promise, nor loosen what it delivers. |
| I | Interface Segregation | No client should have to recompile for a method it never calls. |
| D | Dependency Inversion | Both sides depend on an abstraction, and the abstraction belongs to the caller. |
Five patterns, in Alexander’s form
The word comes from Christopher Alexander’s A Pattern Language (1977), a book about doorways and courtyards that the Gang of Four borrowed wholesale in 1994. Alexander numbered his 253 patterns, linked each one upward to the larger patterns it completes and downward to the smaller ones that complete it, and — the part everyone forgets — graded his own confidence with asterisks.
** an invariant: no better solution is known
* true in shape, but the boundaries are still moving
— a hunch worth writing down
Kept below, honestly. Each entry states the context, the forces in tension, the arrangement that resolves them, and the bill.
Strategy
** ↑ Open/Closed · behaviourala.k.a. Policy · the pattern most likely to already exist in your language
… you have one workflow with one step that varies, the variants are chosen by
configuration or by the customer, and you have caught yourself writing a switch that will only
ever grow.
The forces: the surrounding workflow is stable and well-tested, so you do not want to keep editing it; the variants share nothing but a signature; and the choice is not known until run time, which rules out a compile-time branch.
Therefore Lift the varying step into an interface of one method, pass a concrete implementation in from outside, and let the workflow call it without knowing which one it holds.
Checkout names a type it can never construct; something outside — a factory, a container, three lines in main — decides which of the three objects lands in that field. Swap the arrow from the config file and behaviour changes with no recompilation of Checkout.Idiom — Python, where the interface is just a callable
# No ABC, no registry. The "interface" is the signature. Rule = Callable[[Order], Money] def tiered_bulk(o: Order) -> Money: return o.list_total * (0.9 if o.units >= 50 else 1.0) class Checkout: def __init__(self, rule: Rule) -> None: # injected, not chosen self._rule = rule def total(self, o: Order) -> Money: return self._rule(o) + shipping(o)
- Use when
- The variants outlive the release cycle, or a test needs to substitute a deterministic one.
- Skip when
- There are exactly two variants and there will only ever be two. An
ifis cheaper than an indirection your reader has to chase. - Bill
- One more type to name, and a stack trace that no longer says which branch ran.
Observer
* ↑ Single Resp. · behaviourala.k.a. Publish/Subscribe · the one that gets away from you
… one object owns a fact that several others need to react to, the set of reactors keeps growing, and each new one is arriving as another line at the bottom of the same method.
The forces: the owner of the fact should not accumulate knowledge of everyone downstream of it; the reactors genuinely do need to run when the fact changes; and neither side should have to be alive at the same moment for the other to compile.
Therefore Let interested parties register themselves against an interface the subject defines, and have the subject broadcast a change to a list it never inspects.
- Use when
- The subject is genuinely lower-level than its listeners — a document, a socket, a clock.
- Skip when
- You need the reactions to be ordered, transactional, or observable in a stack trace. That is a queue or a pipeline, not this.
- Bill
- Control flow you cannot read off the page, and a lifetime problem: every
subscribeneeds an owner who will eventuallyunsubscribe.
Decorator
* ↑ Single Resp. · structurala.k.a. Wrapper · every middleware stack you have ever written
… a component does one thing correctly, and the concerns piling on top of it — retries, timing, logging, caching — are each independently optional and independently testable.
The forces: putting all four inside the component makes it four times as hard to read and impossible to disable one; making four subclasses gives you a combinatorial explosion; and callers must not have to know which concerns are switched on.
Therefore Give each concern its own object with the same interface as the component, holding the next object inward. Compose them at construction; the caller sees one interface either way.
TimedClient takes a timestamp inbound and reads the clock again outbound, RetryingClient inspects the outbound result and may re-enter its own inward call. The core object is unchanged and cannot tell it is wrapped — and the order of the rings is a decision: timing inside retry measures one attempt, outside it measures the whole ordeal.Idiom — Go, where composition is the language’s default verb
type Doer interface { Do(*Request) (*Response, error) } type retrying struct{ next Doer; attempts int } // same interface, holds the next func (r retrying) Do(q *Request) (*Response, error) { var err error for i := 0; i < r.attempts; i++ { resp, err := r.next.Do(q) // inbound if err == nil && resp.Status < 500 { return resp, nil } // outbound sleep(backoff(i)) } return nil, err } client := logging{retrying{timed{base}, 3}} // the stack is one expression
- Use when
- Concerns are optional, orthogonal, and want to be switched per environment.
- Skip when
- A layer needs to know what the other layers are, or the interface has fifteen methods each wrapper must forward by hand.
- Bill
- Stack traces get deep, and “which object am I actually holding” becomes a real debugging question.
Adapter
** ↑ Dep. Inversion · structurala.k.a. the place where the vendor’s vocabulary stops
… you need something a third party already does well, but its interface is written in its own nouns — integer cents, string currency codes, exceptions with vendor names in them — and those nouns are leaking into code that should only speak about orders and money.
The forces: you cannot change their signature; you should not deform your domain to fit it; and one day you will replace them, in a hurry, under someone else’s deadline.
Therefore Write the interface you wish they had, in your vocabulary, and put exactly one object in the whole codebase whose job is to translate across the boundary in both directions.
- Use when
- The dependency is external, replaceable in principle, or awkward to run in a test.
- Skip when
- You are wrapping your own stable code, or the “port” ends up as a method-for-method copy of the vendor’s API. That is a second name for the same coupling.
- Bill
- Two vocabularies to keep in your head, and a translation layer that quietly becomes a place for business logic to hide.
State
* ↑ Open/Closed · behaviouralstructurally Strategy, but the object chooses its own successor
… an object behaves differently depending on where it is in a lifecycle, and the
methods have filled up with if self.status == … guards that must all agree with each other.
The forces: the legal transitions are a fact about the business, not an implementation detail; an illegal one should be hard to write, not merely caught at run time; and the list of states will grow.
Therefore Give each state its own object holding only the operations legal in that state, and let handling an event return the next state. The transition table stops being a comment and becomes the code.
Shipped simply has no cancel, so the missing edge is enforced by the compiler instead of by a guard clause that a future contributor can fail to add. This is the difference from Strategy (1): the state object returns its own successor, so the machine advances itself.Idiom — Rust, where the transition table is the enum
enum Order { Draft(Cart), Submitted(Id), Paid(Id, Receipt), Shipped(Id, Tracking), Cancelled } impl Order { fn cancel(self) -> Result<Order, Order> { match self { Order::Draft(_) | Order::Submitted(_) => Ok(Order::Cancelled), Order::Paid(id, r) => { refund(&r); Ok(Order::Cancelled) } shipped => Err(shipped), // nothing to do but hand it back } } }
- Use when
- The states are named in the business’s own language, and illegal transitions have real consequences.
- Skip when
- There are three states and two transitions. An enum and a
matchis the whole pattern already. - Bill
- Shared data has to live somewhere — either duplicated across state objects or in a context they all reach into.
Down to the bottom rung
Every pattern above dissolves when a language absorbs it. Strategy is a function parameter in anything with
first-class functions. Observer is a channel, a signal, an event stream. Decorator is a middleware list.
Iterator, which the Gang of Four spent nine pages on, is a for loop.
That is not an argument against patterns; it is what a maturing language is. The value that survives the dissolution is the vocabulary — being able to say “that’s Adapter, and the port belongs on our side” and have three people picture the same drawing.
Alexander’s warning applies unchanged: a pattern is a solution to a problem in a context, and the context is doing at least half the work. A pattern applied without its forces is just extra indirection with a famous name.
Sources worth the shelf space: Alexander, A Pattern Language (1977); Gamma, Helm, Johnson & Vlissides, Design Patterns (1994) — read the Intent and Consequences sections, skip the C++; Martin, Agile Software Development (2002) for SOLID as originally argued; Hohpe & Woolf for the same move at the messaging scale.