01 / The prompt
“Reporters upload photos. Draft the alt text with AI.”
A local news site wants every photo to have alt text, and a model provider can write a first
draft. What came back from a plain prompt was 5 code files, with provider A’s API in one of them, lib/provider.ts. It survived every
provider failure the checker threw at it: errors, garbage, and a provider that never answers
in time. That is a good build.
Then the provider changes. Model providers retire models on a schedule. Anthropic’s policy says that once a model is retired, “Requests to retired models will fail,” and promises “at least 60 days’ notice” for publicly released models (Model deprecations, checked 26 September 2026). Price, quality, and refusal policies change on shorter notice. So the useful question about a build is not only whether it calls today’s provider correctly. It is how much of the app has to know which provider it calls, and whether its tests can run without one.
Alistair Cockburn described the pattern for this in 2005. His goal: “Allow an application to equally be driven by users, programs, automated test or batch scripts, and to be developed and tested in isolation from its eventual run-time devices and databases” (Hexagonal architecture).
02 / Name the shape
The desk decides. Adapters translate.
In hexagonal architecture, also called ports and adapters, the application sits in the middle and reaches the outside world only through ports it defines. A port is an interface in the application’s own words: “draft alt text for this image.” An adapter implements a port for one real thing: provider A’s API, provider B’s, a JSON file, or a scripted fake in a test. Adapters depend on the application. The application depends on nothing outside itself.
The desk decides what counts as good alt text. An adapter only turns “draft this” into one provider’s API call, and that provider’s answer back into a draft, a refusal, or unavailable.
Here is the photo desk, port by port.
| Port | What the desk asks | Adapters in this lesson |
|---|---|---|
AltTextDrafter | Draft alt text for this image, in at most 150 characters | Provider A, provider B, a fallback made of both, and a scripted drafter for tests |
PhotoStore | Find, list, and save photos | An in-memory store here; a JSON file in both recorded builds |
| The driving side | Upload a photo, write alt text, backfill waiting photos | An HTTP route, a backfill script, and tests |
Words to put in a prompt or a review
- Core
- The application’s rules and use cases, with no imports from the outside world.
- Port
- An interface the core defines, in its own words and its own types.
- Adapter
- Code that implements a port for one technology or vendor.
- Driving adapter
- Calls into the core: a route, a script, a test.
- Driven adapter
- The core calls it through a port: a model provider, a store.
- Composition root
- The one place that picks the adapters and plugs them in.
Hexagonal, onion, clean: one idea, different drawingsNames
Cockburn drew a hexagon to leave room for several ports on the page, not because six matters. He put the idea in one line: “A port identifies a purposeful conversation.” Onion architecture and Clean Architecture keep the same rule, that dependencies point toward the application, and add named rings inside it. For a prompt or a review, the words in the box above are the ones that do the work.
Two neighbors are worth knowing by name. Dependency injection is how the adapters reach the desk: passed in, not imported. The Adapter pattern is what each provider adapter is at the object level.
03 / Follow one upload
Same desk, six situations. What changes?
Watch one photo upload go through the desk while providers misbehave and the wiring changes. Every step comes from running the lesson’s real code. Then open Try it and break the providers yourself.
Follow one upload through the hexagon
Driving side
- HTTP route POST /photos
- Test desk.upload
Core
- Photo desk the rule, and ports for a drafter and a store
Driven side
- Fallback B, then A
- Provider B adapter POST /generate
- Provider A adapter POST /v1/describe
- Scripted drafter no network
- Photo store saves photos
app.tsconst drafter = providerA(http, a);
Running… core/photos.ts · 118 lines · the same file in every chapter
Provider A drafts it.
A reporter uploads a photo. The route hands it to the desk, the desk asks its drafter port, and provider A’s adapter turns that into provider A’s API call. The desk’s own rule accepts the draft.
Reduced motion: choose a scene to see its completed state.
Read this scene
A reporter uploads a photo. The route hands it to the desk, the desk asks its drafter port, and provider A’s adapter turns that into provider A’s API call. The desk’s own rule accepts the draft.
Watch restarts the story when you come back. Step through keeps your step. Try it runs a new upload every time you change a setting.
04 / Read the shape
Two ports, a desk that uses them, and one place that plugs them in.
Basic form is the two ports and what a drafter can report. In the wild is the desk: it asks the drafter port, applies its own rule, and saves through the store port. At the call site is the composition root. Notice what the desk never mentions: a URL, a header, a status code, or a provider’s name.
The two ports, in the desk’s own words, and the three things a drafter can report. Go declares the same interfaces in package photos.
export type DraftOutcome =
{ kind: 'draft'; text: string } | { kind: 'refused' } | { kind: 'unavailable' };
/** Port: draft alt text for an image. The application defines it; adapters implement it. */
export interface AltTextDrafter {
draft(request: { imageUrl: string; maxChars: number }): Promise<DraftOutcome>;
}
export type AltTextStatus = 'drafted' | 'needs-human' | 'written';
export type Photo = {
id: string;
title: string;
imageUrl: string;
altText: string | null;
altTextStatus: AltTextStatus;
};
/** Port: load and save photos. */
export interface PhotoStore {
all(): Promise<Photo[]>;
get(id: string): Promise<Photo | null>;
save(photo: Photo): Promise<void>;
} type DraftKind string
const (
Draft DraftKind = "draft"
Refused DraftKind = "refused"
Unavailable DraftKind = "unavailable"
)
// DraftOutcome is what a drafter reports, in the application's own terms.
type DraftOutcome struct {
Kind DraftKind
Text string
}
type DraftRequest struct {
ImageURL string
MaxChars int
}
// AltTextDrafter is a port: the application defines it, adapters implement it.
type AltTextDrafter interface {
Draft(ctx context.Context, request DraftRequest) DraftOutcome
}
type Photo struct {
ID string `json:"id"`
Title string `json:"title"`
ImageURL string `json:"imageUrl"`
AltText *string `json:"altText"`
AltTextStatus string `json:"altTextStatus"`
}
// PhotoStore is a port for loading and saving photos.
type PhotoStore interface {
All(ctx context.Context) ([]Photo, error)
Get(ctx context.Context, id string) (Photo, bool, error)
Save(ctx context.Context, photo Photo) error
} Provider A’s adapterThe only code that knows provider A
Everything about provider A stays here: the path, the header, the JSON field names, and
the decision that a 429, a 500, an unreadable body, and a timeout all mean unavailable. The deadline itself comes from the HTTP client the composition
root passes in, which cancels the request when it runs out.
// Adapter: provider A's describe API, translated into the application's DraftOutcome.
export function providerA(http: Http, config: { baseUrl: string; key: string }): AltTextDrafter {
return {
async draft({ imageUrl, maxChars }) {
let response;
try {
response = await http({
url: `${config.baseUrl}/v1/describe`,
method: 'POST',
headers: {
authorization: `Bearer ${config.key}`,
'content-type': 'application/json'
},
body: JSON.stringify({ image_url: imageUrl, max_chars: maxChars })
});
} catch {
return { kind: 'unavailable' };
}
if (response.status !== 200) return { kind: 'unavailable' };
const body = parseJson(response.body);
return typeof body?.description === 'string'
? { kind: 'draft', text: body.description }
: { kind: 'unavailable' };
}
};
} Provider B, and a fallback made of two adaptersSame port, different shapes
Provider B can refuse, and it returns several outputs. The port asks for one draft, so this adapter passes on the first. The fallback implements the same port using two others, so the desk cannot tell it apart from a single provider.
import type { AltTextDrafter } from '../core/photos.ts';
import { parseJson, type Http } from './http.ts';
// Adapter: provider B's generate API. Its shape differs from A's, and it can
// refuse. It returns several outputs; the port asks for one, so this adapter
// passes on the first and the core judges it.
export function providerB(http: Http, config: { baseUrl: string; key: string }): AltTextDrafter {
return {
async draft({ imageUrl, maxChars }) {
let response;
try {
response = await http({
url: `${config.baseUrl}/generate`,
method: 'POST',
headers: { 'x-api-key': config.key, 'content-type': 'application/json' },
body: JSON.stringify({ task: 'alt_text', input: { image: imageUrl }, limit: maxChars })
});
} catch {
return { kind: 'unavailable' };
}
if (response.status !== 200) return { kind: 'unavailable' };
const body = parseJson(response.body);
if (body?.refused === true) return { kind: 'refused' };
const outputs = body?.outputs;
const first: unknown = Array.isArray(outputs) ? outputs[0] : undefined;
const text =
typeof first === 'object' && first !== null
? (first as { text?: unknown }).text
: undefined;
return typeof text === 'string' ? { kind: 'draft', text } : { kind: 'unavailable' };
}
};
} import type { AltTextDrafter } from '../core/photos.ts';
// An adapter made of two adapters: try the primary, and ask the secondary when
// the primary does not return a draft. It implements the same port, so the core
// cannot tell it apart from a single provider. It does not judge drafts; the core does.
export function withFallback(primary: AltTextDrafter, secondary: AltTextDrafter): AltTextDrafter {
return {
async draft(request) {
const first = await primary.draft(request);
return first.kind === 'draft' ? first : secondary.draft(request);
}
};
} The behavior every desk and adapter promisesChecked by 44 shared cases in TypeScript and Go
- Alt text is trimmed, not empty, at most 150 characters (characters, not bytes), and does not start with “image of”, “picture of”, or “photo of” followed by a space or the end. “Photo offers a view of the bridge” is fine.
- An upload with an empty title, or an image URL that does not start with
https://, is rejected before any draft is requested. - An upload asks the drafter once. An accepted draft is saved as
drafted; anything else saves the photo without alt text, asneeds-human. - An error status, an unreadable body, a network failure, or a timeout is
unavailable. Provider B’s refusal isrefused. Provider B’s first output is the draft, even when it breaks the rule. - The fallback returns the first drafter’s draft without asking the second. Otherwise it returns whatever the second one says.
- Backfill drafts waiting photos in order, and reports how many it drafted and how many still need a person.
The expectations were written from these rules rather than copied from either language, and the TypeScript and Go tests both check every one.
Reading the TypeScriptinterfaces, a union, and no imports
The ports are interfaces. DraftOutcome is a union with a kind field, so the desk handles a draft, a refusal, and unavailable by
name. Each adapter is a function that returns an object with a draft method; nothing says implements, and nothing needs to.
core/photos.ts has no import statements at all, and this lesson’s test
checks that. TypeScript itself would happily compile a core that imports node:crypto or an adapter, which is why section 09 adds a rule.
Reading the Goimplicit interfaces and context
Go interfaces are satisfied implicitly: “A type implements an interface by implementing
its methods. There is no explicit declaration of intent, no "implements" keyword” (A Tour of Go). So package photos declares AltTextDrafter and PhotoStore, and package adapters implements them without photos ever importing it.
Draft takes a context.Context, so a caller’s deadline reaches
the HTTP request and cancels it. The Go test parses photos.go and fails on any import outside a short list.
Run it yourselfNo dependencies
Save the complete files at the paths in their banners. Then run node --experimental-strip-types run.ts (Node 22.18 or later), or go run . in the Go folder. Both stand in for the providers without a network, and
both print:
photo-1 drafted: A firefighter carries a hose across a flooded street at dusk. photo-2 drafted: Marchers hold signs outside the courthouse. photo-3 needs-human backfill: 1 drafted, 0 still need a person
05 / Review the agent’s diff
“The adapter now skips drafts the desk would reject.”
Provider B sometimes ranks a weak caption first, and an agent notices. Its fix works, and every test passes. Read where the decision went.
06 / How it fails
Adapters contain a provider’s failures. They do not make them go away.
A port gives each failure one place to be handled. Here is what still goes wrong, and where the recorded builds showed it.
| What goes wrong | What happens | What handles it |
|---|---|---|
| A provider is down, rate limited, or sends garbage | Without handling, an upload fails, or saves nonsense as alt text. | The adapter reports unavailable, and the desk saves the photo for a
person. Checked by the shared cases, and by the checker against all four builds. |
| A provider is slow | A timeout that only stops waiting leaves the request running. The architecture build answered its caller at 3 seconds and kept its connection to provider A open until A replied, 4501 ms after the request. After the provider switch, its composition roots raised that timer to make room for a fallback, and with both providers slow it saved a draft 7.5 s after the upload. | Cancel the request in the adapter or its HTTP client, as AbortSignal.timeout does here and a context deadline does in Go, not just the
wait in the core. |
| A fake drifts from the real provider | The provider renames a field. The desk’s tests use a scripted drafter, so they keep
passing while production gets nothing but unavailable. | Adapter tests that replay the provider’s recorded responses: 8 for provider A and 9 for provider B here. |
| The port copies one provider’s shape | Scores, refusal reasons, and provider names creep into the port, and the next swap edits the desk after all. | Review port types as the desk’s vocabulary: what would it ask any provider? |
| A fallback hides a failing provider | When provider B is down, photos still get drafts from A, so nobody notices B, or the time it adds to every upload. | Count outcomes per adapter, not just per upload. Section 07 lists what. |
| Two drivers share one file | The backfill script reads every photo, drafts for a few seconds, then writes every photo back. A photo uploaded in between is gone: both recorded builds returned 404 for it afterwards. | Not something a port fixes, but its shape decides where the fix can go. A store that saves one photo can sit on a database row; one that loads and saves the whole list, as the architecture build’s did, cannot. |
The first three rows are what adapters are for. The last three are what they leave to you: the port’s shape, the numbers, and what happens between two drivers.
07 / Is it worth it?
A port costs an interface and an adapter per provider. Here is what it buys.
| Change | Plain build | Ports-and-adapters build |
|---|---|---|
| A second entry point: the backfill script both prompts asked for | Recorded: backfill.ts imports lib/provider.ts directly.
Provider A’s settings are read in 1 file: lib/provider.ts. | Recorded: backfill.ts is a composition root of its own and plugs in the
adapter itself. Provider A’s settings are read in 2 files: backfill.ts, server.ts. The port costs a
little more here. |
| Replace a dependency: provider B, with A as the fallback | Recorded, the provider B ticket: 3 files, +178 −57: lib/provider.ts, lib/providerA.ts, lib/providerB.ts | Recorded, the same ticket: 4 files, +183 −6: adapters/fallbackAltTextDrafter.ts, adapters/providerBDrafter.ts, backfill.ts, server.ts |
| Change a rule: the “image of” check | Recorded: 1 file makes the check, lib/altTextRules.ts. | Recorded: 1 file makes the check, core/altTextRules.ts. No difference. |
| A second team takes over the provider adapters | Authored. The provider code sits in lib/ beside the rules and the store, and
nothing marks where that team’s part ends. | Authored. The team owns adapters/, and the core only sees the port. A
rule that the core imports only the core marks the line; the checker’s run of it on
this build found: core/photoService.ts:9 → node:crypto. |
| Question | Plain build | Ports-and-adapters build |
|---|---|---|
| Code files, not counting tests | 5 | 10 |
| Tests that pass with networking blocked | 13 of 32 | 19 of 19 |
| Seconds to run every test file once | 4.7 | 0.3 |
| After the ticket: tests that pass with networking blocked | 13 of 42 pass, in 9.2 s | 24 of 28 pass, in 0.6 s |
The costs are real: more files, an interface to read through before you reach the HTTP call, and a mapping to write for every provider. A port with one adapter and nothing that swaps it is a guess about the future.
The architecture only makes the provider switch cheap. Whether the switch is worth making is a separate question, and the code cannot answer it. Before you change providers, take a baseline on provider A and write down what success means:
- The share of uploads saved as
needs-human, and how many of those the rule rejected, per provider. - Refusals and fallbacks per provider. A fallback rate that climbs is a provider failing quietly.
- Time from upload to response, at the 95th percentile. Drafting sits on the request path.
- The share of drafts a person edits before publishing. It is the closest thing to quality you can count.
Provider B is a good switch if those hold steady or improve at a price you accept. For the
architecture itself, count the files outside adapters/ and the composition roots
that a provider change touches, and how long the core’s tests take with the network off. This
lesson did not measure these for a real newsroom.
08 / Ask for it
Two prompts, two builds, then the same provider switch for both.
Two agents running Claude Sonnet received the same photo desk request. One prompt described
the desk. The other added an Architecture block: a core with two ports,
adapters for provider A and a JSON file, fakes for tests, and server.ts and backfill.ts as composition roots. Then fresh agents got the same ticket for each
build: switch to provider B, and fall back to A. A script ran all four builds against fake providers
it controlled, with a fresh server for every question.
| Question | Plain prompt | Ports-and-adapters prompt |
|---|---|---|
| What came back | 5 code files, 3 test files | 10 code files, 3 test files |
| Files that know provider A’s API | lib/provider.ts | adapters/providerADrafter.ts |
| Core imports from outside core/ | No core folder to check | core/photoService.ts:9 → node:crypto |
| A provider slower than 3 s: request canceled | Yes, after 2998 ms | No: left open until provider A answered, at 4501 ms |
| Tests that pass with networking blocked | 13 of 32 pass · all tests take 4.7 s | 19 of 19 pass · all tests take 0.3 s |
| After the ticket: code changed | 3 files, +178 −57: lib/provider.ts, lib/providerA.ts, lib/providerB.ts | 4 files, +183 −6: adapters/fallbackAltTextDrafter.ts, adapters/providerBDrafter.ts, backfill.ts, server.ts |
| After the ticket: rules or request handling changed | None | backfill.ts, server.ts |
| After the ticket: provider B refuses | Drafted by provider A after 13 ms | Drafted by provider A after 12 ms |
| After the ticket: B’s best output starts “Image of” | needs-human after 11 ms; provider A not asked | needs-human after 11 ms; provider A not asked |
| After the ticket: both providers slow | needs-human after 6.0 s | Drafted by provider A after 7.5 s |
| After the ticket: tests that pass with networking blocked | 13 of 42 pass · all tests take 9.2 s | 24 of 28 pass · all tests take 0.6 s |
| An upload made while backfill runs survives, in all four builds | No: 404 afterwards | No: 404 afterwards |
Both builds work, and both handled the ticket. The plain build’s first draft already kept
provider A behind one function, draftAltText, so its switch stayed in one
folder too: a file per provider, and a short function that tries B and then A. That is a
port in all but name, and for this change it was enough. Neither build touched its rules.
The ports build did edit server.ts and backfill.ts, its
composition root: they now wire provider B in front of A and pass a longer draft timeout
into the desk.
The difference the architecture made is in the tests. The ports build’s desk runs without a network: after the ticket, 24 of 28 pass of its tests with sockets blocked, and the whole suite takes 0.6 seconds. The plain build’s tests start real servers, so 13 of 42 pass, and the suite takes 9.2 seconds.
And the ports build broke a promise the plain build kept. The first prompt said a provider slower than 3 seconds is not used. The ports build kept that limit as a timer in the core, wrapped around the drafter port, so to make room for a fallback its ticket raised the timer to 8 seconds in both composition roots. With both providers slow, it waited 7.5 s and saved provider A’s late draft. The plain build cancels each request after 3 seconds inside each provider file, and gave up after 6.0 s. The architecture prompt never said where a time limit belongs. This lesson’s examples put the deadline in the adapter’s HTTP client, where it cancels the request, and the prompt in section 10 says so.
export async function draftAltText(imageUrl: string): Promise<string | null> {
const fromB = await draftAltTextFromB(imageUrl);
if (fromB !== null) {
return fromB;
}
return draftAltTextFromA(imageUrl);
} +// Room for provider B's up-to-3s attempt plus a provider A retry after it.
+const DRAFT_TIMEOUT_MS = DEFAULT_PRIMARY_TIMEOUT_MS + 5000;
- const result = await createPhoto({ title: body.title, imageUrl: body.imageUrl }, drafter, store);
+ const result = await createPhoto(
+ { title: body.title, imageUrl: body.imageUrl },
+ drafter,
+ store,
+ DRAFT_TIMEOUT_MS,
+ ); Two failures were in all four builds. An upload saved while backfill runs is lost, and “Photo offers a view of the bridge” is rejected, because a prefix check with no word boundary reads “photo of” at the start of “Photo offers”. Both come from requirements the prompts share, not from either architecture.
How the runs were made and checkedFour builds, recorded as written
- Round one sent both prompts at the same time to fresh agents. Round two copied each build and gave a fresh agent the same ticket, which says nothing about architecture. No agent knew about the others, the lesson, or the checker.
- All four builds are kept byte for byte, with checksums and both diffs. The checker runs its own fake providers, starts a fresh server per question, and runs each test file twice: normally, and with a preloaded module that makes every socket connection throw.
- Both round-one prompts gave a test command that fails on Node 22.21,
--test tests/. All four agents noticed and used a glob instead. - Both ticket agents sent provider B
limit: 1, reading it as a number of outputs. The ticket did not say; this lesson’s adapter sends the character limit. - Every agent wrote some scratch files outside its folder, and none read this repository.
One stopped servers with
pkill -f "server.ts", which matches any process running a file with that name. - The checker’s first attempt counted an error message that names the rule as a copy of the rule. That attempt is kept, and the measure now looks for the check itself.
- One run of each prompt and ticket is a sample, not a measurement of the model.
09 / Hold it there
A core that imports nothing stays that way only if something checks.
The architecture prompt said the core must never import node:http, node:fs, or fetch. The agent followed that to the letter, and
imported node:crypto into the core for ids. A list of forbidden modules is a suggestion
with gaps. Three layers close them.
The language’s own door
Neither language has one for this. Go comes closest: the core is its own package, so every import it makes sits at the top of one file, and this lesson’s Go test parses that file and fails on anything outside a short list.
A rule a check runs on every change
These two rules run on Enforcement layer’s engine. They find nothing in this lesson’s desk. In the architecture build, the checker found 1 violation: core/photoService.ts:9 → node:crypto. The lesson’s tests add a provider import to the core and a handler that picks its own adapter, and each is caught. Enforcement layer wires the check so an agent’s change cannot skip it.
rules.ts import type { Rule } from '../../../enforcement-layer/examples/rules.ts'; // Ports and adapters, written as rules for the engine Enforcement layer runs. // Paths are relative to the project root. A bare specifier such as node:fs // stays as written, so "anything outside core/" includes the runtime. /** The files allowed to pick adapters: composition roots and tests. */ export const roots = '^(app|server|backfill|run)\\.ts$|^tests/|\\.spec\\.ts$'; export const portRules: Rule[] = [ { name: 'core-knows-only-core', severity: 'error', comment: 'The core imports nothing outside core/: no adapters, no runtime modules, no provider SDKs.', from: { path: '^core/' }, to: { pathNot: '^core/' } }, { name: 'adapters-are-chosen-at-the-root', severity: 'error', comment: 'Only a composition root or a test decides which adapter to use.', from: { pathNot: `^(core|adapters)/|${roots}` }, to: { path: '^adapters/' } } ];A check on each adapter’s contract
The import rule cannot see a provider renaming a field. Adapter tests that replay recorded provider responses can, and they are the tests a scripted drafter is not a substitute for.
Your function props are already portsA component that takes a function prop already has a port. One with fetch inside does not.
Where it already is in your components
An alt-text field that receives draft as a prop has a port. The page passes
the real drafter, and a story or a test passes a scripted one. The same field with fetch('/api/…') written inside has a hard-wired adapter, and every story now needs
a mocked network.
When you have to own it
In the browser, the drafter adapter is your own server, never the provider. The provider’s key must stay on the server, and the desk’s rule has to run where nobody can skip it, as Client–server architecture shows. Context is the frontend’s composition root: the app provides the server-backed drafter once, and a story provides a scripted one.
A field that takes the drafter as a prop and never learns where drafts come from.
import { useState } from 'react';
// The field needs a draft, not a provider. It takes the port as a prop, so the
// page passes the real drafter and a test or a story passes a scripted one.
export type DraftAltText = (imageUrl: string) => Promise<string | null>;
export default function AltTextField({
imageUrl,
draft
}: {
imageUrl: string;
draft: DraftAltText;
}) {
const [text, setText] = useState('');
const [status, setStatus] = useState('');
async function suggest() {
setStatus('Drafting…');
const drafted = await draft(imageUrl);
if (drafted) setText(drafted);
setStatus(
drafted ? 'Draft added. Check it before you publish.' : 'No draft. Write it yourself.'
);
}
return (
<div>
<label>
Alt text
<textarea value={text} maxLength={150} onChange={(event) => setText(event.target.value)} />
</label>
<button type="button" onClick={suggest}>
Suggest a draft
</button>
<p role="status">{status}</p>
</div>
);
}
10 / Make the call
Put a port where you expect a swap, or need tests without the outside world.
Skip it for a script that calls one provider once, or for code with no rules worth protecting. Reach for it when an outside dependency is likely to change, and model providers are; when the rules deserve tests that run without a network; or when a second driver, like a backfill script, needs the same rules. Start with the ports you need now, named in the application’s words, and add adapters as they arrive.
Ports and adapters organize one application. In a modular monolith, each module can have its own core and ports.
Take it with you
Explain the desk without saying “hexagonal”: “The desk decides what alt text is acceptable. It asks for drafts through one interface, and each provider gets a small translator. Switching providers means writing a translator and changing one line where the app starts.” Then find the outside service your code calls from the most places, and count them.
Paste into your next prompt, and fill in the blanks
Organize the code as ports and adapters. The core lives in core/ and imports nothing outside core/: no runtime modules, no SDKs, no fetch. The core defines the ports it needs, in its own words: <ports>. A port's results are the core's own types, failures included (for example: draft, refused, unavailable). Adapters live in adapters/, one per provider or technology. Each translates its provider's requests, responses, errors, and timeouts, and a timeout cancels the request. Business rules stay in the core. Adapters do not choose between results. Only the composition roots (<entry points>) pick adapters. Tests use fakes and need no network. Add a check that fails when core/ imports anything outside core/, and adapter tests that replay each provider's recorded responses.
Connections to follow nextRelated lessons
- Client–server architecture decides which side the desk’s rule runs on.
- Modular monolith puts several cores like this one in one deployment.
- Enforcement layer turns the port rules into a check an agent cannot skip.
- Dependency injection is how the composition root hands adapters to the desk.