Shape, boundaries, failures

Last updated: 2026-08-18A four-beat funnel, limits on nesting and length, where time and randomness are allowed to live, dependency direction, and the two classes of failure.

Shape, boundaries, failures

The funnel: four beats, always in this order

guard
cut off the impossible, exit early
acquire
fetch the data (I/O)
derive
compute, purely
effect
write, send, return

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.

ts
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

LimitValueWhat exceeding it means
Nesting2The inner block is a separate function
Arguments3Beyond that, one named object
Lines per function40The function does more than one thing
Lines per file400The file holds more than one role
Directory depth below src/3Nobody 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 else after return. The guard exits and the rest continues further left.
  • No nested ternaries — that is a switch or 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 return is not needed.
The usual way
const result = await loadTrack(id)
return result
GRAIN
return 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.

Depends on the machine clock
export function isTrialExpired(user: User): boolean {
  return Date.now() > user.trialEndsAt.getTime()
}
A pure function
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:

RoleMay import
*.pure.tspure, shape
*.policy.tspure, shape, policy
*.shape.tsshape
*.store.ts · *.wire.ts · *.event.tsshape, pure, and its own role
*.route.ts · *.rpc.ts · *.job.ts · *.entry.tsanything — 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

180 ms instead of 90
const track = await findTrack(trackId)
const genres = await listGenres()
GRAIN
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.declined

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

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

Shape, boundaries, failures | AS Docs