Shape, boundaries, failures
The funnel: four beats, always in this order
Beats do not interleave. I/O inside a loop inside a condition is not GRAIN. If you end up with two sets of beats, you have two functions.
export async function publishTrack(
trackId: string,
actor: Actor,
nowMs: number,
): Promise<Outcome<Track, PublishFail>> {
const track = await findTrack(trackId)
if (track === null) return makeFail('music.track.missing', 'Track not found')
if (!canPublish(track, actor)) return makeFail('music.track.forbidden', 'Not allowed')
const published = derivePublishedTrack(track, nowMs)
await saveTrack(published)
await emitTrackPublished(published)
return { isOk: true, value: published }
}The numbers
| Limit | Value | What exceeding it means |
|---|---|---|
| Nesting | 2 | The inner block is a separate function |
| Arguments | 3 | Beyond that, one named object |
| Lines per function | 40 | The function does more than one thing |
| Lines per file | 400 | The file holds more than one role |
Directory depth below src/ | 3 | Nobody reads a hierarchy deeper than this |
These are not aesthetics but an alarm. Exceeding a limit does not mean "badly written" — it means "a second job is hidden here".
Shape rules
- No
elseafterreturn. The guard exits and the rest continues further left. - No nested ternaries — that is a
switchor a lookup table. - Named exports only. A thing has the same name at both ends of an import, otherwise searching the codebase stops working.
- A variable that lives one line before a
returnis not needed.
const result = await loadTrack(id)
return resultreturn await loadTrack(id)Boundaries
The rules in this chapter address what most often makes code impossible to test and unpredictable in production.
Time, randomness and environment live only at the boundary
Date.now(), new Date(), Math.random(), crypto.randomUUID() and process.env belong in boundary files: *.entry.ts, *.route.ts, *.rpc.ts, *.job.ts, *.wire.ts. They reach the domain as arguments.
export function isTrialExpired(user: User): boolean {
return Date.now() > user.trialEndsAt.getTime()
}export function isTrialExpired(user: User, nowMs: number): boolean {
return nowMs > user.trialEndsAt.getTime()
}The price of breaking this is concrete: the "expired trial" test has to fake system time, a timezone mistake only surfaces in production, and a bug that happened at night cannot be reproduced during the day.
Dependencies point inward
The domain does not know who calls it or how it reaches the outside world. A file's role decides what it may import:
| Role | May import |
|---|---|
*.pure.ts | pure, shape |
*.policy.ts | pure, shape, policy |
*.shape.ts | shape |
*.store.ts · *.wire.ts · *.event.ts | shape, pure, and its own role |
*.route.ts · *.rpc.ts · *.job.ts · *.entry.ts | anything — these are boundaries, knowing everything is their job |
The moment *.pure.ts imports *.store.ts, the pure part stops being pure, and it can no longer be tested without a database or reused elsewhere.
Independent waits run in parallel
const track = await findTrack(trackId)
const genres = await listGenres()const [track, genres] = await Promise.all([
findTrack(trackId),
listGenres(),
])Sequential await is justified exactly when the second call uses the result of the first. Everything else is a request waterfall, which the user experiences as "slow to load".
Public surface on demand
Everything is private by default. An export appears when there is a second consumer, not "for later": every premature export becomes a permanent API that nobody dares to touch.
Dead code is deleted
Code with no caller is deleted — not commented out, not hidden behind a flag, not kept "just in case". Git remembers everything; a commented-out block is remembered only by its author, and inaccurately within a month.
Failures
Two classes, nothing in between.
An expected failure is part of the contract: not found, not allowed, token expired, insufficient funds. It is returned as a typed value with a code shaped domain.subject.reason:
auth.token.expired
music.track.missing
billing.card.declinedAn unexpected failure is a broken invariant, an unreachable database, a bug. It is thrown, and caught only at the process boundary that knows how to record it and return a 500.
type PublishFail = 'music.track.missing' | 'music.track.forbidden' | 'music.track.incomplete'The codes are listed in the signature, so the caller sees every outcome without reading the body, and the compiler will not let them forget one.