Bridge pattern
Open in the interactive app: practice, quiz, mock interview →In one minute
- Bridge splits one class hierarchy that varies in two directions into two hierarchies joined by a reference: an abstraction (what the caller works with) holds an implementor (how the low-level work is done), and each side grows on its own.
- The win is arithmetic. Four message kinds times three delivery formats is twelve subclasses by inheritance and seven classes with a bridge. Adding a fifth format costs one class instead of four.
- The design work is in the implementor interface: a small set of primitives that every backend can offer, coarse enough to be cheap across the boundary and complete enough that the abstraction never asks "which backend are you?".
- Unlike Adapter, a bridge is designed up front, before the second backend exists or as soon as the second axis appears. Go's
database/sql(abstraction) overdatabase/sql/driver(implementor) is the textbook production example. - Agents asked for "Slack support" will add
SlackAlert,SlackDigestandSlackReportnext to the email versions. Review asks which axis is varying and whether one new implementor class would do.
How it works
The problem
An operations team sends three kinds of notification: alerts, daily digests and incident summaries. They started with email, so the code has Alert, Digest and IncidentSummary, each building an HTML body. Then chat came along, and someone wrote ChatAlert, ChatDigest and ChatIncidentSummary as subclasses that override the body building. Then SMS. Nine classes now, and the rule "critical alerts are prefixed with the on-call name" lives in three of them. When a fourth kind (a change notice) arrives, it needs three new subclasses, one per channel, and the one for SMS is forgotten.
The hierarchy is varying along two independent axes, what is being said and how it is rendered, and inheritance can only express one axis at a time. Each new point on either axis multiplies the class count by the size of the other.
Split the axes
Bridge pulls the rendering axis out into its own interface with a few primitive operations: heading(text), paragraph(text), bullet(text) and join(lines). Each message kind becomes one class that holds a reference to a format and composes its output from those primitives. Each format becomes one class that knows nothing about alerts or digests.
class Digest:
def __init__(self, fmt, title, items):
self.fmt, self.title, self.items = fmt, title, items
def render(self):
lines = [self.fmt.heading(f"{self.title} ({len(self.items)})")]
lines += [self.fmt.bullet(i) for i in self.items]
return self.fmt.join(lines)The class count is now kinds plus formats. The "prefix critical alerts" rule exists once, in Alert. A new format is one class and works with every message kind on day one, including kinds written after it.
The vocabulary
The abstraction is the high-level type the client uses (Message), extended by refined abstractions (Alert, Digest). The implementor is the low-level interface (Format), provided by concrete implementors (MarkdownFormat, PlainFormat). The reference between them is the bridge. In Go the abstraction side is often one struct, and the bridge is one interface-typed field.
Designing the implementor interface
This is where bridges succeed or fail. The implementor interface should be:
- Primitive. It offers the operations every backend can do, not the high-level operations the client wants. A drawing implementor offers
lineandfill_rect, notdraw_bar_chart. The abstraction builds the chart out of lines. - Complete for today's abstractions. If one abstraction needs something only one backend has, it will reach around the bridge with a type check, and the split is broken.
- Coarse where the boundary is expensive. If the implementor is a network driver,
fetch_row()per column is a disaster andnext(dest_row)per row is fine.
Optional capabilities are the hard part. Go solves them with optional interfaces: the abstraction checks with a type assertion whether the implementor also implements a richer interface, uses it if so, and falls back to the primitives otherwise.
Bridge, Adapter, Strategy
Adapter and Bridge both put an interface between a client and a concrete class. An adapter is written after the fact to make an existing class fit an interface it was not built for (see Adapter). A bridge is designed in advance so both sides can grow. In practice a bridge's concrete implementors are often adapters over vendor SDKs: the bridge sets the interface, adapters satisfy it.
Strategy (see Strategy) has the same object diagram as the core of a bridge: an object holding an interface-typed field. The difference is scope. A strategy is one interchangeable algorithm inside an otherwise fixed class. A bridge separates a whole layer, with its own hierarchy of abstractions on top that all share the implementor. If only one class on the abstraction side will ever exist, call it a strategy and stop there.
Reviewing agent-written code
Agents extend whatever axis they find: given EmailAlert and asked for Slack, they write SlackAlert, then SlackDigest. The reverse failure is also common: asked to "use the Bridge pattern", an agent produces AbstractMessage, MessageImplementor, MessageImplementorFactory and one implementor. Ask two questions in review. Are there really two axes that change independently, with at least two points on each today or on the roadmap? And does any abstraction inspect the implementor's concrete type (isinstance(fmt, SlackFormat))? If it does, a primitive is missing from the interface.
The same shape elsewhere
Bridge is how operating systems survive hardware churn. The Linux virtual filesystem is an abstraction (path lookup, permissions, the page cache, open, read, write) over implementors that fill in struct file_operations and struct inode_operations tables: ext4, XFS, NFS, procfs. A new filesystem gets every syscall for free. Python's logging has the same split: Logger decides whether and where a record goes (levels, propagation), and Handler subclasses decide how bytes leave the process (stream, file, syslog, HTTP). The problem shape is fan-out of combinations: when two concerns multiply, keep them as separate types and let composition do the multiplying at runtime.
Down the stack
Layer 1The contract
The abstraction promises its clients behaviour that does not depend on which implementor is plugged in, apart from documented differences (a plain-text format cannot show bold). The implementor promises its primitives' semantics: inputs, outputs, errors, and thread safety. Two things are part of that contract and are often left unstated. First, who owns concurrency: sql.DB is safe for concurrent use by many goroutines, while a driver.Conn is used by one goroutine at a time, and database/sql guarantees that so driver authors do not have to lock. Second, what happens when a capability is missing: return a specific error the abstraction recognises (Go's driver.ErrSkip) rather than a generic failure.
Layer 2The mechanism
Go's database/sql is a bridge you can read in an afternoon. The abstraction side is DB, Tx, Stmt, Rows and Row: connection pooling, retries on bad connections, transaction lifecycle, and scanning into Go types. The implementor side is driver.Driver, driver.Conn, driver.Stmt and driver.Rows, deliberately small: Prepare, Close, Begin, and Rows.Next(dest []driver.Value) which fills one whole row per call. Optional interfaces carry the extras. If a connection implements driver.QueryerContext, database/sql calls it directly; if not, or if it returns driver.ErrSkip, it falls back to prepare, query and close. driver.SessionResetter and driver.Validator let the pool ask a connection whether it can be reused. The abstraction grew pooling limits, context support and sql.Null[T] over the years without breaking a single driver, because those features live on the abstraction side.
JDBC has the same structure in Java, with java.sql.Connection and friends as the implementor interface vendors ship.
Layer 3The machine
The bridge costs one pointer in the abstraction (8 bytes on 64-bit) and one indirect call per primitive. In Go, an interface value is two words, an itab pointer and a data pointer; a call loads the method's address from the itab and branches to it, around 1 to 2 ns when the branch predictor has seen the target. The compiler cannot inline through the interface, and a concrete implementor stored in an interface field usually escapes to the heap, so building a bridged object costs an allocation that a concrete field would not.
The cost that matters is the number of crossings. A renderer that calls bullet() once per item pays a few nanoseconds per item, nothing next to building the string. A driver whose implementor interface returned one column per call would cross the boundary rows times columns times; Rows.Next filling a slice keeps it to once per row, and network protocols batch further (Postgres sends many DataRow messages per TCP segment). In kernels, indirect calls got more expensive after Spectre, because retpoline mitigations turn each one into a sequence that defeats prediction, costing tens of cycles. Linux added static_call in 5.10 to patch hot function-pointer calls into direct calls, which is the kernel devirtualising its own bridges.
In JavaScript, the call site this.fmt.heading(...) gets an inline cache keyed on the implementor's hidden class; V8 turns it megamorphic, with a slower lookup, once it has seen more than four shapes. In CPython, every self.fmt.heading is two attribute lookups and a bound method, roughly 50 to 100 ns.
Why it's built this way
- Real systems vary along more than one axis at once (message kind and channel, query API and database, filesystem semantics and on-disk format).
- Inheritance can express one axis per hierarchy, so mixing two axes in one hierarchy multiplies classes and duplicates logic.
- Composition multiplies at runtime instead: hold a reference to the second axis and pick it when the object is built.
- The reference must cross a stable interface, so the implementor interface is kept primitive and small, which is also what lets third parties write implementors.
- Each crossing costs an indirect call (and in drivers, sometimes a network round trip), so the primitives are made coarse enough that crossings stay rare.
Diagram
flowchart LR
C["Client"] --> A["Alert"]
C --> D["Digest"]
A -- "fmt" --> F{"Format interface"}
D -- "fmt" --> F
F --> M["MarkdownFormat"]
F --> P["PlainFormat"]
F --> S["SlackFormat (added later)"]Worked example
A SaaS company had 5 notification kinds and 3 channels implemented as 15 classes, about 4,200 lines. An audit found the "include the runbook link in critical alerts" rule implemented in 3 of the 5 places it belonged, and the SMS digest silently dropped items past 160 characters while email showed all of them.
The refactor introduced a Format implementor with five primitives (heading, paragraph, bullet, link, join) and a length budget the format reports. Message kinds shrank to 5 classes that build output from primitives; formats became 3 classes. Total: 8 classes, about 1,500 lines, and the runbook rule exists once. Adding Microsoft Teams later was one class of 90 lines and a contract test suite run against it, instead of 5 classes.
Performance was a non-issue: a 200-item digest makes about 202 primitive calls, roughly 16 us in CPython, against 30 to 200 ms for the provider's HTTP call. The one design subtlety was SMS: the abstraction asks the format for its budget once per message and truncates with "and N more" itself, instead of calling a fits() primitive per item.
Trade-offs
| Option | Strengths | Weaknesses | Choose when |
|---|---|---|---|
| One class per combination | Each combination fully explicit | M x N classes; shared rules duplicated | Only one axis really varies |
| Bridge (abstraction holds implementor) | M + N classes; both sides grow independently | Implementor interface must be designed well up front | Two independent axes, each with real variants |
| Strategy field on one class | Simple; no abstraction hierarchy | Does not scale to many abstractions sharing a backend | One high-level class, one pluggable algorithm |
| Adapter per vendor | Fits an existing interface without changing it | Does not organise the abstraction side | The interface already exists; a new vendor must fit |
| Type switch on backend | Fast; obvious for two cases | Every abstraction edits on every new backend | Prototypes, or a closed set of two |
Pitfalls in production
- Abstractions that check the implementor's concrete type for one special case. Fix: add a primitive or an optional capability interface, with a fallback.
- An implementor interface designed around the first backend's API, so the second backend cannot implement half of it. Fix: write the second implementor (even a fake) before freezing the interface.
- Chatty primitives across an expensive boundary, such as a per-field call into a driver or a remote renderer. Fix: batch at the boundary and measure crossings per request.
- Unstated thread-safety rules, so one implementor locks and another does not. Fix: state in the contract who serialises access, as
database/sqldoes for connections. - A bridge built for one axis that never got a second point. Fix: collapse it back to a concrete class; the indirection is cost without benefit.
Leadership lens
- Ask in design review which two things vary, who adds points on each axis, and how often. A bridge pays off when different teams or vendors own the two sides.
- Treat the implementor interface as a published API: version it, keep it small, and ship a contract test suite that every implementor must pass.
- Give a bridge's implementor side a clear owner. In platform teams, the abstraction is the product and the implementors are integrations that other teams may write.
- For agent-assisted work, state the rule in the task: "new channel = one new
Formatclass plus contract tests; do not add message subclasses". It turns a cross-cutting change into an additive one.
Check yourself
Four report types are exported to three file formats by inheritance. How many classes does adding a fourth format cost before and after introducing a bridge?
Show answer
Before, four (one per report type); after, one, because the new format implements the implementor interface and every report type uses it.
Why does
database/sqlfall back to prepare, execute and close when a driver lacksQueryerContext, instead of requiring every driver to implement it?Show answer
Keeping the required implementor interface minimal lets more databases implement it; optional interfaces add fast paths without breaking drivers that lack them.
How do you tell a bridge from a strategy when both are an object holding an interface-typed field?
Show answer
A strategy swaps one algorithm inside one class; a bridge separates a layer, so several abstractions share the implementor and both sides have their own hierarchies.
An abstraction contains
if isinstance(self.fmt, SmsFormat): truncate(). What is wrong and what is the fix?Show answer
The abstraction depends on a concrete implementor, so every new format may need another branch. Expose the need as a primitive or capability (a length budget) that each format answers.
Watch
- Bridge Design Pattern (Derek Banas): builds a bridge between remote controls and devices in Java step by step.
- The Bridge Design Pattern (Design Patterns Lectures): a lecture-style walk through the roles and the class explosion the pattern prevents.
Free resources
- Bridge (Refactoring.Guru): the structure, its relation to Adapter and Strategy, and code in many languages.
- Composition over inheritance (Brandon Rhodes, python-patterns.guide): the class-explosion problem in Python's own logging module and how a bridge solves it.
- database/sql/driver (Go docs): a production implementor interface, with optional interfaces and
ErrSkip. - database/sql source (Go): the abstraction side, including pooling and the fallbacks when a driver lacks a capability.
- Bridge pattern (Wikipedia): background, the Gang of Four roles, and examples in several languages.