← Architecture
Failure and evidence Checked on every change

Fitness functions

Check the promise, not the line.

Somewhere in your repository there is a rule that keeps one part from depending on another: a lint rule, a folder convention, a sentence in the README. It holds until a change finds a way around it that nobody wrote a rule for. Let’s take one boundary in a small monorepo and turn it into a check that holds when the code changes around it.

The skill to keep: State the architecture’s promise as a property of what ships, measure that property on every change, and test the check itself against examples that break the promise and examples that keep it.

TypeScriptGoOne boundary, five commits, two recorded checks.

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.

The clinic’s boundary as a fitness function
PartFor the clinicThe lint rule it replaces
The propertyNo file the pet-owner app ships can reach the records database.No pet-owner file writes from '@clinic/records-db'.
The measurementA walk over the graph of runtime imports.A search of each file’s import lines.
The thresholdZero paths. Print each one found.Zero matching lines.
When it runsOn 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.

Failure and evidence

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

01/ 05
Clean

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.

TypeScriptReading
fitness.ts
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;
}
GoAlongside
main.go
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(), and require() all count; import type and export type do not ship.
  • Relative paths resolve from the importing file; workspace names resolve to their package, and name/sub to 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:

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

The agent’s pull request

“Fixed the boundary lint failure: vaccinations now come through the shared package instead of importing @clinic/records-db directly. Lint and tests pass.”

// packages/shared/src/index.ts
			export const formatDate = (d: Date) => d.toISOString().slice(0, 10);
			(added)export { getVaccinations } from '@clinic/records-db';
			
			// apps/pet-owner/src/vaccinations.ts
			(removed)import { getVaccinations } from '@clinic/records-db';
			(added)import { getVaccinations } from '@clinic/shared';
			
You are reviewing this change. What do you do?

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.

How a boundary check fails, on the clinic’s commits
What goes wrongWhat it costsWhat the reachability rule doesDirect → reachable
The import goes through another packageThe database ships and the check passes.Follows the re-export and prints the path.0 → 3 violations
Other code gets caught up in itA 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 importA false alarm, if the check counts erased imports.Leaves it out: it does not ship.0 → 0
An import in a commentA false alarm, and people stop trusting it.Blanks comments before reading.0 → 0
Import-looking text in a stringA 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.

The same four changes, against a line rule and a fitness function
ChangeImport-line ruleReachability check
A second client: a pet-owner phone appCopy the rule, and every name it lists.Add one line: the new app’s folder to the same property.
Replace the database packageUpdate the forbidden name in every rule.Update one path.
Change a rule: the UI package may not use it eitherAnother rule, with its own gaps.Another start folder for the same walk.
A second team owns the shared packageTheir 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.

What each check said about repositories it had never seen, run 2026-09-23
The repositoryShouldPlain promptArchitecture prompt
The clean repository (the staff app imports the database)PassPassesPasses
A direct importFailFails the buildFails the build
Through the shared packageFailFails the buildFails the build
A dynamic importFailFails the buildFails the build
require()FailFails the buildFails the build
A relative path into the packageFailPasses ✗Fails the build
A commented-out importPassFails the build ✗Passes
A package with a similar namePassPassesPasses
A type-only import (erased at build time)PolicyFails the buildFails the build
Right on the cases with an answer6 of 88 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.

check-boundary.ts · plain prompt
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 + '/');
}
check-boundary.ts · architecture prompt
* 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.

  1. The framework’s own boundary

    Where the framework can see the boundary, let it enforce it. In Next.js, a module that imports server-only fails 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.

  2. A real tool, run here

    dependency-cruiser supports the same two rules; a reachable: true rule 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' }
    	}
    };
    
  3. 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.

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

Take the clinic’s repository into your editor. Add a rule that the UI package may not import any app, and write the example repository that breaks it before you write the rule.

Back to architecture →