Форма, границы, отказы
Воронка: четыре такта, всегда в этом порядке
Такты не перемешиваются. I/O внутри цикла внутри условия — не GRAIN. Если тактов оказалось два комплекта — это две функции.
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 resultreturn 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.ts | pure, shape |
*.policy.ts | pure, shape, policy |
*.shape.ts | shape |
*.store.ts · *.wire.ts · *.event.ts | shape, pure и свою же роль |
*.route.ts · *.rpc.ts · *.job.ts · *.entry.ts | всё — это границы, их работа знать обо всём |
Как только *.pure.ts импортировал *.store.ts, чистая часть перестала быть чистой, и её больше нельзя ни протестировать без базы, ни переиспользовать.
Независимые ожидания — параллельно
const track = await findTrack(trackId)
const genres = await listGenres()const [track, genres] = await Promise.all([
findTrack(trackId),
listGenres(),
])Последовательный await допустим ровно тогда, когда второй вызов использует результат первого. Всё остальное — водопад запросов, который на клиенте виден как «долго грузится».
Публичная поверхность — по требованию
По умолчанию всё приватно. Экспорт появляется, когда есть второй потребитель, а не «на будущее»: каждый преждевременный экспорт становится вечным API, который никто не решается тронуть.
Мёртвый код удаляется
Код без вызывающего удаляется, а не комментируется, не прячется за флаг и не остаётся «на всякий случай». Git помнит всё; закомментированный блок помнит только автор, и через месяц уже неточно.
Отказы
Два класса, середины нет.
Ожидаемый отказ — часть контракта: не найдено, нет прав, истёк токен, не хватает средств. Возвращается типизированным значением с кодом вида домен.предмет.причина:
auth.token.expired
music.track.missing
billing.card.declinedНеожиданный отказ — нарушенный инвариант, недоступная БД, баг. Бросается. Ловит только граница процесса, которая умеет это записать и вернуть 500.
type PublishFail = 'music.track.missing' | 'music.track.forbidden' | 'music.track.incomplete'Коды перечисляются в сигнатуре — вызывающий видит полный набор исходов, не читая тело, и компилятор не даст забыть про один из них.