GUIDE · Structural
Bridge
Decouple an abstraction from its implementation so that the two can vary independently.
At a glance
- INTENT
- Decouple an abstraction from its implementation so that the two can vary independently.
- PROBLEM
- N kinds of thing (Alert, Digest, Reminder) × M ways to deliver them (Email, SMS, Push) and subclassing gives you N×M classes (EmailAlert, SmsAlert, PushAlert, EmailDigest, ...).
- SAY
- Participants: Abstraction (Notification — holds an Implementor), RefinedAbstraction (UrgentAlert, Digest), Implementor (Transport interface), ConcreteImplementor (EmailTransport, SmsTransport). Pitch: "two hierarchies composed, not multiplied — N+M classes instead of N×M."
- SEEN IN
- Winston/pino (Logger abstraction × Transport implementors); react-reconciler (React component model × host renderers react-dom / react-native / ink); ORMs (Model/Query API × dialect driver); Expo/React Native modules (JS API × iOS/Android native implementations); Node
http/httpssharing one request API over different sockets. - VS
- Adapter retrofits an interface AFTER the fact onto something you don't control; Bridge is designed UP FRONT so both sides can grow. Strategy swaps one algorithm; Bridge swaps a whole implementation hierarchy (same shape — context delegates to an object — different intent).
The idea#
Picture a remote and a TV, designed as two pieces from the start. The remote has the buttons. The TV is the box that actually carries the action out. A fancy remote and a basic remote are one family. A living-room TV and a kitchen speaker are another family. You can hand either remote either box. You do not manufacture a separate gadget for every remote-and-box pair. Two remotes plus two boxes is four combinations from four pieces.
A bridge is that split. One hierarchy is the abstraction: what you mean. The other is the implementor: how a particular device carries it out. The abstraction holds an implementor and delegates. Refined abstractions change the meaning. Concrete implementors change the delivery. They vary on their own. The count is N plus M classes, not N times M fused subclasses.
In code the explosion is messages times channels. Urgent alerts and digests are kinds of notice. Email, SMS, and push are ways to deliver them. Subclassing the pairs gives you a class for every combination. Add a channel and you write it again for every kind of notice. Compose them and a new channel is one implementor. A new kind of notice is one abstraction. Two refined notices plus three transports are six combinations from five classes.
Why it matters#
The bug this pattern prevents is a class list that multiplies every time either side grows. Change the urgent prefix and you would edit every channel. Change the SMS length rule and you would edit every kind of notice. With the bridge, UrgentAlert only knows the uppercase text and the action-required subject. SmsTransport only knows it has no subject, so it glues subject and body into one short text. You can swap the transport on a live alert from email to push, and the alert class does not change.
You already use this without the textbook name. Winston and pino are a logger over pluggable transports. React's reconciler is the component model, and react-dom, react-native, and ink are host renderers. An ORM query API over dialect drivers is the same split. Expo and React Native modules put one JavaScript API over iOS and Android. Node's http and https share one request API over different sockets.
An adapter is bolted on later, so something you do not control can fit an interface you already have. A bridge is drawn up front, so both sides are allowed to grow. A strategy also sits behind an object and swaps behavior. A strategy swaps one algorithm. A bridge swaps a whole implementation hierarchy. The picture is similar, an outer object delegating to an inner one, and the reason is different.
A small example#
function tv(action) { return { device: 'tv', action: action };}function speaker(action) { return { device: 'speaker', action: action };}function basicRemote(device) { return { power: () => device('power') };}function fancyRemote(device) { return { power: () => device('power'), mute: () => device('mute'), };}const livingRoom = fancyRemote(tv).mute();const kitchen = basicRemote(speaker).power();livingRoom is mute on the TV. kitchen is power on the speaker. No class was fused out of a remote plus a device.
What the code below is doing#
The sheet shows the same idea twice.
The first shape uses classes so the book names are visible. The implementor shape is send(to, subject, body). There is no separate interface class. EmailTransport, SmsTransport, and PushTransport are the concrete implementors. Email records via, to, subject, and body. SMS has no subject, so it stores one text, subject plus body, cut at 160 characters. Push stores a title and a body. Notification is the abstraction. It holds a transport, and notify sends to user.contact with subject() and the text. The base subject is "Notification." UrgentAlert and Digest are the refined abstractions. They change what the notice means, not how it travels. Urgent sets the subject to "[URGENT] Action required" and uppercases the text. Digest takes the items, sets the subject from the count, and joins them with |. The checks send Ada an urgent email, Bob an urgent text, and Ada a push digest of two items. The log shows the uppercase email body, the SMS text with the urgent subject glued on, and a push title of "Your 2 updates." Replacing that alert's transport with push makes the next notify report via: 'push'. Two refined abstractions times three transports is six combinations, from five classes.
The second shape is how you would write it on a normal day. transports.email and transports.sms are plain functions. makeNotifier(send) is the abstraction: alert and digest decide the words, then call the send you passed. An email alert uppercases the body to LATE. A text digest of two items reads 2 updates: a | b. A function that returns via: 'noop' is a test-double implementor, and the alert still runs. createLogger is the same split for a logger. info and error are the buttons. The transport writes the line, so one of them records INFO boot and ERROR oops.
When you talk about it, say this: two hierarchies, composed and not multiplied. The abstraction holds an implementor. Refined abstractions and concrete implementors grow on their own, so you add N plus M classes instead of N times M.
Everything below is the demonstration. Same idea, written as code you can run.
'use strict';const assert = require('node:assert/strict');Classic shape (classes, so the participant names are visible)#
// Implementor interface: send(to, subject, body) -> recordclass EmailTransport { // ConcreteImplementor constructor(log) { this.log = log; } send(to, subject, body) { this.log.push({ via: 'email', to, subject, body }); return this.log.at(-1); }}class SmsTransport { // ConcreteImplementor — no subject; copes locally constructor(log) { this.log = log; } send(to, subject, body) { this.log.push({ via: 'sms', to, text: `${subject}: ${body}`.slice(0, 160) }); return this.log.at(-1); }}class PushTransport { // ConcreteImplementor constructor(log) { this.log = log; } send(to, subject, body) { this.log.push({ via: 'push', to, title: subject, body }); return this.log.at(-1); }} class Notification { // Abstraction — delegates to the implementor constructor(transport) { this.transport = transport; } notify(user, text) { return this.transport.send(user.contact, this.subject(), text); } subject() { return 'Notification'; }}class UrgentAlert extends Notification { // RefinedAbstraction — varies WHAT, not HOW subject() { return '[URGENT] Action required'; } notify(user, text) { return super.notify(user, text.toUpperCase()); }}class Digest extends Notification { // RefinedAbstraction constructor(transport, items) { super(transport); this.items = items; } subject() { return `Your ${this.items.length} updates`; } notify(user) { return super.notify(user, this.items.join(' | ')); }} const log = [];const ada = { contact: 'ada@x.io' };const bob = { contact: '+15550100' }; new UrgentAlert(new EmailTransport(log)).notify(ada, 'disk 99% full');new UrgentAlert(new SmsTransport(log)).notify(bob, 'disk 99% full');new Digest(new PushTransport(log), ['3 comments', '1 mention']).notify(ada); // Swap the implementor at runtime — abstraction code untouchedconst alert = new UrgentAlert(new EmailTransport(log));alert.transport = new PushTransport(log); // 2 abstractions × 3 implementors = 6 combinations from 5 classes (not 6 subclasses)Verified by 5 asserts
assert.deepEqual(log[0], { via: 'email', to: 'ada@x.io', subject: '[URGENT] Action required', body: 'DISK 99% FULL' });assert.deepEqual(log[1], { via: 'sms', to: '+15550100', text: '[URGENT] Action required: DISK 99% FULL' });assert.deepEqual(log[2], { via: 'push', to: 'ada@x.io', title: 'Your 2 updates', body: '3 comments | 1 mention' });assert.equal(alert.notify(ada, 'x').via, 'push');assert.equal([UrgentAlert, Digest].length * [EmailTransport, SmsTransport, PushTransport].length, 6);Idiomatic JS shape: the abstraction is a function/object that takes the implementor as a parameter#
const transports = { email: (to, subject, body) => ({ via: 'email', to, subject, body }), sms: (to, subject, body) => ({ via: 'sms', to, text: `${subject}: ${body}`.slice(0, 160) }),};const makeNotifier = (send) => ({ // abstraction side alert: (to, text) => send(to, '[URGENT]', text.toUpperCase()), digest: (to, items) => send(to, `${items.length} updates`, items.join(' | ')),}); const emailer = makeNotifier(transports.email);const texter = makeNotifier(transports.sms); // Logger × transport — the shape you already use every day (winston/pino)const createLogger = (transport) => ({ info: (msg) => transport('info', msg), error: (msg) => transport('error', msg),});const lines = [];const logger = createLogger((level, msg) => lines.push(`${level.toUpperCase()} ${msg}`));logger.info('boot'); logger.error('oops');Verified by 4 asserts
assert.equal(emailer.alert('a@x.io', 'late').body, 'LATE');assert.equal(texter.digest('+1', ['a', 'b']).text, '2 updates: a | b');assert.equal(makeNotifier((to) => ({ via: 'noop', to })).alert('x', 'y').via, 'noop'); // test-double implementorassert.deepEqual(lines, ['INFO boot', 'ERROR oops']);See also