Один эндпоинт до и после

Last updated: 2026-08-18Обычный обработчик на 34 строки, переписанный по GRAIN — тринадцать нарушений с ценой каждого, метрики до и после, вывод линтера.

Разбор: один эндпоинт до и после GRAIN

Самый честный способ проверить стандарт — взять код, который пишут все, и переписать его по правилам, считая цену каждого изменения. Задача простая и настоящая: опубликовать трек. Проверить права, проверить готовность, проставить дату публикации, вернуть результат.

Ничего в поведении не изменится. Изменится то, сколько нужно держать в голове, чтобы это поведение проверить.


Как это выглядит обычно

Один файл, одна функция, всё внутри. Так пишет и новичок, и языковая модель без стандарта — потому что это самый вероятный способ, а не самый лучший.

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' })
  }
}

Код работает. С ним всё в порядке ровно до первого вопроса, который ему зададут.

Что здесь стоит денег

СтрокаПравило GRAINЧто сломается
utils/trackUtils.tsроли файловЧерез полгода здесь окажется ещё пять несвязанных функций, и файл нельзя будет ни удалить, ни понять
handleTrackPublishсловарь глаголовhandle не сообщает действия. Функция публикует — так и называется
export defaultименованные экспортыНа другом конце импорта имя можно написать любое; поиск по коду перестаёт находить вызовы
req: any, res: anyнет anyКомпилятор выключен ровно там, где данные приходят снаружи
const data = req.bodyпустые словаdata не сообщает, что внутри. Это черновик трека — draft
track.duration > 30единицы + магические числаСекунды или миллисекунды? Через год никто не вспомнит, а ошибка проявится один раз на проде
Date.now() внутри доменаграницыПравило «опубликовать можно» невозможно протестировать, не подменяя системное время
// Check if user is authorizedкомментарииПересказ следующей строки. Он устареет раньше кода и начнёт врать
console.log('✅ …')логиЭмодзи в логе, который будут читать grep и алерты
catchconsole.log + 500модель отказовЛюбая ошибка превращается в «что-то пошло не так». Расследовать нечего
вложенность try/if/if/ifформаЧтобы понять условие успеха, нужно удержать в голове четыре уровня
'Unauthorized', 'Not found'коды отказаКлиент вынужден разбирать текст на английском вместо кода
SQL прямо в обработчикероли файловСхема БД теперь известна HTTP-слою; смена колонки ломает эндпоинт

Тринадцать пунктов — и ни один из них не «некрасиво». Каждый — конкретная будущая работа для того, кто откроет файл после автора.


Как это выглядит по GRAIN

Одна ответственность на файл. Домен посередине, границы по краям.

track.shape.ts — форма данных, ничего исполняемого

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 — правила. Чисто, без I/O, без времени

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
}

Два правила, две строки логики, ноль зависимостей. Тест на них не требует ни базы, ни сети, ни подмены времени — и пишется за минуту.

track.pure.ts — вычисление нового состояния

ts
import type { Track } from './track.shape'

export function derivePublishedTrack(track: Track, nowMs: number): Track {
  return { ...track, publishedAt: new Date(nowMs) }
}

Время пришло аргументом. Функцию можно вызвать с любым моментом и проверить результат — включая границу суток, високосный год и часовой пояс, в котором никто из авторов не живёт.

track.store.ts — единственное место, где есть 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 возвращает Track | null — контракт виден в имени, вызывающий обязан разобрать оба исхода.

track.policy.ts + track.pure.ts собираются в действие

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', 'Трек не найден')
  if (!canPublish(track, actor.id, actor.isAdmin)) {
    return makeFail('music.track.forbidden', 'Публиковать может только владелец')
  }
  if (!isReadyToPublish(track)) {
    return makeFail('music.track.incomplete', 'Нужны обложка и длительность от 30 секунд')
  }

  const published = derivePublishedTrack(track, nowMs)
  await saveTrack(published)
  return { isOk: true, value: published }
}

Четыре такта воронки видны глазом: acquire → три guardderiveeffect. Вложенность — один уровень. Все исходы перечислены в типе PublishFail, поэтому вызывающий не может забыть про один из них — компилятор не даст.

track.route.ts — граница. Только перевод HTTP ↔ домен

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() вызывается здесь — это граница, ей можно. Соответствие кода отказа HTTP-статусу задано таблицей: добавится новый исход — TypeScript потребует добавить строку.


Что изменилось в цифрах

Обычный вариантПо GRAIN
Максимальная вложенность51
Строк в самой длинной функции3418
Мест, где есть SQL1 из 1 файла — он же HTTP-слой1 выделенный файл
Что тестируется без базы и без моковничегоправила, вычисление, соответствие статусов
Исходов, видимых в типе03
Строк, которые нужно прочитать, чтобы узнать условие публикациивсе 344 в track.policy.ts

Файлов стало больше — пять вместо одного. Это цена, и её стоит назвать честно. Взамен каждый файл отвечает на один вопрос, и на большинство вопросов о поведении можно ответить, открыв ровно один из них.


Что из этого поймает машина

Из тринадцати пунктов линтер ловит одиннадцать без участия человека:

src/utils/trackUtils.ts
   1  L1  no-dump-file      "utils" — свалка по определению; разложи по ролям
   4  L1  comment-form      комментарий без тега — или это пересказ кода
   5  L1  verb-lexicon      глагол "handle" запрещён — назови действие: publishTrack
   5  L2  no-default-export только именованные экспорты
   7  L2  no-vague-name     "data" ничего не сообщает. Назови по домену
   9  L1  comment-form      комментарий без тега — или это пересказ кода
  21  L1  unit-suffix       "duration" — число без единицы (Sec, Ms, Bytes, …)
  22  L2  boundary-effect   Date.now() внутри policy-файла — приходит аргументом
  28  L1  no-emoji-log      эмодзи в логе. Лог читает grep, а не человек
  35  L2  catch-must-act    catch только логирует и продолжает — отказ уходит в тишину
  12  L2  max-depth         вложенность 5 при пределе 2

Оставшиеся два — any в сигнатуре и SQL в обработчике — ловятся компилятором в strict-режиме и ролью файла соответственно.

Дальше: глоссарий · обзор стандарта · как включить проверку

Один эндпоинт до и после | AS Docs