01 / The prompt
“Move PDF rendering to the new library, behind a flag.”
A university registrar issues transcripts and enrollment letters as PDFs, with a library
that is no longer maintained. The team ships from main every day, so the move happens behind
a flag, pdf-engine, and operations will flip it while a batch is running. Ask an agent
and you get both libraries wired to the flag, and with either value it works.
The question the prompt never asked is where the choice between the libraries lives, and when the flag is read. Read it at the wrong moment and a single transcript is laid out by one library and drawn by the other. Spread the choice across the callers and removing the old library later means finding every one.
Branch by abstraction is the name for doing this on main: it “allows you to release the system regularly while the change is still in-progress” (Martin Fowler, BranchByAbstraction, 2014, which credits Paul Hammant with the term). At the end, “Once the flawed supplier isn’t needed, we can delete it.” Checked 23 September 2026.
02 / Name the shape
One port, two adapters, one flag read.
In branch by abstraction, callers depend on a port that does one whole job, here “render this transcript”. Each library sits behind its own adapter. A flag picks the adapter, and a shadow check compares them on real documents before the flag moves. When the new one is proven, the old adapter is deleted and the callers never change.
Put the seam at the size of one unit of work, read the flag once per unit, and let only the adapters name a library.
| What | Owner | Why |
|---|---|---|
| Which library draws a transcript | The adapter chosen for it | Chosen once, at the start of the document. |
| When the flag is read | The port | Once per transcript, so a flip changes only the next one. |
| The flag’s value | Operations | They flip it during a batch, and back when something looks wrong. |
| Each library’s names and options | Its adapter | wrap and paginate, layout and pages, the font: nowhere else. |
| Whether next is ready | The shadow check | Real transcripts, both ways, compared word by word. |
| Removing legacy | A later change | Delete one adapter; the port and every caller stay. |
Words to put in a prompt or a review
- Port
- The one interface callers use, sized to a whole job.
- Adapter
- The only code that names one library and translates the port to it.
- Seam
- The place where the choice between old and new is made.
- Feature flag
- A value read at run time that picks the adapter, flippable without a deploy.
- Shadow check
- Running both adapters on real input and comparing, before switching.
- Retirement
- Deleting the old adapter and library once the new one is proven.
Where it meets other shapesPorts, strangler fig, schema changes
The port and adapters are the shape of Hexagonal / ports & adapters, used here for a while, to move between two implementations. When the thing being replaced is a whole application behind a router, it is a Strangler fig migration; when it is a column, it is Expand and contract. Feature flags and kill switches covers the flag itself.
03 / Follow one batch
Watch where the flag gets read.
One transcript through both adapters, then the shadow check, then a batch of three while operations flip the flag: first through a seam that reads it on every call, then through the port. Open Try it to choose the seam and the moment of the flip.
Where does the flag get read?
pdf-engine each adapter called directly
Ana Lima · legacy legacy
Ana Lima · BScMathematicsMATH 101 Calculus I · AMATH 301 Linear Algebra· A-STAT 210 Probability ·B+2 pages, every word printed
One port, two adapters
Ana Lima · legacy: 2 pages, every word printed
Reduced motion: choose a scene to see its completed state.
Read this scene
Ana Lima · legacy: 2 pages, every word printed
Ana Lima · legacy, legacy: 2 pages, every word printed.
Watch restarts the story when you come back. Step through keeps your step. Try it renders a fresh batch every time.
04 / Read the shape
A port, two adapters, and where the flag is read.
Basic form is the port and its adapters. In the wild is the seam that reads the flag once per transcript, beside the one that reads it on every call, and the shadow check. At the call site is the registrar’s code, which never names a library.
Notice that the libraries’ own names, wrap and layout, paginate and pages, appear only inside the adapters.
The port, render a whole transcript, and its two adapters: one wraps and paginates with the legacy library, the other lays out and pages with the next one, with a font.
// The port: render a whole transcript. Callers know this and nothing else.
export type Renderer = { engine: 'legacy' | 'next'; render(doc: Doc): Pdf };
export const legacyRenderer: Renderer = {
engine: 'legacy',
render: (doc) => ({
engines: ['legacy'],
pages: legacyPdf.paginate(doc.lines.flatMap(legacyPdf.wrap))
})
};
export function nextRenderer(fonts = ['noto']): Renderer {
return {
engine: 'next',
render: (doc) => ({
engines: ['next'],
pages: nextPdf.pages(doc.lines.flatMap(nextPdf.layout), { fonts })
})
};
} // Renderer is the port: render a whole transcript. Callers know this and
// nothing else.
type Renderer interface {
Engine() string
Render(doc Doc) Pdf
}
type LegacyRenderer struct{}
func (LegacyRenderer) Engine() string { return "legacy" }
func (LegacyRenderer) Render(doc Doc) Pdf {
lines := wrapAll(doc.Lines, legacyPdf.Wrap)
return Pdf{Engines: []string{"legacy"}, Pages: legacyPdf.Paginate(lines)}
}
type NextRenderer struct{ Fonts []string }
func (NextRenderer) Engine() string { return "next" }
func (n NextRenderer) Render(doc Doc) Pdf {
lines := wrapAll(doc.Lines, nextPdf.Layout)
return Pdf{Engines: []string{"next"}, Pages: nextPdf.Pages(lines, n.Fonts)}
} The behavior these examples promiseChecked by 39 shared scenarios
- Both adapters print every word of every transcript; they wrap and paginate differently, so pages differ.
- Without a font, the next library prints Łukasz as □ukasz; the shadow check reports it.
- Through the port, a flip after any read changes only the transcripts after it, whole.
- Through the per-call seam, a flip between wrapping and drawing cuts lines at 20 characters and loses words.
Every expectation in the shared cases was produced by a separate model written from these rules and kept beside the examples, not copied from either implementation.
Reading the TypeScriptA port is a type with one method
Renderer is an object type with render(doc); the adapters are
values of it, and nextRenderer(fonts) is a function so the shadow check can build
one without the font. The flag store counts its reads, which is how the tests prove the port
reads once per transcript.
Reading the GoA port is an interface
Renderer is an interface with Render, and each adapter is a
small struct that satisfies it; RendererFor returns the interface, so the
batch never knows which struct it holds. In a real module the adapters would live under
an internal directory.
Run it yourselfNo dependencies
Copy the complete TypeScript file and run node --experimental-strip-types transcripts.ts with Node 22.18 or later. For Go, save main.go next to this go.mod and run go run .. Both print:
module heyrian.dev/lessons/branch-by-abstraction
go 1.23
Ana Lima: legacy 2 pages · next 2 pages · same words: yes shadow, Łukasz Nowak: next without fonts Łukasz → □ukasz · with noto 0 differences flag flipped after read 1, read once per transcript: legacy, next, next · 3 of 3 intact flag flipped after read 1, read on every call: legacy+next, next, next · 2 of 3 intact what Ana Lima's transcript lost: "MATH 101 Calculus I · A" printed as "MATH 101 Calculus I"
05 / Review the agent’s diff
“The flag now takes effect immediately.”
Fewer lines, and a flip that applies at once sounds like what operations want. Read it with a batch running.
06 / How it fails
Two libraries are live. Decide where each one can reach.
Each row is a shared scenario unless it is marked as authored.
| What goes wrong | What a student gets | Port, read once per transcript | Flag read on every call |
|---|---|---|---|
| Half-done: the flag flips between two steps | A transcript missing a grade | Cannot happen: one adapter per document | Wrapped at 24, drawn at 20; words lost |
| Wrong: the new library lacks a glyph | “□ukasz Nowak” | The shadow check reports it before the flip | Found by a student |
| Rolled back mid-batch | A mix of old and new documents | Each document whole, on one engine | The same cut, the other way |
| Bypassed: a caller imports the old library directly (authored) | Old PDFs after the flip | Caught by an import rule; see section 09 | Invisible until removal breaks it |
| Stale: the flag is read once at startup (authored) | A flip waits for a restart | Not this port: it reads per document | Not applicable |
| Never finished: both libraries stay for good (authored) | Nothing, until the old one breaks | A date to delete the adapter | A search through every caller |
A flag is a runtime input like any other: Thinking in failure modes asks what happens when it changes in the middle of something, and Feature flags and kill switches covers reading it safely.
07 / Is it worth it?
A port costs a file. Here is what it buys.
An if on the flag inside each caller is less code, and with two callers it reads
fine. Hold both against the changes.
| Change | An if in each caller | Port and adapters |
|---|---|---|
| A second entry point: an API that renders one transcript | Copy the if, and the font | Call the port |
| Replace the dependency: retire legacy | Edit every caller, then delete | Delete one adapter and one line |
| Change a rule: A4 instead of Letter | Each call’s options | Each adapter; no difference with two callers |
| A second team: admissions sends offer letters | They learn both libraries | They learn render(doc) |
Before flipping, decide what you will measure and the result you would accept:
- Shadow differences on a day’s real transcripts: none before the flip.
- Documents per engine, from the port’s own log, after each flip: the change lands on the next document, and never inside one.
- Files that import either library: one adapter each, then none for legacy.
Take the baseline before the change: today’s output with the flag at legacy, byte for byte. This page did not run a real registrar, so it gives no production numbers.
08 / Ask for it
Two prompts, the same behavior, a different seam.
We sent two agents the same change at the same time, both running Claude Sonnet, each in a copy of the registrar’s service with both libraries vendored. One prompt described the change. The other added an Architecture block: one port at the level of a whole document with an adapter per library, the flag read once per document, and a shadow check before the flip. A script then ran both builds as operations would, flipping the flag during a batch. A third column is a control written for the lesson that reads the flag when its modules load and forgets the font, to show the checker can fail a build.
| Question | Plain prompt | Architecture prompt | Control (written for the lesson) |
|---|---|---|---|
| flag at legacy: output as before | 6 of 6 files byte for byte as before | 6 of 6 files byte for byte as before | 6 of 6 files byte for byte as before |
| flag at next: next-pdf, every word | 6 of 6 by next-pdf; every word printed | 6 of 6 by next-pdf; every word printed | 6 of 6 by next-pdf; wrong: s-1187-letter.pdf (next, words missing), s-1187-transcript.pdf (next, words missing) |
| flag reads in one run | 6 reads for 6 documents | 6 reads for 6 documents | 2 reads for 6 documents |
| flip to next during a run | 6 reads a run; flipped after each of reads 1 to 5, the documents after the flip were next-pdf's in 5 of 5 runs; words missing in 0 documents | 6 reads a run; flipped after each of reads 1 to 5, the documents after the flip were next-pdf's in 5 of 5 runs; words missing in 0 documents | 2 reads a run; flipped after each of reads 1 to 1, the documents after the flip were next-pdf's in 1 of 1 runs; words missing in 1 documents |
| flip back to legacy during a run | flipped back after each of reads 1 to 5, legacy took over in 5 of 5 runs | flipped back after each of reads 1 to 5, legacy took over in 5 of 5 runs | flipped back after each of reads 1 to 1, legacy took over in 1 of 1 runs |
| files that import a PDF library | letters.mjs, transcripts.mjs | adapters/legacy-adapter.mjs, adapters/next-adapter.mjs | letters.mjs, transcripts.mjs |
| the build’s own tests | 8 of 8 pass | 23 of 23 pass | no tests |
The two builds behave the same. Both keep today’s output byte for byte at legacy, both print
every word at next, and both read the flag once per document, so a flip lands exactly
between two documents. The prompt’s sentence about flipping during a batch carried that, and
the vendored library’s own comment carried the font: both agents read that characters past
Latin-1 print as □ without noto-sans, and both pass it. The cut transcript in
section 03 is the lesson’s per-call seam, not an agent’s.
The difference is where the seam lives. The plain build puts an if on the flag inside
each render function, and each one imports both libraries. The architecture build has one port,
and only its two adapters import a library; it also added a shadow check that renders every real
document both ways and exits non-zero on a difference.
import { LegacyPdf } from './vendor/legacy-pdf.mjs';
import { render } from './vendor/next-pdf.mjs';
import { flag } from './flags.mjs';
export async function renderTranscript(student) {
if (flag('pdf-engine') === 'next') {
return render([`${student.name} · ${student.program}`, ...student.courses], { fonts: ['noto-sans'] });
}
const doc = new LegacyPdf();
doc.text(`${student.name} · ${student.program}`);
for (const course of student.courses) doc.text(course);
return doc.end();
} import { flag } from './flags.mjs';
import * as legacyAdapter from './adapters/legacy-adapter.mjs';
import * as nextAdapter from './adapters/next-adapter.mjs';
const ADAPTERS = {
legacy: legacyAdapter,
next: nextAdapter
};
// Reads the flag and resolves it to an adapter. Exported so the shadow
// check and tests can pick a named engine without re-reading flags.json.
export function adapterFor(engine) {
const adapter = ADAPTERS[engine];
if (!adapter) throw new Error(`unknown pdf-engine flag value: ${JSON.stringify(engine)}`);
return adapter;
}
// Renders one whole document — all of its paragraphs, in order — with
// whichever adapter the flag names at this moment. The flag is read exactly
// once for this call, so a document already in flight never changes engine
// partway through, and a flip between two calls only affects the later one.
export async function renderDocument(paragraphs) {
const engine = flag('pdf-engine');
const adapter = adapterFor(engine);
return adapter.renderDocument(paragraphs);
} Nothing the checker ran separates them today; they differ in what the next two changes cost. Retiring the old library means editing every caller in one and deleting a file in the other, and only one of them can show the flip is safe before making it. So the line worth adding to the plain prompt is the part it did not already carry: only one adapter per library may import it, callers use one port, and before the flip a shadow check renders real documents both ways and reports any difference.
How the runs were made and checkedOne sample of each prompt
- Both agents received the prompts word for word, in fresh contexts, in the same message. The prompt files were kept outside the run folders’ parent.
- The files each agent wrote are kept byte for byte, with checksums. For every question the checker restores a build into a fresh folder; for a flip it swaps in a flag module that counts reads and changes the value after the one chosen.
- Neither agent started a server, wrote outside its folder, or used
/tmp. The checker’s first run worded the control’s flip verdict badly; the second rewords it, with the same results. - This is one sample of each prompt, not a measurement of a model.
09 / Hold it there
Keep the libraries inside their adapters, by rule and by test.
The seam erodes the first time someone needs one option from the new library “just here”, and imports it straight into a caller. Three checks.
The language’s own door
Go has one: since Go 1.4 “the go command introduces a mechanism to define ‘internal’ packages that may not be imported by packages outside the source subtree in which they reside” (Go 1.4 release notes, checked 23 September 2026). Put the adapters under
pdf/internal/and only the port can import them. TypeScript has no such door; a package’sexportsmap or a lint rule has to do it.A rule a check enforces
Only
adapters/legacy-adaptermay importvendor/legacy-pdf, and onlyadapters/next-adaptermay importvendor/next-pdf. Enforcement layer runs rules like this against real code. This rule was not run here; the checker listed each build’s importers instead.A check on what actually happens
Flip the flag after every possible read during one batch and check each document is whole and on one engine. The checker does it by swapping in a flag module that counts its reads.
check-runs.mjs const COUNTING_FLAGS = `import { appendFileSync, readFileSync } from 'node:fs'; let value = null; let reads = 0; export function flag(name) { const stored = JSON.parse(readFileSync(new URL('./flags.json', import.meta.url), 'utf8'))[name]; if (name !== 'pdf-engine') return stored; if (value === null) value = stored; const seen = value; reads++; if (process.env.FLAG_LOG) appendFileSync(process.env.FLAG_LOG, seen + '\\n'); if (reads === Number(process.env.FLIP_AFTER ?? 0)) value = process.env.FLIP_TO; return seen; } `;
Your wrapper modules are ports alreadylib/dates.ts and lib/api.ts are the seam. A library swap behind a flag is where you own it.
Where it already is in your components
Most apps already wrap their date library, their HTTP client, or their UI kit’s button in one module that every component imports. That module is a port, and replacing what is inside it is branch by abstraction without the name.
When you have to own it
Moving due dates from a hand-written formatter to Intl.DateTimeFormat behind a
flag that the flag service can change while the page is open. Read the flag for every date and
one list can show two formats after a refresh; read it once at the root and pass the formatter
down. The helper below is shared by both versions.
// The seam every component already imports: formatDate. Behind it, the old
// hand-written formatter and Intl.DateTimeFormat, chosen by the flag
// date-engine, which the flag service can change while the page is open.
export type DateFormatter = { engine: 'legacy' | 'intl'; format(date: Date): string };
const pad = (n: number) => String(n).padStart(2, '0');
export const legacyDates: DateFormatter = {
engine: 'legacy',
format: (d) => `${pad(d.getMonth() + 1)}/${pad(d.getDate())}/${d.getFullYear()}`
};
export function intlDates(locale: string): DateFormatter {
const formatter = new Intl.DateTimeFormat(locale, { dateStyle: 'medium' });
return { engine: 'intl', format: (d) => formatter.format(d) };
}
export type Flags = { get(name: string): string | undefined };
export function formatterFor(flags: Flags, locale: string): DateFormatter {
return flags.get('date-engine') === 'intl' ? intlDates(locale) : legacyDates;
}
Every date goes through the seam, but the flag is read for each one, so a list can mix formats.
import { formatterFor, type Flags } from './dates';
// Every due date goes through the seam; no component imports a date library.
// The flag is read for each date, so a flag refresh mid-render can show two
// formats in one list.
export function DueDates({ dates, flags }: { dates: Date[]; flags: Flags }) {
return (
<ul>
{dates.map((date) => (
<li key={date.toISOString()}>{formatterFor(flags, navigator.language).format(date)}</li>
))}
</ul>
);
}
10 / Make the call
Branch by abstraction when the swap outlasts a pull request.
Replace a library in one pull request when it has a few callers, the change can be reviewed in one sitting, and there is nothing to compare before switching. A port for a two-hour change is ceremony.
Build the port and adapters when the swap will take more than a few deploys, when the old and new results must be compared on real input, or when you need to flip back without a deploy. Reopen the decision if the port starts growing options that only one adapter understands: the seam is at the wrong size.
Take it with you
Explain it without saying “port”, “adapter”, or “branch by abstraction”: “Every transcript goes through one function. Behind it, either the old library or the new one does the whole document, depending on a switch we check once per transcript. We compared them first, and when the new one has run for a while we delete the old one.” Then find a library in your code that more than one file imports directly.
Paste into your next prompt, and fill in the blanks
Replace <old library> with <new library> using branch by abstraction, on main. Callers use one port at the level of <a whole unit of work>; each library sits behind its own adapter, and only that adapter imports it. The flag <name> is read once per <unit>, and that unit is done entirely by the adapter it chose, so a flip mid-batch changes only later units. Before the flag moves, a shadow check runs <real inputs> through both adapters and reports any difference. Removing <old library> later means deleting its adapter.
Connections to follow nextRelated lessons
- Hexagonal / ports & adapters is the shape, kept for good.
- Strangler fig migration is the same move at the size of an application.
- Expand and contract is the same move for a column.
- Feature flags and kill switches is the flag itself.
- Adapter is the pattern each side uses.