Форма, границы, отказы

Last updated: 2026-08-18Воронка из четырёх тактов, пределы вложенности и длины, где живут время и случайность, направление зависимостей и две модели отказа.

Форма, границы, отказы

Воронка: четыре такта, всегда в этом порядке

guard
отсечь невозможное, выйти рано
acquire
добыть данные (I/O)
derive
посчитать, чисто
effect
записать, отправить, вернуть

Такты не перемешиваются. I/O внутри цикла внутри условия — не GRAIN. Если тактов оказалось два комплекта — это две функции.

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', 'Трек не найден')
  if (!canPublish(track, actor)) return makeFail('music.track.forbidden', 'Нет прав')

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

Числа

ПределЗначениеЧто означает превышение
Вложенность2Внутренний блок — отдельная функция
Аргументов3Дальше — один именованный объект
Строк в функции40Функция делает больше одного дела
Строк в файле400В файле больше одной роли
Глубина каталогов от src/3Иерархия глубже никем не читается

Это не эстетика, а сигнализация. Превышение — не «плохо написано», а «здесь спрятано второе дело».

Правила формы

  • else после return не пишется. Guard выходит, продолжение идёт левее.
  • Вложенных тернарных операторов нет — это switch или таблица.
  • Экспорт только именованный. Имя сущности одинаково на обоих концах импорта, иначе поиск по коду перестаёт работать.
  • Переменная, живущая одну строку до return, не нужна.
Как обычно
const result = await loadTrack(id)
return result
По GRAIN
return await loadTrack(id)

Границы

Правила этой главы — про то, что чаще всего делает код невозможным для тестирования и непредсказуемым в проде.

Время, случайность и окружение — только на границе

Date.now(), new Date(), Math.random(), crypto.randomUUID(), process.env живут в файлах-границах: *.entry.ts, *.route.ts, *.rpc.ts, *.job.ts, *.wire.ts. В домен они приходят аргументом.

Зависит от часов машины
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()
}

Цена нарушения конкретна: тест на «истёкший триал» приходится писать через подмену системного времени, ошибка часового пояса всплывает только в проде, а баг, случившийся ночью, невозможно воспроизвести днём.

Зависимости направлены внутрь

Домен не знает, кто его вызывает и через что ходит наружу. Роль файла определяет, что ему разрешено импортировать:

РольМожет импортировать
*.pure.tspure, shape
*.policy.tspure, shape, policy
*.shape.tsshape
*.store.ts · *.wire.ts · *.event.tsshape, pure и свою же роль
*.route.ts · *.rpc.ts · *.job.ts · *.entry.tsвсё — это границы, их работа знать обо всём

Как только *.pure.ts импортировал *.store.ts, чистая часть перестала быть чистой, и её больше нельзя ни протестировать без базы, ни переиспользовать.

Независимые ожидания — параллельно

180 мс вместо 90
const track = await findTrack(trackId)
const genres = await listGenres()
По GRAIN
const [track, genres] = await Promise.all([
  findTrack(trackId),
  listGenres(),
])

Последовательный await допустим ровно тогда, когда второй вызов использует результат первого. Всё остальное — водопад запросов, который на клиенте виден как «долго грузится».

Публичная поверхность — по требованию

По умолчанию всё приватно. Экспорт появляется, когда есть второй потребитель, а не «на будущее»: каждый преждевременный экспорт становится вечным API, который никто не решается тронуть.

Мёртвый код удаляется

Код без вызывающего удаляется, а не комментируется, не прячется за флаг и не остаётся «на всякий случай». Git помнит всё; закомментированный блок помнит только автор, и через месяц уже неточно.


Отказы

Два класса, середины нет.

Ожидаемый отказ — часть контракта: не найдено, нет прав, истёк токен, не хватает средств. Возвращается типизированным значением с кодом вида домен.предмет.причина:

auth.token.expired
music.track.missing
billing.card.declined

Неожиданный отказ — нарушенный инвариант, недоступная БД, баг. Бросается. Ловит только граница процесса, которая умеет это записать и вернуть 500.

ts
type PublishFail = 'music.track.missing' | 'music.track.forbidden' | 'music.track.incomplete'

Коды перечисляются в сигнатуре — вызывающий видит полный набор исходов, не читая тело, и компилятор не даст забыть про один из них.

Форма, границы, отказы | AS Docs