01 / The prompt
“Stop the pet-owner app from importing the records database.”
A veterinary clinic keeps its code in one repository: a web app where pet owners book appointments, an app for the staff, and shared packages. One package talks to the medical-records database. The staff app needs it. The pet-owner app must never ship it, because that app runs in owners’ browsers and must not carry the queries, let alone the credentials.
You ask an agent for a check, and it adds one: a rule that fails when a pet-owner file
imports @clinic/records-db. It works. A week later another agent needs vaccination
history in the pet-owner app, hits the rule, and moves the import into the shared package,
which the app is allowed to use. The rule passes. The database ships.
The question the prompt never answered: is the promise about a line of code, or about what the app ships? A rule written for the first is easy to satisfy without keeping the second.
02 / Name the move
A property, a measurement, a threshold, and when it runs.
A fitness function is a check that measures how well the system keeps one of its architectural promises, run automatically as the system changes. Thoughtworks puts it as “Fitness functions describe how close an architecture is to achieving an architectural aim,” crediting Neal Ford, Rebecca Parsons, and Pat Kua’s Building Evolutionary Architectures (Fitness function-driven development).
Say the promise as a property of what ships. Measure that, not a line of code that usually implies it. Decide what fails, and run it on every change.
| Part | For the clinic | The lint rule it replaces |
|---|---|---|
| The property | No file the pet-owner app ships can reach the records database. | No pet-owner file writes from '@clinic/records-db'. |
| The measurement | A walk over the graph of runtime imports. | A search of each file’s import lines. |
| The threshold | Zero paths. Print each one found. | Zero matching lines. |
| When it runs | On every change, in CI, before merge. | Whenever someone runs lint. |
Words to put in a prompt or a review
- Fitness function
- A check on an architectural promise that runs as the code changes.
- Boundary
- A line in the code that one part must not cross to reach another.
- Import graph
- Every file and every file it imports: the map of what can reach what.
- Reachable
- Imported directly, or through any chain of other files and packages.
- Erased import
- An
import type: checked by the compiler, gone before anything ships. - Barrel
- A file that re-exports from other modules, and so carries them along.
Beyond importsOther promises a fitness function can hold
Import boundaries are the most common fitness function because they are cheap to measure, but the idea is general: a bundle size budget for the pet-owner app, a query-count ceiling on one page, a latency target replayed in CI, the output snapshot from Baseline before you change. Each has the same four parts: a property, a measurement, a threshold, and when it runs.
03 / Follow five commits
Watch a lint rule stop seeing the database.
Five commits to the clinic’s repository, each checked by both rules: the one that reads import lines and the one that follows the graph. The third commit is the one that matters. Open Try it to write your own way around the rule.
Does the pet-owner app ship the records database?
Commit · clean
apps/pet-owner
- appointments.ts → @clinic/shared
- main.ts → appointments.ts→ @clinic/ui
apps/staff
- main.ts → @clinic/records-db→ @clinic/ui
packages
- @clinic/records-db
- @clinic/shared
- @clinic/ui
Direct-import rulenot run yet
Reachability rulenot run yet
Commit: clean
The clinic’s repo today. The pet-owner app uses the shared and UI packages; the staff app also uses the records database, as it should.
Reduced motion: choose a scene to see its completed state.
Read this scene
The clinic’s repo today. The pet-owner app uses the shared and UI packages; the staff app also uses the records database, as it should.
Commit: clean. Imports: apps/pet-owner/src/appointments.ts imports packages/shared/src/index.ts; apps/pet-owner/src/main.ts imports apps/pet-owner/src/appointments.ts; apps/pet-owner/src/main.ts imports packages/ui/src/index.ts; apps/staff/src/main.ts imports packages/records-db/src/index.ts; apps/staff/src/main.ts imports packages/ui/src/index.ts.
Watch restarts the story when you come back. Step through keeps your step. Try it builds a fresh repository every time you run the rules.
04 / Read it in code
Read the imports, build the graph, walk it.
Basic form reads what one file asks for, and knows which of those requests ship. In the wild builds the graph and applies both rules to it. At the call site the rule runs over real files in CI and fails with the path.
Reading what a file asks for: comments blanked first, then static imports and re-exports, bare imports, import(), and require(). An import type is marked, because it is erased before anything ships.
const FROM = /\b(import|export)(\s+type)?\s[^;]*?\bfrom\s*['"]([^'"]+)['"]/g;
const BARE = /\bimport\s*['"]([^'"]+)['"]/g;
const DYNAMIC = /\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
const REQUIRE = /\brequire\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
/** Every module a file asks for. `import type` and `export type` are erased and do not ship. */
export function importsOf(source: string): Import[] {
const code = stripComments(source);
const found: Import[] = [];
for (const m of code.matchAll(FROM)) found.push({ spec: m[3], typeOnly: Boolean(m[2]) });
for (const re of [BARE, DYNAMIC, REQUIRE])
for (const m of code.matchAll(re)) found.push({ spec: m[1], typeOnly: false });
return found;
} var (
fromRe = regexp.MustCompile(`(?s)\b(import|export)(\s+type)?\s[^;]*?\bfrom\s*['"]([^'"]+)['"]`)
bareRe = regexp.MustCompile(`\bimport\s*['"]([^'"]+)['"]`)
dynamicRe = regexp.MustCompile(`\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)`)
requireRe = regexp.MustCompile(`\brequire\s*\(\s*['"]([^'"]+)['"]\s*\)`)
)
// ImportsOf lists every module a file asks for. `import type` and `export type` are erased
// at build time and do not ship.
func ImportsOf(source string) []Import {
code := StripComments(source)
found := []Import{}
for _, m := range fromRe.FindAllStringSubmatch(code, -1) {
found = append(found, Import{Spec: m[3], TypeOnly: m[2] != ""})
}
for _, re := range []*regexp.Regexp{bareRe, dynamicRe, requireRe} {
for _, m := range re.FindAllStringSubmatch(code, -1) {
found = append(found, Import{Spec: m[1]})
}
}
return found
} The behavior these examples promiseChecked by 17 shared cases in TypeScript and Go
- Comments are blanked before anything is read. Static imports, re-exports, bare imports,
import(), andrequire()all count;import typeandexport typedo not ship. - Relative paths resolve from the importing file; workspace names resolve to their
package, and
name/subto a file inside it. Anything else is outside the repository. - The direct rule reports every pet-owner file’s own import of the database. The reachability rule reports, for every pet-owner file, the shortest path to the database, if there is one.
- The extractor reads patterns, not a syntax tree: import-looking text inside a string literal counts. One shared case records that limit on purpose.
Every expectation in the shared cases was produced by a separate model written from these
rules, kept beside the examples in examples/model/, not copied from either
implementation.
Reading the TypeScriptmatchAll, a Map, and a queue
Each pattern is a global regular expression read with matchAll. The graph
is a Map from file to the files it imports. The walk keeps a previous map, so the first time it reaches the database it can rebuild the path
backward; a queue makes that path a shortest one.
Reading the GoRE2 and filepath.WalkDir
Go’s regexp is RE2, which has no backreferences, so the quote characters
are matched as a class rather than paired. CheckTree uses filepath.WalkDir and filepath.ToSlash, so paths match the rule on every platform.
Run it yourselfNo dependencies
Copy the complete TypeScript file and run node --experimental-strip-types fitness.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/fitness-functions
go 1.23
clean: direct 0, reachable 0 a direct import: direct 1, reachable 2: apps/pet-owner/src/main.ts -> apps/pet-owner/src/vaccinations.ts -> packages/records-db/src/index.ts through the shared package: direct 0, reachable 3: apps/pet-owner/src/appointments.ts -> packages/shared/src/index.ts -> packages/records-db/src/index.ts a dynamic import: direct 1, reachable 2: apps/pet-owner/src/main.ts -> apps/pet-owner/src/vaccinations.ts -> packages/records-db/src/index.ts a type-only import: direct 0, reachable 0
05 / Review the agent’s diff
“Fixed the boundary lint failure.”
The rule failed, and the agent made it pass. Read what the change does to the app, not to the rule.
06 / How it fails
A check fails by missing a way in, or by crying wolf.
A fitness function is code, and it has failure modes of its own. Here is each one for the clinic’s boundary, what it costs, and what the two rules in this lesson do.
| What goes wrong | What it costs | What the reachability rule does | Direct → reachable |
|---|---|---|---|
| The import goes through another package | The database ships and the check passes. | Follows the re-export and prints the path. | 0 → 3 violations |
| Other code gets caught up in it | A page nobody touched now ships the database. | Reports every pet-owner file that reaches it. | appointments.ts is on a path |
| A dynamic import or require() | The same, only later. | Counts them as imports. | 1 → 2 |
| A type-only import | A false alarm, if the check counts erased imports. | Leaves it out: it does not ship. | 0 → 0 |
| An import in a comment | A false alarm, and people stop trusting it. | Blanks comments before reading. | 0 → 0 |
| Import-looking text in a string | A false alarm. | Counts it: a known limit of reading patterns. | recorded in the cases |
The two directions of failure are not equal. A check that misses lets the promise break silently. A check that cries wolf gets disabled, and then it misses everything. Test the check against both kinds of example before you trust it, which is exactly what the recorded runs in section 08 show.
07 / Is it worth it?
You pay for a graph walk in CI. Here is what it buys.
A lint rule on import lines is simpler, faster, and familiar. Hold both up against the changes a monorepo like this gets.
| Change | Import-line rule | Reachability check |
|---|---|---|
| A second client: a pet-owner phone app | Copy the rule, and every name it lists. | Add one line: the new app’s folder to the same property. |
| Replace the database package | Update the forbidden name in every rule. | Update one path. |
| Change a rule: the UI package may not use it either | Another rule, with its own gaps. | Another start folder for the same walk. |
| A second team owns the shared package | Their re-export gets past your rule. | Their re-export fails your check, with the path. |
What to measure, and what to accept, before switching: run both checks on the last hundred merged changes and count what each would have flagged. Accept the reachability check if it flags every change the line rule flags, plus any real crossing the line rule missed, and no false alarm a person had to wave through. Then watch how often it is overridden: a fitness function that is routinely skipped is measuring the wrong thing.
This page did not replay a real history, so it has no numbers of that kind. A walk over a few hundred files takes milliseconds; the cost worth watching is false alarms, not time.
08 / Ask for it
Two checks, nine repositories they had never seen.
We asked two agents, both running Claude Sonnet, for this check on the same repository. One prompt asked for a check that stops the pet-owner app from depending on the database. The other added a paragraph: treat the rule as a fitness function, state the property for what the app ships, check that rather than an import line, and test the check against small repositories that break it and ones that should pass. It named no way of breaking the rule. Then a script ran both checks against nine repositories built for the purpose.
| The repository | Should | Plain prompt | Architecture prompt |
|---|---|---|---|
| The clean repository (the staff app imports the database) | Pass | Passes | Passes |
| A direct import | Fail | Fails the build | Fails the build |
| Through the shared package | Fail | Fails the build | Fails the build |
| A dynamic import | Fail | Fails the build | Fails the build |
| require() | Fail | Fails the build | Fails the build |
| A relative path into the package | Fail | Passes ✗ | Fails the build |
| A commented-out import | Pass | Fails the build ✗ | Passes |
| A package with a similar name | Pass | Passes | Passes |
| A type-only import (erased at build time) | Policy | Fails the build | Fails the build |
| Right on the cases with an answer | 6 of 8 | 8 of 8 |
Both agents did better than the lint rule this lesson starts with. The prompt said “must not
depend on”, not “must not import”, and both built a search that follows dependencies through
other packages, so the route through shared failed both builds.
The difference is in the edges of the promise. The plain check matched package names in the raw source, so it missed a relative path into the database’s folder, and it failed the build on an import that had been commented out. The fitness-function check tested itself against eight example repositories it wrote, and it got every answerable case right. It also blanks the insides of strings as well as comments before it reads imports, which is stricter than this lesson’s own extractor.
function extractImportSpecifiers(source: string): string[] {
const specifiers: string[] = [];
IMPORT_SPECIFIER_RE.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = IMPORT_SPECIFIER_RE.exec(source)) !== null) {
const spec = match[1] ?? match[2];
if (spec) specifiers.push(spec);
}
return specifiers;
}
// Does `specifier` refer to workspace package `pkgName`? Either the bare
// name itself, or a deep import into it (e.g. "@clinic/records-db/src/x").
function specifierMatchesPackage(specifier: string, pkgName: string): boolean {
return specifier === pkgName || specifier.startsWith(pkgName + '/');
} * Builds a same-length "skeleton" of the source where every character
* inside a // line comment or a /* block comment *\/ is blanked to a space,
* and every character inside the BODY of a string/template literal is also
* blanked to a space (the quote delimiters themselves are kept). Line
* breaks are preserved as line breaks so multi-line constructs still line
* up.
*
* This means a specifier mentioned only in a comment, or a require(...)-
* shaped fragment that appears merely as *text inside some unrelated string
* literal* (e.g. a log message), disappears from the skeleton and can never
* be mistaken for a real import - the check is looking for import syntax,
* not for the substring "@clinic/records-db" anywhere in the file. Both checks count an erased import type, which this lesson’s rule allows. That
is a policy, and the prompt did not settle it, so each agent chose. The line the runs point
to is the one that settles the edges in advance: list the ways the promise can break, and the things that must not trip it, and make each
one an example repository the check is tested against.
How the runs were made and checkedOne run each, recorded as written
- Both agents received the prompts word for word, in fresh contexts, in the same message, each in a folder seeded with the same clean repository. The only differences were the Architecture paragraph and the folder.
- Every file each agent added is kept byte for byte, with checksums, beside this lesson’s examples, including the eight example repositories the fitness-function agent wrote. The checker lays each agent’s files over nine repositories of its own and records the exit code.
- The type-only row is a policy question, so neither check is scored on it. This is one sample of each prompt, not a measurement of a model; the transcript audit is in the run notes.
09 / Hold it there
Let the framework say no, then check what it cannot see.
A fitness function is itself something to hold in place. Three layers keep this one honest.
The framework’s own boundary
Where the framework can see the boundary, let it enforce it. In Next.js, a module that imports
server-onlyfails the build if a Client Component imports it, and a file marked'use client'puts “all of its imports and the components it directly renders” in the client bundle (Server and Client Components). SvelteKit refuses browser code that imports$lib/server(server-only modules). The records package can say what it is.A real tool, run here
dependency-cruiser supports the same two rules; a
reachable: truerule follows “either directly or via other modules”. We ran it on the shared-package commit: the direct rule was silent and the reachable rule found the same 3 violations as this lesson’s code. It also showed that the tool’s settings change the answer: with type-only imports counted, it flagged the type-only commit; with them left out, TypeScript’s own elision of an unused import made it find 2 instead of 3. Decide which build stage “ships” means, and test the tool against your examples..dependency-cruiser.cjs // Two rules for the same promise. The first is what a lint rule on imports checks; the // second is the fitness function: nothing the pet-owner app can reach may be the database. module.exports = { forbidden: [ { name: 'pet-owner-no-direct-records-db', severity: 'error', from: { path: '^apps/pet-owner/' }, to: { path: '^packages/records-db/' } }, { name: 'pet-owner-never-reaches-records-db', severity: 'error', from: { path: '^apps/pet-owner/' }, to: { path: '^packages/records-db/', reachable: true } } ], options: { tsConfig: { fileName: 'tsconfig.json' }, tsPreCompilationDeps: true, doNotFollow: { path: 'node_modules' } } };Test the check itself
A fitness function that has never failed has not been tested. Keep small example repositories that break the promise each way you know of, and ones that must pass, and run the check against them in CI. The checker in section 08 does that for both recorded checks.
check-runs.mjs const fixtures = [ { name: 'the clean repository (the staff app imports the database)', breaks: false, files: {} }, { name: 'a direct import', breaks: true, files: { 'apps/pet-owner/src/vaccinations.ts': "import { getVaccinations } from '@clinic/records-db';\nexport const show = (pet: string) => getVaccinations(pet);\n", 'apps/pet-owner/src/main.ts': "import { renderAppointments } from './appointments';\nimport { Button } from '@clinic/ui';\nimport { show } from './vaccinations';\n" } }, { name: 'through the shared package', breaks: true, files: { 'packages/shared/src/index.ts': "export const formatDate = (d: Date) => d.toISOString().slice(0, 10);\nexport { getVaccinations } from '@clinic/records-db';\n", 'apps/pet-owner/src/vaccinations.ts': "import { getVaccinations } from '@clinic/shared';\nexport const show = (pet: string) => getVaccinations(pet);\n" } }, { name: 'a dynamic import',
Build UIs?Your framework already runs a fitness function on every build. The pet-owner app is where you write your own.
Where it already is in your components
Every time you add 'use client' in Next.js, or put a module under $lib/server in SvelteKit, you are relying on a fitness function someone else wrote:
the build walks your imports and refuses to put server-only code in the browser. It checks reachability,
not import lines, which is why a server-only module pulled in through three other files still
fails the build.
When you have to own it
The framework only knows the boundaries it was told about. The clinic’s rule, that the pet-owner app never ships the records package, is yours to write down. The component that shows vaccination history asks the server for it; the page that is allowed to use the package is a server component or a server load; and the package announces itself as server-only so the framework’s check covers it too.
A client component that fetches vaccinations from an API instead of importing the records package.
'use client';
import { useEffect, useState } from 'react';
type Vaccination = { vaccine: string; date: string };
// A client component. Everything it imports ships to the owner's browser, so it asks the
// server for vaccinations instead of importing the records package. Types are fine: they are
// erased before anything ships.
export default function Vaccinations({ pet }: { pet: string }) {
const [rows, setRows] = useState<Vaccination[] | null>(null);
useEffect(() => {
fetch(`/api/pets/${encodeURIComponent(pet)}/vaccinations`)
.then((response) => response.json() as Promise<Vaccination[]>)
.then(setRows);
}, [pet]);
if (!rows) return <p>Loading vaccinations…</p>;
return (
<ul>
{rows.map((r) => (
<li key={`${r.vaccine}-${r.date}`}>
{r.vaccine}, {r.date}
</li>
))}
</ul>
);
}
10 / Make the call
Check the promises that would be expensive to break quietly.
A comment or a convention is enough for a boundary nobody would be hurt by crossing, in a repository two people work in. Write a fitness function when crossing it would leak data, break an independent deploy, or cost a team a week to untangle, and whenever agents change the code, because an agent will satisfy a rule exactly as written.
Revisit it when a new app or package joins, when the check has been overridden more than once, and when a crossing reaches production anyway: that crossing is the next example repository.
Take it with you
Explain it without saying “fitness function”: “We wrote down that the pet-owner app never ships the database, and CI walks everything the app imports to prove it on every change. We tested that check against repos that break the rule in each way we know.” Then pick one boundary in your own repository and write its property in one sentence.
Paste into your next prompt, and fill in the blanks
Add a fitness function for this boundary: <app> must never ship code from <package>, directly or through any other package; <other app> may. - State the property for what ships, not for import lines. - Check it over the import graph: static imports, re-exports, import(), require(), and relative paths into <package>. Ignore comments and import type. - Test it against small example repositories: one clean, one per way to break it, and <similar names, allowed apps> that must pass. - Run it in CI on every change, and print the path when it fails.
Connections to follow nextRelated lessons
- Architecture as rules writes rules like this one from a single declaration of the architecture.
- Enforcement layer runs them against agents’ changes and records what they catch.
- Baseline before you change is a fitness function for behavior and speed.
- Client–server architecture draws the first boundary a browser app has.