One endpoint, before and after

Last updated: 2026-08-18An ordinary 34-line handler rewritten to GRAIN — thirteen violations with the price of each, before-and-after metrics, and what the linter says.

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.

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

LineGRAIN ruleWhat breaks
utils/trackUtils.tsfile rolesIn six months five unrelated functions will live here, and the file can be neither deleted nor understood
handleTrackPublishverb lexiconhandle states no action. The function publishes — so that is its name
export defaultnamed exportsThe other end of the import can call it anything; searching the codebase stops finding callers
req: any, res: anyno anyThe compiler is switched off exactly where data arrives from outside
const data = req.bodyempty wordsdata says nothing about its contents. It is a track draft — draft
track.duration > 30units and magic numbersSeconds or milliseconds? In a year nobody remembers, and the mistake surfaces once, in production
Date.now() inside the domainboundariesThe "can be published" rule cannot be tested without faking system time
// Check if user is authorizedcommentsA restatement of the next line. It will go stale before the code and start lying
console.log('✅ …')logsEmoji in a log that grep and alerting will read
catchconsole.log + 500failure modelEvery error becomes "something went wrong". There is nothing left to investigate
try/if/if/if nestingshapeUnderstanding the success condition means holding four levels in your head
'Unauthorized', 'Not found'failure codesThe client has to parse English prose instead of reading a code
SQL inside the handlerfile rolesThe 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

ts
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

ts
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

ts
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

ts
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

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

ts
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 versionGRAIN
Maximum nesting51
Lines in the longest function3418
Places containing SQLthe HTTP handler itselfone dedicated file
What is testable without a database or mocksnothingthe rules, the computation, the status mapping
Outcomes visible in the type03
Lines you must read to learn the publish conditionall 344, 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 2

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

One endpoint, before and after | AS Docs