Engineering Atlas › Low-Level Design

Bridge pattern

14 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

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:

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

  1. Real systems vary along more than one axis at once (message kind and channel, query API and database, filesystem semantics and on-disk format).
  2. Inheritance can express one axis per hierarchy, so mixing two axes in one hierarchy multiplies classes and duplicates logic.
  3. Composition multiplies at runtime instead: hold a reference to the second axis and pick it when the object is built.
  4. 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.
  5. 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

OptionStrengthsWeaknessesChoose when
One class per combinationEach combination fully explicitM x N classes; shared rules duplicatedOnly one axis really varies
Bridge (abstraction holds implementor)M + N classes; both sides grow independentlyImplementor interface must be designed well up frontTwo independent axes, each with real variants
Strategy field on one classSimple; no abstraction hierarchyDoes not scale to many abstractions sharing a backendOne high-level class, one pluggable algorithm
Adapter per vendorFits an existing interface without changing itDoes not organise the abstraction sideThe interface already exists; a new vendor must fit
Type switch on backendFast; obvious for two casesEvery abstraction edits on every new backendPrototypes, or a closed set of two

Pitfalls in production

Leadership lens

Check yourself

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

  2. Why does database/sql fall back to prepare, execute and close when a driver lacks QueryerContext, 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.

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

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

Free resources

Open in the interactive app: practice, quiz, mock interview →