Every topic in this module presented one pattern and argued about when to use it. Real design is the opposite problem: a requirement arrives, several patterns could apply, and you must choose — including choosing none.
This topic walks one system from nothing, stating the decision at each step. The value is in the rejections as much as the selections, because naming what you did not do and why is what distinguishes a considered design from a recited one.
The system
A notification platform for a business application. Requirements, in the order they arrived — which is how requirements actually arrive, and part of the point:
- Send an email when an order is placed.
- Also send SMS and push.
- Users choose which channels they want, per notification type.
- Some notifications are legally required and must not be lost.
- Support teams need to see what was sent and retry failures.
- Marketing wants to add notification types without engineering.
Step 1: send an email
Decision: a function. No pattern.
async function sendOrderConfirmation(order) { /* … */ }There is one channel, one notification type and no variation. Introducing an interface here means designing it against a single implementation, which is a guess — and the guess would be shaped by email's concepts: subject lines, HTML alternatives, addresses.
What I do anyway, because it costs nothing: keep the sending behind one narrow function rather than inlining it in the order handler, and do not let email vocabulary reach callers. Containment is cheap; abstraction is a commitment. Encapsulation now, abstraction later.
Step 2: add SMS and push
Decision: Strategy — now, not before.
The second and third implementations are what teach the interface. And they immediately teach it something: SMS has a length limit, push has a title and a payload, email has a subject and an HTML body. A seam carrying a pre-composed string would already have failed — the first channel's vocabulary would have destroyed the information the others need.
So the seam carries structured content: a title, a body, an urgency. Each channel renders it as it sees fit.
const channels = { email: emailChannel, sms: smsChannel, push: pushChannel };Rejected: Bridge. Both notification kinds and channels will eventually vary, which is Bridge's signal — but there is only one notification type today. Bridge needs both hierarchies to be real; with one kind it is Strategy, and pre-emptively splitting would design the seam against a single case.
Step 3: per-user channel preferences
Decision: still no new pattern.
The temptation is a rules engine or a Chain of Responsibility over preference rules. But this is a lookup — which channels has this user enabled for this type — followed by a loop. A pattern here would be structure imposed on a query.
Rejected: Chain of Responsibility. Its shape is "the first handler that applies takes it". Here all matching channels are used, not the first. The semantics do not match, and adopting it would suggest an optionality that does not exist.
Step 4: legally required notifications
Decision: this changes the architecture, and it is the first requirement that does.
"Must not be lost" means an in-process call is inadequate — a crash between the order committing and the notification sending loses it. This is the dual-write problem, and it has one correct answer.
Transactional outbox. The notification request is written in the same transaction as the order. A relay publishes it. Delivery becomes at-least-once, so consumers must be idempotent — each notification carries a stable identifier and the sender records what it has handled.
Rejected: Observer with an in-memory event bus, which is what this would naturally have been. In-process events are at-most-once and vanish on a crash, which is exactly the failure the requirement forbids. This is the distinction that matters: reactions may be observers, required steps may not.
Also rejected: making the notification part of the order transaction directly. Sending is a network call; holding a database transaction open across it is how connection pools are exhausted.
Step 5: visibility and retry
Decision: Command, and it is nearly free at this point.
The notification request is already a serialisable record in the outbox. Making it an explicit command object gives support tooling something to display, filter and re-enqueue.
What this forces, which is the real content of the decision: retry means running it twice, so idempotency stops being a nicety. The identifier already exists from step 4. Retries must be bounded, and exhausted commands go to a dead-letter destination with the failure recorded — a poison notification retried forever would block everything behind it.
Rejected: a general undo. Support asked to "unsend" a notification. There is no such operation — the email has left. What they need is a compensating notification (a correction), and naming it honestly prevents an interface that promises something impossible.
Step 6: marketing adds notification types
Decision: templates as data. No Interpreter.
Diagram, as text
- Domain event order.placed leads to Outbox same transaction
- Outbox same transaction leads to Relay
- Relay leads to Queue
- Queue leads to Worker: idempotent
- Worker: idempotent leads to Preferences lookup
- Preferences lookup leads to Template registry (data)
- Template registry (data) leads to Strategy: per channel
- Strategy: per channel leads to Email · SMS · Push
- Worker: idempotent leads to Delivery log for support
A notification type is a registry entry: a name, a template per channel, a default preference. Adding one is a data change with a form and a preview, not a deployment.
Rejected: a rules DSL. The bar for a language is that rules change faster than code, non-engineers genuinely author them, and the rule space is bounded. Only the second is arguably true here, and templates plus a preference lookup meet the requirement with no parser, no grammar and no evaluation limits to design.
Rejected: Abstract Factory over channels. Channels are independent — a valid configuration can mix a real email channel with a fake SMS one, and tests do exactly that. Abstract Factory exists to prevent mixing, and here mixing is legitimate.
What the finished design contains
| Pattern | Why |
|---|---|
| Strategy | Three interchangeable channels, chosen at runtime |
| Transactional outbox | Required notifications cannot be lost |
| Command | Requests are inspectable, retryable, auditable |
| Producer–consumer | Bounded queue between the relay and the workers |
| Registry as data | Notification types added without deployment |
Five patterns, arrived at in six steps, none introduced before its requirement. Four were rejected explicitly: Bridge, Chain of Responsibility, Abstract Factory and a rules DSL.
Check your understanding
Common mistakes
Designing all six steps at step one. The seam would be shaped by the only implementation you had.
Reaching for a pattern when a lookup would do. Step 3 is a query and a loop.
Modelling a required step as an observer. Silent loss on any crash.
Adding retry without idempotency. At-least-once delivery makes duplicates certain.
Promising undo for an operation with external effects. An email has left; only compensation is available.
Building a DSL because "non-engineers will author rules". Test that claim; templates plus a lookup usually suffice.
Listing patterns in an interview without naming rejections. It demonstrates recall rather than judgement.
Practice
Design a file-processing pipeline with the same discipline: uploads arrive, are virus-scanned, converted to a standard format, thumbnailed, indexed for search, and must be auditable. State each pattern you choose and at least three you reject with reasons.
Write your own answer first, on paper or in a note. Then compare.
Show a hint
Work requirement by requirement, and be specific about why each rejection does not fit.
Show a strong answer
Ordering and failure semantics first, because they determine everything else. Scan must precede conversion — converting a malicious file means executing it in a decoder. Conversion must precede thumbnailing and indexing, which both need the standard format. Thumbnailing and indexing are independent of each other.
Chosen patterns:
- Template Method or a fixed pipeline function for the ordering. The sequence is a correctness invariant, not a convention, so it must live in one place where no file type can reorder it. I would use a plain function taking the varying steps rather than inheritance, since only parsing varies per type — the invariant is preserved without the coupling.
- Strategy per file type for the parse and convert steps. Several interchangeable implementations with one shape, chosen at runtime.
- Claim check. Files are large; messages carry a storage reference, not bytes. This also means object retention must exceed the maximum message age including dead-letter time.
- Producer–consumer with a bounded queue between stages, so a slow converter applies backpressure rather than exhausting memory. Bounded, sized from a latency budget.
- Transactional outbox for the audit requirement, so the record of what happened commits with the state change and cannot be lost.
- Command for each processing job — inspectable, retryable, idempotent by a stable key.
Rejected, with reasons:
- Chain of Responsibility. The shape is wrong: every stage must run, and none may decline. Calling it a chain implies an optionality that does not exist, and the first person to add a stage will assume they can skip it.
- Observer for the ordering. Thumbnailing and indexing are genuinely independent and could be observers — but scanning and conversion cannot, because they are required and ordered. I would use events only for the two genuinely optional stages, and keep the required sequence explicit.
- Abstract Factory over the per-type components. Parser and converter for one file type do need to agree, which is the pattern's signal — but the set is small and the accessor would return two things. A single per-type object with both operations is simpler and expresses the same constraint.
- Visitor over file types. Tempting, because operations (scan, convert, thumbnail, index) look like they grow while types look fixed. But the type set is not closed: new formats arrive as product requirements, and every new format would edit every visitor. This is exactly Visitor's expensive direction.
The decision I would flag as needing a product answer: what happens when scanning finds a virus. That is not a pattern question — it decides whether the pipeline has a rejection path, whether the uploader is told, and whether the file is retained as evidence. Getting it wrong architecturally is much more expensive than any pattern choice here.
A candidate in a design interview lists eight patterns they would use, correctly, with accurate descriptions. Why might a strong interviewer still be unconvinced, and what would change their assessment?
Write your own answer first, on paper or in a note. Then compare.
Show a hint
What does listing correct patterns demonstrate, and what does it not?
Show a strong answer
Because correct recall and good judgement are different things, and only one of them is being tested.
Eight accurate descriptions demonstrate that the candidate has read the catalogue. It says nothing about whether they can tell when a pattern applies — which is the entire difficulty in practice, since a pattern applied where its conditions do not hold makes code worse while looking sophisticated. An interviewer who has reviewed a lot of code has seen far more damage from patterns applied confidently than from patterns not known.
The specific concerns a listing raises:
- Eight patterns in one design is itself a signal. Real designs of interview scope usually need three or four. Eight suggests the patterns are driving the design rather than responding to it.
- No rejections means no decisions were made. If every pattern considered was adopted, nothing was weighed, and the interviewer cannot see any reasoning — only its output.
- No costs stated means the trade-offs may not be understood. Every pattern buys flexibility along one axis and charges indirection. A candidate who never mentions the charge may not know it exists.
- No sequencing — everything designed up front — suggests they would design the seam against the first implementation, which is the most common way real designs go wrong.
What would change the assessment, in order of impact:
- Naming a pattern they considered and rejected, with the reason. "I would not use an event bus for this because the requirement says it cannot be lost, and in-process events are at-most-once — so it is an outbox." That single sentence demonstrates more than the eight correct descriptions combined, because it shows the condition being checked rather than the pattern being recalled.
- Stating what each pattern costs, and that they are willing to pay it here.
- Saying what they would not build yet. "I would keep this as a function until there is a second implementation, because the interface would otherwise be shaped by email" shows they understand timing, which is where most speculative abstraction comes from.
- Naming what would change their mind. A design presented with its conditions attached is a judgement; one presented as fixed is a recitation.
The general point: an interviewer is trying to predict what the candidate will do in a code review a year from now — whether they will add an interface with one implementation, or ask what varies. Listing patterns does not predict that. Explaining a rejection does.
Key takeaways
- Real design is choosing among patterns, including choosing none — the rejections are where judgement is visible.
- Encapsulation now, abstraction later. Containing logic behind one entry point is cheap; an interface is a commitment.
- The second implementation teaches the interface. A seam designed against the first carries its vocabulary and fails for the second.
- Seams must carry meaning, not formatting — the failure that would have occurred had the channel interface taken a composed string.
- Required steps are not observers. "Must not be lost" means an outbox, and at-least-once delivery means idempotency.
- Retry is undo's opposite: retry needs idempotency, and undo of an external effect is compensation, not reversal.
- Test the "non-engineers will author this" claim before building a language; templates as data usually suffice.
- Five patterns across six requirements, none before its requirement — and four rejected explicitly.
Further reading
- Erich Gamma, Richard Helm, Ralph Johnson and John Vlissides, Design Patterns: Elements of Reusable Object-Oriented Software (Addison-Wesley, 1994).
- Joshua Kerievsky, Refactoring to Patterns (Addison-Wesley, 2004).
- Gregor Hohpe and Bobby Woolf, Enterprise Integration Patterns (Addison-Wesley, 2003).
- Chris Richardson, Microservices Patterns (Manning, 2018).
- Sandi Metz, Practical Object-Oriented Design in Ruby (Addison-Wesley, 2012).
- Michael Nygard, Release It!, second edition (Pragmatic Bookshelf, 2018).