← Architecture
Deploy and evolve A port, two adapters, a flag

Branch by abstraction

Swap what is behind the seam, not the code in front of it.

You already have a file like lib/dates.ts or lib/api.ts: one function that the whole app calls, wrapped around a library. That file is the seam that lets you replace the library without touching the callers. Let’s swap a PDF library that way, while shipping every day, and see what happens when the flag flips in the middle of a batch.

TypeScriptGoOne transcript service, one port, two adapters, two recorded builds.

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.

Who owns each part of the swap
WhatOwnerWhy
Which library draws a transcriptThe adapter chosen for itChosen once, at the start of the document.
When the flag is readThe portOnce per transcript, so a flip changes only the next one.
The flag’s valueOperationsThey flip it during a batch, and back when something looks wrong.
Each library’s names and optionsIts adapterwrap and paginate, layout and pages, the font: nowhere else.
Whether next is readyThe shadow checkReal transcripts, both ways, compared word by word.
Removing legacyA later changeDelete 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.

Branch by abstraction

Where does the flag get read?

pdf-engine each adapter called directly

  1. Ana Lima · legacy legacy

    Ana Lima · BScMathematicsMATH 101 Calculus I · AMATH 301 Linear Algebra
    · A-STAT 210 Probability ·B+

    2 pages, every word printed

01/ 05
One port, two adapters

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.

TypeScriptReading
transcripts.ts
// 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 })
		})
	};
}
GoAlongside
main.go
// 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:

go.mod
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.

The agent’s pull request

“Simplifies the PDF seam: one pdf() helper instead of a renderer per document, and the flag now takes effect immediately. Tests pass with the flag at legacy and at next.”

// pdf.ts
			(removed)export function rendererFor(flags: Flags): Renderer {
			(removed)  return flags.get('pdf-engine') === 'next' ? nextRenderer() : legacyRenderer;
			(removed)}
			(added)// Reads the flag on every call, so a flip takes effect at once.
			(added)export const pdf = (flags: Flags) => (flags.get('pdf-engine') === 'next' ? nextSteps : legacySteps);
			
			// transcripts.ts
			(removed)  return rendererFor(flags).render(doc);
			(added)  const lines = doc.lines.flatMap(pdf(flags).wrap);
			(added)  return { pages: pdf(flags).paginate(lines) };
			
You are reviewing this change. What do you do?

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.

Failure modes of swapping one library
What goes wrongWhat a student getsPort, read once per transcriptFlag read on every call
Half-done: the flag flips between two stepsA transcript missing a gradeCannot happen: one adapter per documentWrapped at 24, drawn at 20; words lost
Wrong: the new library lacks a glyph“□ukasz Nowak”The shadow check reports it before the flipFound by a student
Rolled back mid-batchA mix of old and new documentsEach document whole, on one engineThe same cut, the other way
Bypassed: a caller imports the old library directly (authored)Old PDFs after the flipCaught by an import rule; see section 09Invisible until removal breaks it
Stale: the flag is read once at startup (authored)A flip waits for a restartNot this port: it reads per documentNot applicable
Never finished: both libraries stay for good (authored)Nothing, until the old one breaksA date to delete the adapterA 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.

The same four changes, made to each
ChangeAn if in each callerPort and adapters
A second entry point: an API that renders one transcriptCopy the if, and the fontCall the port
Replace the dependency: retire legacyEdit every caller, then deleteDelete one adapter and one line
Change a rule: A4 instead of LetterEach call’s optionsEach adapter; no difference with two callers
A second team: admissions sends offer lettersThey learn both librariesThey 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.

What the checker found, run 2026-09-23
QuestionPlain promptArchitecture promptControl (written for the lesson)
flag at legacy: output as before6 of 6 files byte for byte as before6 of 6 files byte for byte as before6 of 6 files byte for byte as before
flag at next: next-pdf, every word6 of 6 by next-pdf; every word printed6 of 6 by next-pdf; every word printed6 of 6 by next-pdf; wrong: s-1187-letter.pdf (next, words missing), s-1187-transcript.pdf (next, words missing)
flag reads in one run6 reads for 6 documents6 reads for 6 documents2 reads for 6 documents
flip to next during a run6 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 documents6 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 documents2 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 runflipped back after each of reads 1 to 5, legacy took over in 5 of 5 runsflipped back after each of reads 1 to 5, legacy took over in 5 of 5 runsflipped back after each of reads 1 to 1, legacy took over in 1 of 1 runs
files that import a PDF libraryletters.mjs, transcripts.mjsadapters/legacy-adapter.mjs, adapters/next-adapter.mjsletters.mjs, transcripts.mjs
the build’s own tests8 of 8 pass23 of 23 passno 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.

transcripts.mjs · plain prompt
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();
}
pdf-port.mjs · architecture prompt
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.

  1. 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’s exports map or a lint rule has to do it.

  2. A rule a check enforces

    Only adapters/legacy-adapter may import vendor/legacy-pdf, and only adapters/next-adapter may import vendor/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.

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

dates.ts
// 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.

ReactAlready in your code
DueDates.tsx
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

Take the swap into your own code. Find a library two files import directly, and write the port they both should call.

Back to architecture →