01 / The idea
A chart should not need to know its pixels.
A function that ranks ticket categories and returns a small SVG string is a reasonable first implementation. Its data rules and its geometry live together because there is only one thing to draw.
Now add a text version for the digest, then a weekly trend. Separate functions for each pairing can repeat the ranking in two places and the escaping in two others. A change to how many categories count as “top” can drift between the dashboard and the digest. The pressure comes from two kinds of change crossing the same code.
Bridge separates a higher-level abstraction from the implementation it uses, so the two can vary independently. Here, each chart composes meaningful rows through a surface interface. Each surface turns those operations into one kind of drawing. The caller connects a chart to a surface.
The traditional names are abstraction for the chart side and implementor for the surface side. Both sides contain real implementation code. These names describe their relationship: the chart expresses higher-level behavior using a smaller set of drawing operations.
02 / See the shape
The chart decides. The surface draws.
The Basic form retains a surface and delegates a title and figure operation to it. It shows the connection before adding data rules or validation.
In the wild is a dashboard chart. TopCategories validates points, ranks them by value, and shows three bars unless asked for every category. WeeklyTrend validates weeks, keeps their order, and notes the change from first to last. Neither branches on SVG versus text.
| Decision | Owner | Reason |
|---|---|---|
| Which categories appear, in what order? | TopCategories | The answer should agree across surfaces. |
| What does the trend note say? | WeeklyTrend | The change from first to last week is part of the chart’s meaning. |
| How long is a bar? | The selected surface | Two hundred SVG units and twenty block characters are different drawings of the same ratio. |
| Which pair is requested? | The caller | It supplies a surface to the chosen chart. |
A chart retains a surface and builds a figure through its operations. This small form shows the connection; the next form adds two chart policies, two surfaces, and validation.
export interface Surface {
title(text: string): string;
bar(row: number, label: string, value: number, max: number): string;
note(row: number, text: string): string;
figure(parts: readonly string[], rows: number): string;
}
export class BasicChart {
#surface: Surface;
constructor(surface: Surface) {
this.#surface = surface;
}
draw(title: string): string {
return this.#surface.figure([this.#surface.title(`Top categories — ${title}`)], 0);
}
} type Surface interface {
Title(text string) string
Bar(row int, label string, value, maxValue int) string
Note(row int, text string) string
Figure(parts []string, rows int) string
}
type BasicChart struct{ surface Surface }
func (c BasicChart) Draw(title string) string {
return c.surface.Figure([]string{c.surface.Title("Top categories — " + title)}, 0)
} The call site runs all four pairings. On either surface, Login leads the top categories and only three of four appear by default. On either surface, the trend runs W1 to W3 and notes a change of +1. Switching the surface changes the drawing while preserving those content decisions.
The shared contract and its boundariesRaw text and numbers in, drawn fragments out
The title and every label a chart uses must be nonempty strings without carriage returns or line feeds; values are whole numbers of zero or more. Accepted text is preserved exactly, including spaces and Unicode. The model has no length limit. A production input policy can be stricter.
TopCategories validates every category, then sorts by value with ties keeping input order, and caps the list at three unless the caller asks for every category. The longest shown bar sets the scale. WeeklyTrend validates every week, keeps their order, scales to the largest week, and notes the signed change from the first week to the last. Each chart checks the title first, ignores the other chart’s collection, and completes validation before calling any surface operation.
Title, bar, and note receive raw text and numbers, with the row index the chart assigned. The figure operation receives fragments already produced by that surface and the number of rows to reserve. SvgSurface escapes ampersands, angle brackets, and both quote characters in text nodes and scales bars to 200 units; TextSurface scales to 20 block characters. Neither is an HTML sanitizer or a layout engine.
Both surfaces return a complete string without mutating the input. SVG is one svg element with a viewBox sized from the row count; text joins rows with line breaks and has no trailing newline. Empty collections produce a title and an explanatory note. Validation errors produce no output and no surface calls.
Reading the TypeScriptStructural interfaces and retained collaborators
Surface describes four methods. A class with those methods fits the interface.
A chart keeps its surface in a private field and uses it for each drawing. TypeScript’s interface
is a compile-time agreement, while the explicit data checks handle the model’s runtime validation.
The surface returns strings for each row. The chart pushes title, bars, and note into an
array before calling figure, so those operations happen first. The
recording wrapper makes that order visible.
Reading the GoImplicit interfaces and explicit errors
SVGSurface and TextSurface satisfy the Surface interface through their methods. A chart struct retains that interface value. Changing its concrete implementation does not change the chart’s data code.
Draw returns (string, error). Invalid points return an empty
string and an error before building rows. The ranking uses a small stable insertion sort
so ties keep input order, matching the other languages. These built-in surfaces have no
mutable fields; the diagnostic recorder does, so give each drawing its own recorder or
add a concurrency policy before sharing it.
Reading the PythonProtocols, dataclasses, and stable sorting
Python’s Surface Protocol describes the same four operations without
requiring inheritance. Frozen, slotted dataclasses keep points and chart data
value-like, while tuple fields and copied traces make the example’s ownership boundary visible.
sorted(..., reverse=True) creates a ranked list without changing the
caller’s data; Python’s sort is stable, so equal values keep input order. ValueError carries the same validation failures before any surface call.
03 / Follow the calls
Change one choice. Keep the other.
Start with Top categories on SVG. Before moving to text, predict which strings and numbers will cross the surface boundary. The title, the ranked labels, their values, and the note should remain the same. Only their drawing changes.
Now choose Weekly trend on text. The surface stays the same, but the chart supplies different rows. Return to top categories and show every category: the list grows to four, while Login still leads. Try labels containing markup, then invalid points, and explain why escaping happens on one side and validation on the other.
One chart choice. One surface choice.
Choose a cell, then move across its row or down its column. Watch which responsibility changes and which surface calls stay the same.
Surface →
01 / Chart owns meaning
Top categories
Validate categories, rank them by value, and decide how many bars to show.
02 / Surface owns drawing
SVG surface
Escape text, place each row at a y offset, and scale bars to 200 units inside one svg element.
Apply the title with Draw. Pair and policy changes use the last applied title. A sample selection replaces it; Reset restores typical tickets and keeps the selected pair.
Inspect the sample points
{
"categories": [
{
"label": "Billing",
"value": 12
},
{
"label": "Login",
"value": 30
},
{
"label": "Exports",
"value": 7
},
{
"label": "Mobile",
"value": 18
}
],
"weeks": [
{
"label": "W1",
"value": 20
},
{
"label": "W2",
"value": 26
},
{
"label": "W3",
"value": 21
}
]
}Top categories / SVG · Drew “Support tickets” through 6 surface calls.
Generated chart
The generated svg element, displayed with fixed preview-only styles inside a sandboxed frame.
Across the surface boundary
Raw text and numbers enter title, bar, and note. Their generated fragments enter figure.
title: Top categories — Support ticketsbar 0: Login = 30 of 30bar 1: Mobile = 18 of 30bar 2: Billing = 12 of 30note 3: 3 of 4 categories shown.figure: 5 parts, 4 rows
How the call trace is recordedA Decorator around either surface
export class RecordingSurface implements Surface {
#inner: Surface;
#calls: string[] = [];
constructor(inner: Surface) {
this.#inner = inner;
}
title(text: string): string {
this.#calls.push(`title: ${text}`);
return this.#inner.title(text);
}
bar(row: number, label: string, value: number, max: number): string {
this.#calls.push(`bar ${row}: ${label} = ${value} of ${max}`);
return this.#inner.bar(row, label, value, max);
}
note(row: number, text: string): string {
this.#calls.push(`note ${row}: ${text}`);
return this.#inner.note(row, text);
}
figure(parts: readonly string[], rows: number): string {
this.#calls.push(`figure: ${parts.length} parts, ${rows} rows`);
return this.#inner.figure(parts, rows);
}
calls(): string[] {
return [...this.#calls];
}
} RecordingSurface implements the same surface interface, records each call, then forwards it to an inner surface. The lab creates a fresh wrapper for every drawing and reads a copy of its trace. A failed validation leaves that trace empty because the chart never reaches the surface.
This wrapper is a concrete Decorator used alongside Bridge. The chart still sees a Surface. The wrapper adds observation around the chosen drawing implementation without taking over chart policy.
04 / Try a decision
Independent change has a boundary.
A third chart can use the operations we already have. A new kind of drawing may need a richer vocabulary. Decide what happens when that distinction becomes visible.
Reason through the alternatives
Build the paired bars as SVG markup inside ComparisonChart and pass it to note. The chart would now know one surface. SvgSurface would escape that markup as text, while TextSurface would print the tags. Passing raw markup also violates note’s text contract.
Define a paired-bar operation and its behavior on each supported surface before using it in the new chart. The requirement exceeds the current vocabulary. The chart can own which two values sit together and in what order; the surfaces can own side-by-side rectangles and an agreed text arrangement. Updating both surfaces is an honest shared-contract change. If text cannot show the pairing legibly, make that limitation explicit.
Make each surface inspect the chart kind and lay out the comparison itself. Now adding or changing a chart requires editing every surface. The drawing side has absorbed chart policy, which is the coupling this boundary was meant to remove.
Two separate rows may be enough when the comparison is incidental. This question makes the pairing part of the requirement, so a new operation needs an explicit meaning on every supported surface.
Where would you add a share-of-total chart? Where would you add a Canvas surface? Which new requirement would force you to revisit the surface contract?
This note stays on this page; nothing is saved or graded.05 / Give it a real job
Build the pair at the dashboard boundary.
A dashboard controller receives a chart kind and a surface, authorizes access, and loads the relevant numbers. It chooses a surface, supplies it to the requested chart, and asks for a complete figure. The page, the digest message, or the image endpoint then handles display. The chart does not need to know who opened the dashboard or where the drawing will go.
A share-of-total chart can become another chart using the existing operations. A Canvas or terminal-color surface can become another surface implementing those operations for both current charts. If a future chart needs paired bars, a legend, or a second axis, define how those capabilities should work on every supported surface before promising that every pairing remains valid.
The built-in surfaces are stateless and reusable. A surface that owns a canvas context, a file, or a GPU resource introduces lifetimes and failures of its own. Streaming also changes the contract: an error after emitting some rows cannot retract those bytes. Buffering, cancellation, cleanup, retries, and partial-drawing reporting need deliberate ownership.
Build UIs?Hooks work in every React renderer, and a div does not. Tracking events for more than one analytics destination makes that split your design.
Where it already is in your components
If you have written a React Three Fiber scene or an Ink command-line app, you already
follow a rule that comes from this split: useState and useEffect work there exactly as they do in the browser, and <div> does not. React’s core is the abstraction, and each renderer
supplies the implementor. The react-reconciler README calls that implementor a host config: an object describing how to make things happen in the
host environment, whether that is the DOM, a canvas, or a console. Its createInstance receives the element type, and for the DOM renderer the
README’s example is document.createElement(type).
Hooks sit on the abstraction’s side. In React 19.1.0, useState in the react package only looks up the current dispatcher and calls its useState; the reconciler
inside whichever renderer is rendering sets that dispatcher. A lowercase tag sits on the
other side. React passes the string "div" to the renderer and leaves its meaning
to the host config.
We checked both halves. In a renderer built on react-reconciler 0.32.0 whose host config
knows a single element type, a counter using useState and useEffect committed 0, 1, then 2 with no DOM anywhere, and a <div> reached createInstance as the string "div". In React Three Fiber 9.7.0 on Chromium 153, a counter with the same
hooks ran inside <Canvas>, and a <div> placed there threw
“R3F: Div is not part of the THREE namespace! Did you forget to extend?” Component behavior
crosses renderers unchanged, while the element vocabulary belongs to each renderer, as drawing
a bar belongs to each of our surfaces.
When you have to own it
Now your checkout needs product analytics. The product team wants checkoutStarted and couponApplied with the properties each one
carries, growth wants the same events in a vendor tool, and in development you want them
in the console. Events and destinations change for different reasons, so give each its own
side. Components call event functions, and those functions build each event from a small
destination interface, say send(name, properties) and identify(userId). Each destination implements that interface. A new event
touches no destination, and a new vendor touches no component.
Keep delivery details in the destination. Sending while the page goes away is a transport
question: MDN recommends navigator.sendBeacon from a visibilitychange listener, and fetch with keepalive when you need the response. Event
code that checks which vendor it is talking to has pulled a destination back onto the event
side. Decide what a failing destination does to the page, too: a vendor script that never loads
should not break the checkout button that recorded the click. And widen the interface on purpose.
If one vendor offers something the others cannot, such as session replay, that is a new capability
to define for every destination, like the paired bars in section 04.
Each vendor destination is usually an Adapter around that vendor’s SDK. The Bridge is the split you choose before the second vendor arrives: which operations the event side may use.
06 / Already in your toolbox
A common surface can have different backends.
These documented APIs show the same separation at their public boundaries.
Qt · paint operations and device engines
QPainter exposes drawing operations. QPaintDevice represents a surface, and QPaintEngine supplies the interface used to draw on different devices. Qt normally hides the engine from application code. It is the same shape as our chart and surface, at library scale.
Read Qt’s paint system overview ↗Go · database/sql and drivers
database/sql provides a common SQL API and requires a database driver. The separation lets application-facing operations use different database implementations. Backend differences still matter: the documentation explicitly notes that a driver without cancellation support may finish the query before returning.
Read the SQL package contract ↗07 / When it earns its place
Choose operations that carry enough meaning.
Bridge is useful when chart behavior and drawing behavior have separate reasons to change, and a shared operation vocabulary can support their combinations. Four pairings make the separation easy to see here. Counting classes is not the test: functions and tables can express the same choices without a class for every pair.
Keep the direct drawing function when one small chart is the actual requirement. If several charts already produce the same structured row data, a data model followed by a renderer may be clearer than having chart objects call surface methods. The responsibility split can remain useful even when the handoff changes.
A surface contract that accepts only an arbitrary drawn string can be too weak to preserve meaning. A contract containing every possible visual feature can become difficult to implement. Start with operations demanded by real charts. Review new capabilities against accessibility, drawing fidelity, and each surface’s limits.
The interface adds a place to navigate and a contract to maintain. The implementations still need tests for every supported pairing: here, exact output, call order, escaping, validation, and input preservation in every implementation.
08 / Take the idea with you
Name the two reasons for change.
Explain it without the pattern name: “The chart selects and orders meaningful rows. It holds a surface that draws those rows through a shared set of operations. The caller can choose either side independently, as long as the operations support the requested drawing.” Then name one change that belongs on each side.
Connections to follow nextRelated lessons
Strategy also uses composition to replace behavior. Its usual focus is choosing a policy for a context. Bridge emphasizes the relationship between two independently varying dimensions. Similar code shapes can support different design explanations; a constructor taking an interface does not settle the name.
Adapter makes an existing interface fit a caller’s expectation, usually after both sides already exist. Bridge is a split you choose up front, before the implementations multiply. A surface adapter could connect a third-party drawing library to this Bridge. Abstract factory constructs related product families whose members should agree. Here, chart and surface are intentionally independent choices.
Ports and adapters applies a related dependency boundary at application scale. Naming a port helps express what the application needs; deciding which responsibilities vary independently still takes domain reasoning.