Location Berlin, Germany Working on Modelling weather patterns Reading Demons by Dostoevsky Location Berlin, Germany Working on Modelling weather patterns Reading Demons by Dostoevsky
← Back

Principle, Pattern, Idiom

September 10, 2026 · 12 min read

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.

PRINCIPLEwhat must stay true · no codePATTERNan arrangement of parts · a named costIDIOMwhat your language already hasnarrows tois spelledanswers toanswers to“Depend on abstractions,not on concretions.”SOLID — DStrategyone interface, interchangeablepricing algorithms behind itusing Rule =std::function<Money(Order)>;C++ · no class hierarchy leftone worked example
Fig. 1 — the same decision, three rungs down. Reading downward you lose generality and gain compilable text; reading upward you lose detail and gain an argument you can have with a colleague. The rung a discussion belongs on is usually the one where the disagreement actually lives.

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.

PrincipleOne line
SSingle ResponsibilityOne reason to change — and the reason is a person, not a topic.
OOpen/ClosedNew behaviour should arrive as new code, not as edits to old code.
LLiskov SubstitutionA subtype may not tighten what the caller must promise, nor loosen what it delivers.
IInterface SegregationNo client should have to recompile for a method it never calls.
DDependency InversionBoth sides depend on an abstraction, and the abstraction belongs to the caller.
CLOSED FOR EXTENSIONOPEN FOR EXTENSIONnew rule: “seasonal”reopen the classPriceCalculatorswitch (order.kind) {case Retail: ...case Wholesale: ...case Employee: ...case Seasonal: ...}every rule ships a diff to the same file— and a re-test of the other threePriceCalculatorrule.price(order)callsPricingRuleprice(Order) → MoneyRetailWholesaleEmployeeSeasonalthe new rule — a new file
Fig. 2 — Open/Closed is a claim about where the red arrow lands. The pattern did not remove work; the fourth rule still has to be written. What moved is the blast radius: on the left the change touches a file three other rules already live in, on the right it touches a file nothing else points at. Hollow triangles are UML for “implements”.
BEFOREAFTEROrderServicepolicy: when may we ship?module boundaryimports psycopgPostgresRepodetail: rows and columnspolicy cannot compile without the driverhigh‑level module — owns the interfaceOrderServiceOrderReposave(Order) · find(Id) → Order?module boundaryimplementsPostgresRepoimports psycopg · imports OrderRepopolicy compiles, and tests, with no database
Fig. 3 — inversion means one arrow reverses across one line. The trick is not “add an interface” — an interface parked in the persistence package changes nothing. It is that the interface moves up into the module that consumes it, so the only edge crossing the boundary now points at policy instead of away from it.

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.

1

Strategy

** ↑ Open/Closed  ·  behavioural

a.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.

Checkoutrule: PricingRuletotal() { ... rule.price(o) }delegates the stepPricingRuleprice(Order) → MoneyimplementsListPriceTieredBulkStaffDiscountpricing.yamlrule: tiered_bulkbound once, at startup
Fig. 4 — the field is the whole pattern. 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 if is 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.
2

Observer

* ↑ Single Resp.  ·  behavioural

a.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.

PriceFeedChartRiskLimitWIRINGsubscribe(self)subscribe(self)RUNprice = 4.21notify()notify()read price — pull, not pushthe two red arrows are a loop over a list of Observer — PriceFeed contains neither the word Chart nor RiskLimit
Fig. 5 — the dependency and the call point opposite ways. At wiring time the observers reach in and name the feed; at run time the feed calls back without naming them. That inversion is the payoff, and also the hazard: the red arrows are where re-entrancy, ordering surprises and leaked subscriptions live. Pulling the value (dashed) rather than pushing it in the callback keeps the notification payload from becoming an API of its own.
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 subscribe needs an owner who will eventually unsubscribe.
3

Decorator

* ↑ Single Resp.  ·  structural

a.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.

LoggingClientone line per call, with the request idRetryingClient3 attempts, jittered backoff, 5xx onlyTimedClienthistogram, p50 / p99HttpClient.send()Callersees oneinterfacesend(req)Responseinbound: each layer may act, then must call the nextoutbound: each layer sees the result on the way back
Fig. 6 — the call crosses every boundary twice. That round trip is what a subclass cannot give you: 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.
4

Adapter

** ↑ Dep. Inversion  ·  structural

a.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.

OUR VOCABULARYTHEIRSCheckoutPaymentPortpay(Money) → Receiptstripe.Clientcharge(amount:int, cur:str)StripeAdapterthe only file that imports stripeimplementsMoney(4.21,"EUR") → 421, "eur"{"id":"ch_1","paid":true} → Receiptgrep for the vendor’s package name: one hit, or the boundary has already failed
Fig. 7 — the translation is the deliverable. Note where the interface lives: on our side of the line (Fig. 3), which is what makes the adapter replaceable rather than merely present. The pattern’s health has a one-command test — if the vendor’s import appears anywhere but this file, the boundary is decorative.
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.
5

State

* ↑ Open/Closed  ·  behavioural

structurally 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.

DraftSubmittedPaidShippedsubmitpayshipCancelledcancelcancelcancel + refundno such methodon Shippedseven edges drawn, seven methods written — the eighth is a compile error rather than an if-statement someone forgot
Fig. 8 — the diagram and the type system say the same thing. 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 match is 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.