One endpoint, before and after
The honest way to judge a standard is to take the code everyone writes and rewrite it by the rules, counting the price of each change. The task is simple and real: publish a track. Check permissions, check readiness, stamp the publication date, return the result.
Nothing about the behaviour will change. What changes is how much you must hold in your head to verify that behaviour.
How it usually looks
One file, one function, everything inside. This is what a beginner writes, and what a language model writes without a standard — because it is the most probable shape, not the best one.
// src/utils/trackUtils.ts
// Handle track publishing
export default async function handleTrackPublish(req: any, res: any) {
try {
const data = req.body
// Check if user is authorized
if (!req.user) {
res.status(401).json({ error: 'Unauthorized' })
return
}
const result = await db.query('SELECT * FROM tracks WHERE id = $1', [data.id])
const track = result.rows[0]
if (track) {
if (track.owner_id === req.user.id || req.user.role === 'admin') {
// Check if track can be published
if (track.duration > 30 && track.cover_url) {
const now = Date.now()
const updated = await db.query(
'UPDATE tracks SET published_at = $1, updated = $2 WHERE id = $3 RETURNING *',
[now, now, data.id],
)
console.log('✅ Track published!', data.id)
res.json(updated.rows[0])
} else {
res.status(400).json({ error: 'Invalid track' })
}
} else {
res.status(403).json({ error: 'Forbidden' })
}
} else {
res.status(404).json({ error: 'Not found' })
}
} catch (error) {
console.log('Error publishing track', error)
res.status(500).json({ error: 'Something went wrong' })
}
}The code works. It is fine right up until the first question someone asks of it.
What this costs
| Line | GRAIN rule | What breaks |
|---|---|---|
utils/trackUtils.ts | file roles | In six months five unrelated functions will live here, and the file can be neither deleted nor understood |
handleTrackPublish | verb lexicon | handle states no action. The function publishes — so that is its name |
export default | named exports | The other end of the import can call it anything; searching the codebase stops finding callers |
req: any, res: any | no any | The compiler is switched off exactly where data arrives from outside |
const data = req.body | empty words | data says nothing about its contents. It is a track draft — draft |
track.duration > 30 | units and magic numbers | Seconds or milliseconds? In a year nobody remembers, and the mistake surfaces once, in production |
Date.now() inside the domain | boundaries | The "can be published" rule cannot be tested without faking system time |
// Check if user is authorized | comments | A restatement of the next line. It will go stale before the code and start lying |
console.log('✅ …') | logs | Emoji in a log that grep and alerting will read |
catch → console.log + 500 | failure model | Every error becomes "something went wrong". There is nothing left to investigate |
try/if/if/if nesting | shape | Understanding the success condition means holding four levels in your head |
'Unauthorized', 'Not found' | failure codes | The client has to parse English prose instead of reading a code |
| SQL inside the handler | file roles | The database schema is now known to the HTTP layer; renaming a column breaks the endpoint |
Thirteen items — and not one of them is "ugly". Each is concrete future work for whoever opens the file after the author.
How it looks in GRAIN
One responsibility per file. The domain in the middle, boundaries at the edges.
track.shape.ts — the shape of the data, nothing executable
export type Track = {
id: string
ownerId: string
durationSec: number
coverUrl: string | null
publishedAt: Date | null
}
export type Fail<Code extends string> = { isOk: false; code: Code; message: string }
export type Ok<Value> = { isOk: true; value: Value }
export type Outcome<Value, Code extends string> = Ok<Value> | Fail<Code>
export function makeFail<Code extends string>(code: Code, message: string): Fail<Code> {
return { isOk: false, code, message }
}track.policy.ts — the rules. Pure, no I/O, no clock
import type { Track } from './track.shape'
const MIN_PUBLISHABLE_DURATION_SEC = 30
export function canPublish(track: Track, actorId: string, isAdmin: boolean): boolean {
return track.ownerId === actorId || isAdmin
}
export function isReadyToPublish(track: Track): boolean {
return track.durationSec >= MIN_PUBLISHABLE_DURATION_SEC && track.coverUrl !== null
}Two rules, two lines of logic, zero dependencies. Testing them needs no database, no network and no faked clock — and takes a minute to write.
track.pure.ts — computing the new state
import type { Track } from './track.shape'
export function derivePublishedTrack(track: Track, nowMs: number): Track {
return { ...track, publishedAt: new Date(nowMs) }
}Time arrived as an argument. The function can be called with any moment and checked — including a day boundary, a leap year, and a timezone none of the authors live in.
track.store.ts — the only place with SQL
import type { Track } from './track.shape'
export async function findTrack(trackId: string): Promise<Track | null> {
const rows = await db.query<Track>('select * from tracks where id = $1', [trackId])
return rows[0] ?? null
}
export async function saveTrack(track: Track): Promise<void> {
await db.query('update tracks set published_at = $1, updated_at = $2 where id = $3', [
track.publishedAt,
new Date(),
track.id,
])
}findTrack returns Track | null — the contract is visible in the name, and the caller is obliged to handle both outcomes.
The action itself
import { canPublish, isReadyToPublish } from './track.policy'
import { derivePublishedTrack } from './track.pure'
import { findTrack, saveTrack } from './track.store'
import { makeFail, type Outcome, type Track } from './track.shape'
type PublishFail =
| 'music.track.missing'
| 'music.track.forbidden'
| 'music.track.incomplete'
export async function publishTrack(
trackId: string,
actor: { id: string; isAdmin: boolean },
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.id, actor.isAdmin)) {
return makeFail('music.track.forbidden', 'Only the owner may publish')
}
if (!isReadyToPublish(track)) {
return makeFail('music.track.incomplete', 'A cover and at least 30 seconds are required')
}
const published = derivePublishedTrack(track, nowMs)
await saveTrack(published)
return { isOk: true, value: published }
}The four beats of the funnel are visible: acquire → three guards → derive → effect. Nesting is one level. Every outcome is listed in PublishFail, so the caller cannot forget one — the compiler will not allow it.
track.route.ts — the boundary. Only HTTP ↔ domain translation
export async function routePublishTrack(request: Request): Promise<Response> {
const actor = getActor(request)
const outcome = await publishTrack(readTrackId(request), actor, Date.now())
if (outcome.isOk) return Response.json(outcome.value)
return Response.json({ code: outcome.code, message: outcome.message }, {
status: FAIL_STATUS[outcome.code],
})
}
const FAIL_STATUS: Record<PublishFail, number> = {
'music.track.missing': 404,
'music.track.forbidden': 403,
'music.track.incomplete': 422,
}Date.now() is called here — this is the boundary, and it is allowed to. The mapping from failure code to HTTP status is a table: add a new outcome and TypeScript will demand a new row.
What changed, in numbers
| The usual version | GRAIN | |
|---|---|---|
| Maximum nesting | 5 | 1 |
| Lines in the longest function | 34 | 18 |
| Places containing SQL | the HTTP handler itself | one dedicated file |
| What is testable without a database or mocks | nothing | the rules, the computation, the status mapping |
| Outcomes visible in the type | 0 | 3 |
| Lines you must read to learn the publish condition | all 34 | 4, in track.policy.ts |
There are now five files instead of one. That is a cost, and it deserves to be stated plainly. In exchange each file answers one question, and most questions about behaviour can be answered by opening exactly one of them.
What the machine catches
Of the thirteen items, the linter finds eleven without human involvement:
src/utils/trackUtils.ts
1 L1 no-dump-file "utils" is a dumping ground by definition; split it by roles
4 L1 comment-form untagged comment — or a restatement of the code
5 L1 verb-lexicon the verb "handle" is banned — name the action: publishTrack
5 L2 no-default-export named exports only
7 L2 no-vague-name "data" says nothing. Name it after the domain
9 L1 comment-form untagged comment — or a restatement of the code
21 L1 unit-suffix "duration" is a number without a unit (Sec, Ms, Bytes, …)
22 L2 boundary-effect Date.now() inside a policy file — pass it as an argument
28 L1 no-emoji-log emoji in a log. A log is read by grep, not by a person
35 L2 catch-must-act catch only logs and continues — the failure goes silent
12 L2 max-depth nesting 5, limit 2The remaining two — any in the signature and SQL in the handler — are caught by the compiler in strict mode and by the file role respectively.