Lesson 08 / 30
Bridge
Separate evolution of abstraction and implementation: comparing the arrangement where the report-type and output-format axes are multiplied through inheritance against the arrangement split into two hierarchies, by type, line, and output-body count; measuring the added type and lines when a third format is added; the cost of a product collapsing to a sum.
Contents
The adapter stepped in between two existing interfaces after the fact: the providers were already written, and so was the library’s contract; the pattern reconciled the two. This lesson’s problem shows up at the design stage, before anything is written: a capability changes along two independent axes at once.
The library is going to produce a fee report. The report’s type is one axis — summary, weight-tier breakdown, zone breakdown — and the business rule requests a new type as it changes. Its output format is the second axis: plain text, delimited values, aligned table. The two axes grow unaware of each other; no format is tied to a particular type. The bridge pattern splits them into separate hierarchies and places a contract between them. The gain is measured in type count: combining through inheritance yields the product of the type count and the format count, the bridge yields their sum.
Problem: Same Data, Separate Format
The reported shipment set and the grouping helper are common to both arrangements; they are not the axes being compared.
// data.mjs — the shipment set both arrangements report on; amount is in cents const TIER = [[1, 4990], [5, 8490], [15, 14990], [30, 24990]]; const COEFFICIENT = { 34: 100, "06": 115, 35: 120, 65: 145 }; export const ZONE = { 34: "near", "06": "mid", 35: "mid", 65: "far" }; const amount = (weight, postalCode) => { const tier = TIER.find(([cap]) => weight <= cap) ?? [0, 24990]; return Math.round((tier[1] * (COEFFICIENT[postalCode.slice(0, 2)] ?? 165)) / 100); }; export const SHIPMENTS = [ ["G-1", 0.8, "34100"], ["G-2", 12, "06500"], ["G-3", 26, "65100"], ["G-4", 3, "35400"], ["G-5", 4.5, "34710"], ["G-6", 18, "06800"], ].map(([code, weight, postalCode]) => ({ code, weight, postalCode, amount: amount(weight, postalCode) })); export const TIER_ORDER = ["0-1 kg", "1-5 kg", "5-15 kg", "15+ kg"]; export const tierName = (weight) => weight <= 1 ? "0-1 kg" : weight <= 5 ? "1-5 kg" : weight <= 15 ? "5-15 kg" : "15+ kg"; export const group = (shipments, key, order = null) => { const set = new Map(); for (const s of shipments) { const k = key(s), v = set.get(k) ?? [0, 0]; set.set(k, [v[0] + 1, v[1] + s.amount]); } const rows = [...set].map(([k, v]) => [k, String(v[0]), String(v[1])]); return order === null ? rows.sort() : rows.sort((x, y) => order.indexOf(x[0]) - order.indexOf(y[0])); };
The Arrangement Where the Product Unfolds into Classes
In the first arrangement, every type–format pair is a leaf class. Data extraction is
gathered in intermediate classes, and the write method producing output falls to the
leaves.
// inheritance/reports.mjs — one leaf class per type-format pair: 3 x 2 = 6 import { ZONE, TIER_ORDER, group, tierName } from "../data.mjs"; class Report { constructor(shipments) { this.shipments = shipments; } } export class SummaryReport extends Report { heading() { return "summary"; } columns() { return ["metric", "value"]; } rows() { const t = this.shipments.reduce((s, g) => s + g.amount, 0); return [["shipments", String(this.shipments.length)], ["total", String(t)], ["average", String(Math.round(t / this.shipments.length))]]; } } export class SummaryText extends SummaryReport { write() { return [this.heading(), this.columns().join(" "), ...this.rows().map((s) => s.join(" "))].join("\n"); } } export class SummaryDelimited extends SummaryReport { write() { return [this.columns().join(";"), ...this.rows().map((s) => s.join(";"))].join("\n"); } } export class TierReport extends Report { heading() { return "tier breakdown"; } columns() { return ["tier", "count", "amount"]; } rows() { return group(this.shipments, (g) => tierName(g.weight), TIER_ORDER); } } export class TierText extends TierReport { write() { return [this.heading(), this.columns().join(" "), ...this.rows().map((s) => s.join(" "))].join("\n"); } } export class TierDelimited extends TierReport { write() { return [this.columns().join(";"), ...this.rows().map((s) => s.join(";"))].join("\n"); } } export class ZoneReport extends Report { heading() { return "zone breakdown"; } columns() { return ["zone", "count", "amount"]; } rows() { return group(this.shipments, (g) => ZONE[g.postalCode.slice(0, 2)] ?? "unknown"); } } export class ZoneText extends ZoneReport { write() { return [this.heading(), this.columns().join(" "), ...this.rows().map((s) => s.join(" "))].join("\n"); } } export class ZoneDelimited extends ZoneReport { write() { return [this.columns().join(";"), ...this.rows().map((s) => s.join(";"))].join("\n"); } }
The six leaves’ bodies are identical two by two. This is a textbook example of the knowledge duplication covered in the Clean Code course: the knowledge of how the delimited-value format is produced sits in three separate classes. The caller translates the pair into a name.
// inheritance/main.mjs — composition root: a type-format pair maps directly to a leaf class import { SummaryText, SummaryDelimited, TierText, TierDelimited, ZoneText, ZoneDelimited } from "./reports.mjs"; const LEAF = { "summary/text": SummaryText, "summary/delimited": SummaryDelimited, "tier/text": TierText, "tier/delimited": TierDelimited, "zone/text": ZoneText, "zone/delimited": ZoneDelimited, }; export const report = (type, format, shipments) => new LEAF[`${type}/${format}`](shipments);
Solution: Two Hierarchies, a Contract Between Them
The bridge’s solution is to pull the second axis out of the inheritance tree into a tree of its own, with the abstraction holding a reference to the implementation. The contract consists of three pieces of data: heading, column names, rows.
// bridge/formats.mjs — implementation hierarchy: output format lives here, report type is unknown to it export const textFormat = { produce(heading, columns, rows) { return [heading, columns.join(" "), ...rows.map((s) => s.join(" "))].join("\n"); }, }; export const delimitedFormat = { produce(heading, columns, rows) { return [columns.join(";"), ...rows.map((s) => s.join(";"))].join("\n"); }, };
// bridge/reports.mjs — abstraction hierarchy: data extraction lives here, output format lives in the format object import { ZONE, TIER_ORDER, group, tierName } from "../data.mjs"; class Report { constructor(shipments, format) { this.shipments = shipments; this.format = format; } write() { return this.format.produce(this.heading(), this.columns(), this.rows()); } } export class SummaryReport extends Report { heading() { return "summary"; } columns() { return ["metric", "value"]; } rows() { const t = this.shipments.reduce((s, g) => s + g.amount, 0); return [["shipments", String(this.shipments.length)], ["total", String(t)], ["average", String(Math.round(t / this.shipments.length))]]; } } export class TierReport extends Report { heading() { return "tier breakdown"; } columns() { return ["tier", "count", "amount"]; } rows() { return group(this.shipments, (g) => tierName(g.weight), TIER_ORDER); } } export class ZoneReport extends Report { heading() { return "zone breakdown"; } columns() { return ["zone", "count", "amount"]; } rows() { return group(this.shipments, (g) => ZONE[g.postalCode.slice(0, 2)] ?? "unknown"); } }
No leaf class remains: the pair is no longer a class name but a composition of two objects, assembled in the composition root.
// bridge/main.mjs — composition root: the two axes are joined here import { SummaryReport, TierReport, ZoneReport } from "./reports.mjs"; import { textFormat, delimitedFormat } from "./formats.mjs"; const REPORT = { summary: SummaryReport, tier: TierReport, zone: ZoneReport }; const FORMAT = { text: textFormat, delimited: delimitedFormat }; export const report = (type, format, shipments) => new REPORT[type](shipments, FORMAT[format]);
// run.mjs — do the two arrangements produce the same text for all six type-format pairs import { SHIPMENTS } from "./data.mjs"; import { report as inheritanceReport } from "./inheritance/main.mjs"; import { report as bridgeReport } from "./bridge/main.mjs"; let mismatched = 0; for (const type of ["summary", "tier", "zone"]) { for (const format of ["text", "delimited"]) { if (inheritanceReport(type, format, SHIPMENTS).write() !== bridgeReport(type, format, SHIPMENTS).write()) mismatched += 1; } } console.log(inheritanceReport("zone", "text", SHIPMENTS).write()); console.log("--"); console.log(bridgeReport("zone", "delimited", SHIPMENTS).write()); console.log(`mismatched pairs = ${mismatched} / 6`);
zone breakdown zone count amount far 1 36236 mid 3 56166 near 2 13480 -- zone;count;amount far;1;36236 mid;3;56166 near;2;13480 mismatched pairs = 0 / 6
The same text in all six pairs. The difference is not in the output, but in how many types the output is split across.
Measuring the Type and Body Count
// type-count.mjs — counts the type count, line count, and output body count in both arrangements import { readdirSync, readFileSync } from "node:fs"; const count = (text, pattern) => (text.match(pattern) ?? []).length; for (const dir of ["inheritance", "bridge"]) { let type = 0, body = 0, line = 0; for (const d of readdirSync(dir).sort()) { const text = readFileSync(`${dir}/${d}`, "utf8"); type += count(text, /^(?:export )?class \w+/gm) + count(text, /^export const \w+Format/gm); body += count(text, /^ write\(\)/gm) + count(text, /^ produce\(/gm); line += text.split("\n").filter((s) => s.trim() !== "" && !s.trim().startsWith("//")).length; } console.log(`${dir.padEnd(11)} type=${String(type).padStart(2)} line=${String(line).padStart(3)} output body=${body}`); }
inheritance type=10 line= 63 output body=6 bridge type= 6 line= 42 output body=3
For three types and two formats, inheritance carries 10 types: one base, three intermediate
classes, six leaves. The bridge carries 6: one base, three reports, two format objects.
Output-body count is 6 to 3 — one of the bridge’s three is the delegating body (write),
two are the formats themselves. The same format body is written once per type in
inheritance, that is, 3 times.
When a Third Format Is Added
The new requirement is an aligned table: columns are padded to the widest value. This is an addition on the second axis; it concerns no report type’s data extraction.
mkdir -p new && cp -r data.mjs inheritance bridge run.mjs type-count.mjs new/ ls new
bridge data.mjs inheritance run.mjs type-count.mjs
In the inheritance arrangement, avoiding writing the format body three times means pulling it into a helper — and the moment that helper gets a name and a contract, the bridge’s implementation side is already written. The difference is that leaf classes are still needed.
// new/inheritance/aligned.mjs — third format: the body was written once but three leaf classes were still needed import { SummaryReport, TierReport, ZoneReport } from "./reports.mjs"; const align = (heading, columns, rows) => { const width = columns.map((s, i) => Math.max(s.length, ...rows.map((r) => r[i].length))); const line = (r) => r.map((h, i) => h.padEnd(width[i])).join(" | ").trimEnd(); return [heading, line(columns), ...rows.map(line)].join("\n"); }; export class SummaryAligned extends SummaryReport { write() { return align(this.heading(), this.columns(), this.rows()); } } export class TierAligned extends TierReport { write() { return align(this.heading(), this.columns(), this.rows()); } } export class ZoneAligned extends ZoneReport { write() { return align(this.heading(), this.columns(), this.rows()); } }
What gets added in the bridge arrangement is a single object in the implementation hierarchy.
cat >> new/bridge/formats.mjs <<'FORMAT' export const alignedFormat = { produce(heading, columns, rows) { const width = columns.map((s, i) => Math.max(s.length, ...rows.map((r) => r[i].length))); const line = (r) => r.map((h, i) => h.padEnd(width[i])).join(" | ").trimEnd(); return [heading, line(columns), ...rows.map(line)].join("\n"); }, }; FORMAT tail -3 new/bridge/formats.mjs
return [heading, line(columns), ...rows.map(line)].join("\n");
},
};
What is left is updating the two composition roots and the run script; the script below applies these, runs all nine pairs, and counts both arrangements.
cd new sed -i.y -e 's#^import { SummaryText.*$#&\nimport { SummaryAligned, TierAligned, ZoneAligned } from "./aligned.mjs";#' \ -e 's#^ "zone/text": ZoneText, "zone/delimited": ZoneDelimited,#&\n "summary/aligned": SummaryAligned, "tier/aligned": TierAligned, "zone/aligned": ZoneAligned,#' inheritance/main.mjs sed -i.y -e 's#, delimitedFormat } from "./formats.mjs";#, delimitedFormat, alignedFormat } from "./formats.mjs";#' \ -e 's#delimited: delimitedFormat };#delimited: delimitedFormat, aligned: alignedFormat };#' bridge/main.mjs sed -i.y -e 's#\["text", "delimited"\]#["text", "delimited", "aligned"]#' -e 's#/ 6`#/ 9`#' \ -e 's#bridgeReport("zone", "delimited"#bridgeReport("tier", "aligned"#' run.mjs rm -f inheritance/*.y bridge/*.y *.y node run.mjs | tail -7 node type-count.mjs for d in inheritance bridge; do echo "$d: added lines=$(diff -rN "../$d" "$d" | grep '^>' | grep -cvE '^> *(//|$)') edited files=$(diff -rq "../$d" "$d" | grep -c '^Files ')" done
tier breakdown tier | count | amount 0-1 kg | 1 | 4990 1-5 kg | 2 | 18678 5-15 kg | 1 | 17239 15+ kg | 2 | 64975 mismatched pairs = 0 / 9 inheritance type=13 line= 80 output body=9 bridge type= 7 line= 49 output body=4 inheritance: added lines=17 edited files=1 bridge: added lines=9 edited files=2
All nine pairs produced the same text. Inheritance gained 3 types and 17 lines, the bridge 1 type and 9 lines. The two measurement points show the product: inheritance 10 → 13 (three types times three formats plus the base and intermediate classes), bridge 6 → 7. A fourth format brings 3 more types in inheritance, 1 more in the bridge; the difference grows not with the format count but with the type count.
In edited-file count the bridge trails: 2 to 1. The bridge added the new format to an existing implementation file, while inheritance opened a new one. The two numbers read together — inheritance’s single edited file adds three lines to the composition root, while both files edited in the bridge are single-line lists.
Cost and When Not to Apply It
The bridge’s first cost is the contract itself. The three pieces of data between abstraction and implementation — heading, columns, rows — are the language every format must make do with. A format that wants to wrap a line by column width, add a subtotal, or write a currency symbol into a cell does not fit; the contract must be grown, and a grown contract forces every format object to answer for that field. The cost grows in direct proportion to the number of implementations.
The second cost is indirection: one output is produced in a single body under inheritance,
while in the bridge the call passes from write to produce, taking the bodies to trace
from 1 to 2. The third is the loss of cell-specific behavior: any one of the six leaves can
be customized on its own under inheritance — a request for “no heading line in the zone
report’s delimited output” changes a single leaf’s body. In the bridge, the same request
either puts a condition checking the report type into the format object — binding
implementation to abstraction and breaking the axes’ independence — or adds a flag to the
contract.
Two conditions for not applying the pattern follow from this. If one axis stays single-member, the product already equals the sum, and the bridge is only a layer of indirection. If most cells want special behavior — that is, the format is genuinely tied to the type — the axes are not independent; in that case the bridge accumulates a condition in every cell, and the inheritance arrangement works with fewer conditions.
Summary
- The problem the bridge solves is a capability changing along two independent axes at once; the solution is moving the second axis into a separate hierarchy, with the abstraction holding a reference to the implementation.
- For three types and two formats, the inheritance arrangement carries 10 types and 63 lines, the bridge arrangement 6 types and 42 lines; the output-body count is 6 to 3, and the same format body is written as many times as there are types in inheritance.
- A third format added 3 types and 17 lines to the inheritance arrangement, and 1 type and 9 lines to the bridge arrangement; the output of the nine type–format pairs stayed the same across both arrangements.
- The two measurement points confirm the growth rule: the type count in inheritance grows as the product of type and format, in the bridge as their sum.
- The cost is a fixed contract every implementation must make do with, a level of indirection, and the loss of cell-specific behavior; the pattern does not pay off if one axis is single-member or the format is genuinely tied to the type.
Next Step
Both the adapter and the bridge placed one object behind another, and in both what got wrapped was single. The reported shipment list was flat too: six shipments, all at the same level. The domain breaks this — a shipment can be a consolidated shipment carrying other shipments inside it, and one of those can itself be consolidated. Weight total, fee total, and delivery time are now computed over a tree. If every operation asks “is this a single piece or consolidated” in its own body, the type check repeats once per operation. The next lesson counts these checks, writes the composite pattern that puts the leaf and the node behind the same interface, and measures both arrangements along two directions of growth: files touched, lines added, and type checks added when a new node type and a new operation are added.
To keep your progress and take notes, Log in
My notes
Log in to take notes.