You inherit a hierarchy of classes like EmailAlert, SmsAlert, PushAlert, EmailReminder, SmsReminder, PushReminder. Walk through refactoring it to the Bridge pattern — how do you pick which axis becomes the abstraction and which becomes the implementor?
answer
- Class names read as A×B → two fused axes
- Domain side = abstraction, mechanism side = implementor
- Abstraction calls implementor, never reverse
- Primitives, not a mirror interface
- Success test: new channel = exactly one new class
basics
~20 sSpot the two independent axes (notification kind × delivery channel). Keep the domain-facing one — the notification kind — as the abstraction hierarchy, extract the mechanism — the channel — into an interface, and have each notification hold and delegate to a channel.
solid answer
~50 sFirst confirm the axes are genuinely independent: every kind × channel pairing is meaningful, and the class names literally read as `A×B`. That prefix/suffix duplication in names is the strongest smell. Then decide direction: the **abstraction** is the side that carries domain meaning and drives the workflow (Alert vs Reminder — escalation rules, retry policy, wording); the **implementor** is the side that is a pluggable mechanism, closer to I/O or platform (Email/SMS/Push — connect, format payload, send, report failure). A useful heuristic: the abstraction calls the implementor, never the reverse; if you find yourself asking "does Email use Alert or does Alert use Email?", the answer is whichever direction gives the *lower-level, more stable, more primitive* interface. Next, design the implementor's primitive vocabulary (`send(recipient, payload)`, `maxPayloadSize()`, `supportsRichText()`) — not a mirror of `Alert.notify()`. Finally, inject the channel at construction, delete the six combination classes, and move duplicated per-channel code into the channel classes.
code
pseudocode · 28 lines// BEFORE: 2 kinds x 3 channels = 6 classes; adding WhatsApp adds 2 more
class EmailAlert {...} class SmsAlert {...} class PushAlert {...}
class EmailReminder {...} class SmsReminder {...} class PushReminder {...}
// AFTER — Implementor: primitive mechanism vocabulary
interface Channel {
send(recipient, subject, body): Receipt
maxBodyLength(): int
}
class EmailChannel implements Channel {...}
class SmsChannel implements Channel {...}
class PushChannel implements Channel {...}
// AFTER — Abstraction: domain concepts, composing primitives
abstract class Notification {
constructor(protected channel: Channel) {}
abstract notify(user)
}
class Alert extends Notification {
notify(user) {
body = truncate(render(user), channel.maxBodyLength())
receipt = channel.send(user.address, "ALERT", body)
if (!receipt.ok) channel.send(user.fallbackAddress, "ALERT", body) // escalation policy lives here
}
}
class Reminder extends Notification { ... }
// 2 + 3 = 5 classes; adding WhatsAppChannel = exactly 1 new class.go deeper
Identify the two axes from the Cartesian class names and say the channel becomes an interface the notification holds and calls.
Add the direction rule (domain = abstraction, mechanism = implementor), design a primitive channel interface rather than a mirror, and state the success test: a new channel is one new class.
Discuss verifying orthogonality first (sparse matrices, differing change cadence), the leak risk of capability queries, the step-by-step refactor keeping tests green, and where object assembly now lives.
Frame the implementor interface as a long-lived contract with versioning and ownership consequences — who may change it, what happens to out-of-tree channels — and set the bar for when the indirection is not worth paying.
## Step 0 — recognise the smell The giveaway is **names that are Cartesian products**: `EmailAlert`, `SmsAlert`, `PushAlert`, `EmailReminder`, ... Each name is `<mechanism><domain concept>`. Whenever a hierarchy's names read as `A×B`, you have two variation axes fused into one inheritance tree. Add a channel (`WhatsAppAlert`, `WhatsAppReminder`) and you add one class *per* existing kind — multiplicative growth. That is the **class explosion** Bridge exists to remove. Second smell: **duplicated code along both directions**. All three `*Alert` classes repeat the escalation/retry policy; all three `Email*` classes repeat SMTP setup and MIME formatting. Neither duplication can be pulled into a single superclass, because single inheritance only lets you factor along one axis. ## Step 1 — verify the axes are independent Bridge only pays off if the axes are genuinely orthogonal. Checks: - **Is every cell meaningful?** If `PushDigest` is nonsense, the matrix is sparse and you may be looking at one axis plus special cases, not two axes. - **Do they change for different reasons and at different rates?** (This is the Single Responsibility Principle read as "one reason to change.") Channels change when a vendor's API changes; notification kinds change when product rules change. Different owners, different cadence — good sign. - **Do they change independently?** If adding a channel always forces a change to every kind, they are not independent and the split will leak. ## Step 2 — choose the direction This is the part candidates fumble. Guidelines, in priority order: 1. **Domain vs mechanism.** The abstraction should be the side clients think in ("send a reminder"), the implementor the side that is a swappable technical mechanism ("over SMS"). Clients construct `new Reminder(smsChannel)`, not `new SmsChannel(reminder)`. 2. **Direction of calls.** In Bridge, Abstraction → Implementor, one way. Pick the direction where the callee's interface can be expressed as small, self-contained primitives with no knowledge of the caller. `Channel.send(recipient, payload)` needs to know nothing about alerts; `Alert.escalate()` would need to know about channels. So Channel is the implementor. 3. **Stability.** The implementor interface is the contract both sides depend on, so put the *more stable, more primitive* vocabulary there. A volatile interface is expensive because every concrete implementor must be updated. 4. **Cardinality is not the criterion.** "Fewer classes therefore implementor" is a bad rule; a bridge with 2 abstractions and 20 implementors is perfectly normal (think: 2 report styles, 20 storage backends). ## Step 3 — design the primitive interface The most common mistake is making the implementor a **mirror** of the abstraction: ``` interface Channel { notify(Notification n) } // ← mirror: a wrapper, not a bridge ``` That re-couples the halves — every channel now knows the notification model, and adding a notification kind may force channel changes. Instead, expose primitives the abstraction *composes*: ``` interface Channel { send(recipient, subject, body): DeliveryReceipt maxBodyLength(): int supportsRichText(): bool } ``` Now `Alert.notify()` can do: truncate to `maxBodyLength()`, choose plain vs rich formatting, call `send`, and on failure apply its own escalation policy by calling `send` again on a fallback recipient. Note it calls the implementor *several times and conditionally* — composition, not one-to-one forwarding. Capability-query methods (`supportsRichText`) are a mild leak: the abstraction is reasoning about implementor differences. A little of this is normal and pragmatic; a lot of it (long `if (channel is SmsChannel)` chains) means the interface is wrong. ## Step 4 — mechanical refactor 1. Extract the `Channel` interface and create `EmailChannel`, `SmsChannel`, `PushChannel` by moving the channel-specific code out of the six classes (each channel's logic currently exists in duplicate — pick the best copy, diff the others, reconcile). 2. Turn `Alert` and `Reminder` into the abstraction hierarchy, each taking a `Channel` in its constructor and calling only interface methods. 3. Replace every `new EmailAlert(...)` at call sites with `new Alert(emailChannel, ...)` — a factory or DI container usually centralises this so call sites stay tidy. 4. Delete the six combination classes. Keep tests green at each step; the six original classes' tests become the source of truth for the merged behaviour. 5. Verify the win: adding a `WhatsAppChannel` must now be exactly one new class with zero edits elsewhere. If it isn't, the seam is in the wrong place. ## When *not* to do this - Only one channel exists and none is planned → indirection with no payoff. - The matrix is sparse or cells behave wildly differently → the axes aren't independent; you'll end up with conditionals reintroducing the combinations. - Behaviour differences are per-call, not per-object → that's Strategy territory (swap an algorithm per invocation) rather than a lifetime-long structural split.
- After the refactor, `Alert` contains `if (channel.supportsRichText()) ... else ...` in five places. Is that acceptable?A capability query or two is pragmatic, but five branches signals the primitive interface is at the wrong level. Options: push formatting into the channel (`send` takes a structured message and each channel renders it), or introduce a small formatting collaborator the channel supplies. If branches key off concrete types (`channel is SmsChannel`) rather than declared capabilities, the abstraction is depending on implementations and the bridge is broken.
- How do you keep call sites clean once construction requires two objects?Centralise assembly: a factory method (`Notifications.alertOver(SMS)`), an Abstract Factory when several parts must be consistent, or a DI container that binds the channel once per environment. Bridge deliberately moves the pairing decision to composition time, so somebody must own that decision — make it one place, not every call site.
- What if a new requirement adds a third axis, like locale-specific templating?Check whether it is truly a peer axis. Templating usually layers onto formatting, so it can hang off the abstraction (a template collaborator) or off the channel. If all three are genuine peers, chained bridges or a parts-based composition is better than a second explosion; an Abstract Factory can assemble consistent triples.
saying these in an interview costs you the question
- Making the implementor interface a mirror of the abstraction's methods (`Channel.notify(Notification)`)
- Picking the implementor side by class count instead of by level of abstraction and stability
- Skipping the orthogonality check and bridging a sparse matrix, then reintroducing combinations via conditionals
- Leaving `if (channel is SmsChannel)` type checks in the abstraction after the refactor
- Refactoring with one implementation and no realistic second one