Polymorphism
One interface, several implementations, chosen because the behaviour genuinely differs. With one implementation it is not polymorphism — it is a redirect with a type on it.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind survives until the requirement changes.
When does dispatching on a type buy something that a conditional does not, and when is it the same conditional hidden across six files?
Notifications go out by email. Support wants SMS for delivery alerts, and enterprise customers want them posted to a webhook. All three take a recipient and a rendered message, and all three can fail.
A switch on channel inside send(). Three cases, each five lines, all visible on one screen — you can read the whole behaviour at once, which is more than can be said for three files.
The first switch is genuinely fine. It breaks when a second one appears — in retry policy, then in cost accounting, then in the admin UI — because now adding a channel means finding every switch and the compiler helps with none of them (Shotgun Surgery).
- The first switch is genuinely fine. It breaks when a second one appears — in retry policy, then in cost accounting, then in the admin UI — because now adding a channel means finding every switch and the compiler helps with none of them (Shotgun Surgery).
- Each case grows. SMS acquires a rate limiter and a spend cap, the webhook acquires signing and backoff, and the "five lines each" function becomes three hundred with three unrelated dependency sets (Divergent Change).
- The module now depends on every channel's SDK at once, so a test of email delivery drags in the SMS client and the HTTP signer (Testing as Design Feedback).
- The failure that costs money: someone adds a case to two of the four switches and ships. Nothing fails to compile; a class of customers silently stops receiving alerts.
What limits the solution, and what must never stop being true
This domain leads with these two. A design that ignores its constraints is not a design, and an invariant nobody named is one nothing is protecting.
- The three channels have genuinely different failure behaviour: email is fire-and-forget, SMS costs money per send, a webhook can be slow and must be retried (Retries Are a Property of the Operation).
- Which channel a customer gets is data in the database, not a compile-time fact, so the choice must happen at runtime.
- A fourth channel — push — is on the roadmap but unfunded, so it is a possibility rather than a requirement (Design for the Known, Name What You Assumed).
- Every notification is delivered at least once or recorded as failed. Silence is not an outcome (Swallowed Errors).
- A caller must never need to know which channel it is using in order to call correctly.
Who owns what, and where the seams fall
Responsibilities decide boundaries; boundaries decide what an interface has to say.
- Each channel owns how it sends, what it costs, how it fails and how it retries — one type per channel, one reason to change each.
- Something must own the *selection*, and it must be exactly one place: a registry or a factory, not a switch replicated per call site (Factory).
- The caller owns none of it. It owns "notify this person of this thing".
- The interface is the boundary, and it should be shaped by what the caller needs —
send(recipient, message): Result— not by the union of what the three implementations happen to do (Designing a Module Interface). - Anything that differs per channel and the caller must know — cost, latency class, whether delivery is confirmable — belongs in the return type, not in the caller's knowledge of which implementation it holds (Result Types).
- The registry that maps a stored channel name to an implementation is the one place that knows all three, and it is the place a fourth is added (Wiring and the Composition Root).
One interface, three genuinely different things
The diagram is the test, not a picture of the solution. If you cannot name at least two implementations that differ in what they *do* — not merely in which SDK they call — the interface has nothing to dispatch on and should not exist yet.
Note what varies below: not just the send call, but the failure model, the cost model and the retry policy. That is variation with substance. Three implementations that each wrap one SDK call and differ in nothing else would be an argument for one function taking a client.
- The caller never learns which it got — that is what makes this polymorphism rather than a lookup.
- The registry is the single place that knows the full set, and it is the price of runtime selection.
- Failure differs per channel, so the *return type* is where the differences surface, not the caller's knowledge (An Error Taxonomy That Survives Contact).
One implementation is indirection with a type on it
The single-implementation interface is the most-shipped design mistake in this domain, and it survives review because it looks like good practice. It is not neutral: it costs a file, a name, a hop, and — critically — a lie, because the interface asserts a variation that does not exist and therefore encodes a guess nobody has checked.
The specific damage is that the interface was designed from one implementation, so it has that implementation's shape. When the second case finally arrives it does not fit, and the interface has to change anyway — meaning the pre-built abstraction bought nothing and cost two years of indirection (What an Abstraction Costs).
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
| One implementation, added "for testability" | UserServiceImpl is the only implementer and always will be | An interface introduced to enable mocking rather than to express variation | Test the real thing, or inject the one volatile dependency it has. An interface per class is not a design (Mocking). |
| The caller branches on which implementation it has | if (channel instanceof SmsChannel) in the caller | The interface hides something callers actually need to make decisions about | Move the decision into the implementation, or surface it as data on the interface — not as a type check (Leaky Abstractions). |
| The interface has fourteen methods | Every new implementation writes ten empty bodies | The interface is the union of implementations rather than the caller's need | Split by capability; let implementations opt in to what they can do (Interface Segregation, Critically). |
| A new case needs a new method on the interface | Adding push notifications requires deviceToken() on all four | The dispatch axis is wrong — the cases differ in what they need, not only in what they do | Pass the channel-specific data in a per-channel config rather than widening the shared contract (Introduce Parameter Object). |
| No implementation registered at runtime | Production error for one customer tier only | Compile-time exhaustiveness was traded for runtime selection and never bought back | A startup or test-time assertion over every value the database can hold. |
The conditional that should stay a conditional
Not every branch is a latent type. The reliable distinction: a conditional on a *value* stays a conditional, and a conditional on a *kind* that recurs across several functions is the one worth dispatching on. Free-shipping thresholds, retry counts and feature flags are values; notification channel and payment provider are kinds.
The second test is repetition. One switch is a switch. The same switch in four places is a type that has not been named yet, and the cost of leaving it unnamed grows with every new place (Duplicate Knowledge).
interface ShippingRule { applies(o: Order): boolean; cost(o: Order): Money }
class FreeOverFifty implements ShippingRule { /* ... */ }
class StandardRate implements ShippingRule { /* ... */ }
// One call site. One rule set. The threshold changed twice
// last year and the *number of rules* never changed.
// Reading it now takes three files instead of two lines.function shippingCost(o: Order, policy: ShippingPolicy): Money {
return o.total >= policy.freeOver ? Money.zero : policy.flatRate
}
// The variation is a *value*, so it lives in a value.
// If a second country needs different rules, that is when
// the kind appears — and then it is evidence, not a guess.The variation in the second case is numeric and configurable, so the cheapest structure that absorbs it is a parameter. Turning it into a type hierarchy buys dispatch on an axis that has never varied, and pays a file and a hop for it every time anyone reads the shipping code. The polymorphic version becomes right the moment a second *kind* of rule appears — per-country rules with different inputs — and not before (The Rule of Three).
How to build it
Most important first.
- Wait for the second implementation. One implementation behind an interface has no variation to absorb and costs a file, a name and a hop for nothing (The Rule of Three).
- Derive the interface from the two real cases, keeping only what both genuinely need. An interface designed before the second case is a guess about the second case (Premature Abstraction).
- Push the differences *into* the implementations rather than leaking them out as flags. If the caller has to check
if (channel.isSms), the interface is wrong. - Model failure uniformly in the return type, because that is the part callers actually have to handle and it is where the three channels differ most (Error Modeling).
- Keep selection in one place and make it data-driven, so adding a channel is a registry entry rather than an edit to control flow.
- Do not chase every conditional. A conditional on a *value* — over the free-shipping threshold — is not polymorphism waiting to happen; only a conditional on a *type or kind that keeps recurring* is (Replace Conditional With Polymorphism).
What the next change costs
The field this whole domain exists for. A structure is only better if it makes the change after this one cheaper — and it is worth saying which changes it does not help.
- Add a fourth channel under the switch: find every switch (four at last count, and no tool tells you it is four), add a case to each, and regression the notification module as a whole because they all live in it.
- Add a fourth channel under polymorphism: one new file, one registry line, one test. Nothing existing is opened, and the existing tests do not need to run differently.
- Change the *interface* — add
estimatedCost()— and the trade reverses: every implementation must change at once, where the switch would have needed one function. Polymorphism makes new cases cheap and new operations expensive; a switch does the opposite. Which one you want depends entirely on which arrives more often. - With one implementation, neither change got cheaper and both got one hop longer. That is the case where this is indirection, not design.
- You give up seeing all the behaviour in one place. Three files is genuinely harder to read than one switch, and every reader pays that forever.
- You give up compile-time exhaustiveness in exchange for open extension. Sum types give the opposite trade; neither is strictly better (When Inheritance Fits).
- Dynamic dispatch has a runtime cost that is negligible in a notification path and is not negligible in a tight loop (Premature Optimization, Reclaimed).
What can go wrong
- The interface is the union of all three implementations, so every implementation has methods it does not need and the abstraction leaks the very thing it was hiding (Leaky Abstractions).
- Dispatch is now dynamic, so the failure moves from a compile error to a runtime "no implementation registered for channel push" — a real loss that must be bought back with a startup check or a test.
- Reading the code no longer tells you what runs. Tracing a notification means finding the registry, then the row in the database. That is the cost of polymorphism and it is permanent (Local Reasoning).
- The mitigation fails as well: adding a startup assertion that every stored channel has an implementation means the service now refuses to boot when someone adds a row to a config table.
- Callers depend on the interface only; each implementation depends on its own SDK and nothing else. The vendor dependencies stop at the implementation boundary (Volatile Dependencies).
- The registry depends on all implementations — a deliberate concentration of fan-in at one point that is easy to find (Fan-in and Fan-out).
- Nothing depends on the *set* of implementations except the registry, which is what makes adding one a local change.
- "Replace every conditional with polymorphism." Only conditionals on a recurring kind, appearing in more than one place. A single local branch turned into two classes is strictly worse (Over-Decomposition).
- "One implementation now, more later — so add the interface now." That is the speculative case, and it is the single most common way this idea is misapplied (Speculative Generality).
- "Polymorphism needs inheritance." It needs a common interface. Structural typing (Go, TypeScript), traits (Rust), protocols (Swift), duck typing (Python) and plain function values all give it without a base class (Mixins, Traits and Embedding).
- "An interface makes it testable." A small interface makes it substitutable. A large one makes it a large mock, and large mocks are how tests come to mirror implementations (Test Doubles, Precisely).
- shotgun-surgery
- divergent-change
Testing it, and how it ages
- One contract test suite run against all implementations: every channel must report failure rather than throw, must be idempotent for the same message id, must never return success without an attempt (Contract Tests).
- Test the caller against a stub implementation, which is now trivial to write because the interface is small.
- Test the registry separately — that every channel value stored in the database resolves — since that is the failure the compiler no longer catches.
- Do not write a test per implementation that merely asserts the mock was called. That is a test of the wiring, mirrored from the implementation (Mocking).
- The interface will be pulled toward the union as channels accumulate. Splitting it — a
Retryablecapability separate fromSendable— is the healthy response; widening it is the unhealthy one (Interface Segregation, Critically). - If the channel set stabilises at three forever, a sealed sum type with exhaustive matching becomes the better shape: same safety, less indirection, and the compiler back on your side.
- The interface outlives every implementation in it. That is normal, and it is why the interface should be named for the caller's need —
NotificationChannel— and never for the first implementation.
Where this applies
This domain's advice is contested more than most. These labels say what each claim is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view rather than a caricature.
- GENERALThat new cases and new operations trade against each other — cheap to add one, expensive to add the other, and reversed between dispatch and matching — is the expression problem, and it holds in every language regardless of how the dispatch is spelled.
- PARADIGM-SPECIFICIn OO the unit of variation is a type with several methods; in a functional language it is usually a record of functions or a sum type plus a match, and with first-class functions a single-method interface is just a function type — which is why Strategy and Command mostly vanish there (Strategy).
- CONTESTEDThe strongest opposing view is that a closed switch is superior wherever the case set is genuinely closed: it is exhaustively checked, entirely local, trivially readable and has no registry to get out of sync — and that OO codebases scatter behaviour across files reflexively, calling it design. That argument wins outright when the case set is fixed by something external, and loses when third parties must be able to add cases.
Where the depth lives
This domain teaches the codebase-level structure and hands the rest off.
- — Programming Languages & Runtime Internals — the expression problem stated properly, and what dynamic dispatch costs: an indirect call the branch predictor may miss and the inliner usually gives up on, which matters in a hot loop and nowhere else.
- — Testing & Reliability Engineering — one contract suite run against every implementation is the test shape polymorphism enables, and it is worth more than per-implementation tests that assert on mocks.