Проект: контракты, схема, границы

проект≈ 15 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Часть дошла до проекта: каркас, который отвечает 200, 404 и 500, валидатор границы, таблица с уникальностью и токен с настоящей подписью — всё это уже написано вашими руками, и рядом разобрано, где то же самое лежит в настоящем Nest. Осталось сложить части в одно приложение: API учебника — учётные записи читателей и их заметки. Новых механизмов в этой главе нет, есть порядок, в котором механизмы складывают. Соблазн открыть редактор и написать первый контроллер силён — и именно он через три недели делает проект неремонтопригодным: договор оказывается написан случайно, в подписи обработчика, схема выведена из первого запроса, а границы модулей встали там, куда попали импорты. Поэтому в этой статье не будет ни одного обработчика. Будут три документа, которые пишутся раньше, — договор, схема и карта границ, — и первая миграция, которая ложится в базу до того, как приложение ответит хоть на один запрос.

Разделы темы:·

Проект главы — API самого учебника, с тем же доменом, что у боевого apps/api: читатели и их заметки. Ничего нового вы здесь не напишете. Контейнер, конвейер, валидатор, таблица, токен и проверка личности лежат готовыми; глава соединяет их и ставит рядом ту же работу на настоящем Nest, Drizzle и Postgres. Ценность её не в новом коде, а в форме: закончив главу, вы получите раскладку, которую можно перенести на свой домен, поменяв слова.

Начинать принято с обработчика — он самый осязаемый: два декоратора, return, и в браузере уже что-то видно. Плата приходит позже и не выглядит связанной с началом. Договор с клиентом никто не писал, но он появился: это подпись обработчика и то, что JSON.stringify сделал с возвращённым объектом. Схема таблиц выросла из первого запроса и потому не держит ни одного правила. Границы модулей встали по импортам, а импорт — не решение, а следствие того, что файл оказался рядом.

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

РешениеГде записаноЧем дорога поздняя правка
Какие есть процедуры и что они принимаютдоговор: схемы входа и выхода, общие для сервера и клиентову договора два конца провода, и телефон на старом бандле остаётся в строю после выката сервера
Что и как хранится, какие правила держит базасхема таблиц и файлы миграциймомент, когда старый код работает на новой схеме, случается при каждом выкате, который схему меняет
Кто кого видит: провайдеры, экспорт, ввозкарта границ — три списка в каждом модулепереезд класса тянет за собой все места, где он оказался виден; невидимый класс ломать нечем
Как именно отвечает обработчиксервис и роутер разделаничем: снаружи не видно, как внутри, — единственный из четырёх, который правится свободно

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

Порядок решений и порядок написания кода — разные вещи. Код пишется в любом порядке; решение, принятое вторым, уже не свободно.

Схему как значение вы уже писали: тип входа выводится из самой схемы, и status в нём сужен до двух литералов, потому что так написано в схеме. Остался один шаг — собрать схемы всех процедур в одно место и дать этому месту имя. Это и есть договор.

Устроен он до скуки просто: объект, у которого ключ — имя процедуры, а значение — её схема входа и уровень доступа. Читателей у такого объекта несколько, и каждый берёт своё. Разбор тела берёт схему. Компилятор берёт из неё тип входа и раздаёт его и обработчику, и клиенту; без общей записи у клиента появляется своя — ровно как интерфейс ответа, написанный второй раз, который расходится с обработчиком молча. Описание API берёт имена и поля — тем же обходом, которым сборка ищет обработчики. Транспорт берёт уровень доступа.

Прогноз: шесть строк. Первая — имена процедур в том порядке, в каком договор их называет. Дальше договор разбирает три тела: правильное создание заметки, у которого есть лишнее поле authorId, испорченное и правильный запрос списка. Что попадёт в разобранное значение первого тела, сколько причин отказа найдётся у второго — и что ответит разбор на заметке, которую компилятор принять отказался.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Лишнее поле authorId в разобранном значении не появилось, и это не мелочь: обработчик получает ровно то, что перечислено в схеме, и подписать заметку чужим автором через тело запроса физически невозможно. Отказ второго тела назвал обе причины сразу и с адресами полей — форме этого достаточно, чтобы подсветить оба ввода. Причём путь в адресе начинается с имени процедуры: notes.create.title читается как «поле title входа notes.create», и в журнале такая строка понятна без догадок.

Последние две строки — про две стороны одной записи. Тип Input<"notes.create"> компилятор вывел из той же схемы и значение status: "archived" не принял; разбор той же схемы на том же значении отвечает false. Запись одна — расходиться проверке и типу нечем.

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

В проекте главы схемы написаны вашим валидатором из главы 6: глава ничего не вводит заново. В боевом API на этом месте zod, и в устройстве это ничего не меняет — разбор обращается к схеме через одну условленную дырочку, и имя библиотеки знает одно место. Единственную зависимость, которую пакету договора позволено иметь, zod ровно и занимает, — поэтому выбор между ним и своим валидатором не двигает ни одной границы: договор по-прежнему уезжает в телефон целиком и документацией вместо кода не становится.

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

У договора две половины, и вторую забывают чаще. Вход описан схемой, потому что ему не доверяют; выход описывать вроде бы незачем — его составляет свой же код. Беда в том, что составляет он его из строк таблицы, а в строке лежит всё, что в ней хранится. Достаточно одного return row в обработчике карточки и одного соединения с автором, чтобы наружу уехал хеш пароля.

Белый список на выходе вы уже ставили — перехватчиком, который собирает ответ из перечисленных полей, а не вырезает известные секретные. Новое здесь в том, что перечень перестаёт быть отдельным списком в усилителе: он становится второй половиной договора — той же схемой, из которой читается тип ответа, — и лежит рядом со схемой входа, а не в другом файле.

Прогноз: четыре строки. Сначала ответ собран как попало — строка заметки со вложенной строкой автора, и печатаются имена полей верхнего уровня и имена полей автора. Затем тот же ответ проходит через схему выхода, в которой перечислены четыре поля. Что от него останется, что ответит проверка "body" in out — и чем кончится попытка прогнать через ту же схему заметку без автора.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Первая строка — цена одной склейки: семь ключей верхнего уровня, то есть шесть полей заметки и вложенный автор, плюс пять полей самого автора, среди них passwordHash. Ни одно из них не названо в договоре, и ни в одном обработчике нет строки, которая бы их отдавала: они уехали потому, что лежали в объекте. После схемы остаются четыре перечисленных поля — object собирает новый объект и лишнего в него не переносит. Тело заметки не уехало ни в рантайме, ни в типе: компилятор на out.body отказал, а "body" in out отвечает false.

Четвёртая строка — то, чем схема выхода отличается от списка полей в комментарии. Сборщик ответа забыл author, и это стало отказом с адресом поля на сервере, а не пустой подписью под карточкой у читателя. Схема выхода — не документация, а сито, и ловит оно обе беды: лишнее не пропускает, недостающее называет.

В боевом API учебника то же сито стоит ещё раньше — в самом запросе. Репозиторий там одна дверь к строкам, и набор колонок ответа объявлен в нём один раз, сразу на список и на карточку:

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Из девяти колонок три взяты у автора, и passwordHash среди них нет — база его даже не читает. Схему выхода это не отменяет: перечисление колонок легко потерять при правке запроса, а схема стоит на границе и срабатывает на каждом ответе. Две линии обороны здесь не роскошь, а разделение обязанностей: запрос решает, что достать, договор — что отдать.

Список полей в комментарии обещает; схема выхода отвечает. Разница видна в первый же раз, когда сборщик ответа забывает поле.

Схема таблиц похожа на договор до обмана: те же слова, почти те же поля. Вывести одну из другой нельзя, и разобрано это подробно — таблица допускает то, чего API не принимает, и наоборот. Для порядка работ отсюда следует вот что: раз документов два, то и вопросов два. Договор отвечает «что нам позволено принять», схема — «что обязано остаться верным всегда, даже если весь код неправ».

Второй список короче и злее. Уникальность почты — не проверка перед вставкой: два одновременных запроса оба увидят, что такой почты нет. Ссылка на автора — не if в сервисе: заметка, оставшаяся без автора, не сломает ни одного теста, пока кто-нибудь не откроет список. Список статусов — не константа в коде: строку в колонку положит и миграция, и psql, и сосед с правами на запись. Правило живёт в таблице, а не в обработчике именно потому, что вторая дверь к таблице всегда пишется позже первой.

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

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

прогон под базой PostgreSQL 18.4 · node 26.5.0 · 29 сентября 2026
Схема проекта, сгенерированный из неё SQL и семь попыток записи в свежую базу
import { sql } from 'drizzle-orm';
import { check, index, pgEnum, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';

/*
 * Схема проекта: две таблицы и всё, что база обязана держать сама.
 * Списки значений те же, что в контракте, — но здесь у них ещё и тип в базе.
 */
export const userRole = pgEnum('user_role', ['user', 'admin']);
export const noteStatus = pgEnum('note_status', ['draft', 'published']);

export const users = pgTable(
  'users',
  {
    id: uuid().primaryKey().defaultRandom(),
    // нижний регистр нормализует сервис при регистрации и входе
    email: text().notNull().unique(),
    passwordHash: text('password_hash').notNull(),
    name: text().notNull(),
    role: userRole().notNull().default('user'),
    createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
  },
  (table) => [check('users_email_lowercase', sql`${table.email} = lower(${table.email})`)],
);

export const notes = pgTable(
  'notes',
  {
    id: uuid().primaryKey().defaultRandom(),
    authorId: uuid('author_id')
      .notNull()
      .references(() => users.id, { onDelete: 'cascade' }),
    title: text().notNull(),
    body: text().notNull().default(''),
    status: noteStatus().notNull().default('draft'),
    createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
  },
  (table) => [
    index('notes_author_idx').on(table.authorId),
    check('notes_title_not_blank', sql`length(btrim(${table.title})) > 0`),
  ],
);
node up.ts

Принято две записи из семи — первая и последняя. Пять отказов пришли кодами SQLSTATE: 23505 на повторе почты, 23514 на заглавных буквах и на заголовке из пробелов, 23503 на неизвестном авторе. У этих четырёх есть constraint — имя сработавшего правила, и по нему код отличает «почта занята» от «заголовок пустой», не читая текст ошибки. А у пятого отказа имени нет: 22P02 — это неверное значение для типа, а не нарушение ограничения, и искать в нём имя правила нечего. Перевод отказа базы в доменный код смотрит как раз на имя ограничения — значит, этот отказ через него не пройдёт и станет пятисоткой. Так и надо: статус вне списка схема входа не пропустит, и прийти он может только второй дверью, а это ошибка, а не часть договора.

Заметьте и то, чего в выводе нет: ни одной строки от приложения. Миграция — первый артефакт проекта, и появляется он раньше кода: generate прочитал описание и положил рядом шаг, migrate применил его и оставил отметку в самой базе. Дальше любая правка схемы будет вторым шагом, третьим, десятым — и каждый уедет на боевой стенд отдельным выпуском.

В договоре заголовок ограничивают, а в колонке стоит text без длины, и это не забывчивость. Предел ввода и предел хранения меняются по разным причинам. Попросят заголовки подлиннее — правится договор: один файл и выпуск клиента. А сужение колонки на живой базе — отдельная миграция, и бесплатной она не бывает. В самой колонке остаётся check ровно на то, чего не должно случиться никогда: заголовок из одних пробелов бессмыслен при любом пределе. Правило простое — в базу уходит то, что делает данные бессмысленными, а не то, что делает ввод неудобным.

Договор говорит, что вы согласны принять. Схема говорит, что останется верным, даже если весь код неправ. Это разные вопросы, и одним документом на них не ответить.

Третий документ — карта: какие есть модули и что каждый отдаёт наружу. У проекта их четыре, и три очевидны: common с таблицами и доменными кодами отказов, auth с личностью, notes с самим доменом. Четвёртый — корень, который не делает ничего сам и только перечисляет остальные.

МодульЧто внутриЧто отдаёт наружуЧто ввозит
commonтаблицы, доменные коды отказов, перевод кода в ответтаблицы и коды: их просят всеничего
authхеш пароля, выпуск и проверка токена, страж личностиличность — кто пришёл и какая у него рольcommon
notesрепозиторий, сервис, процедуры доменаничего: снаружи в заметки никто не ходитcommon, auth
кореньничегоничеговсе три и транспорт

Направление здесь — главное решение, и оно одно на весь проект: notes знает про auth, auth про notes не знает. Соблазн развернуть стрелку появляется на первой же задаче вида «показать, сколько у читателя заметок»: профиль ведь лежит в auth. Стоит завести там эту зависимость — и модули склеиваются, а зависимость, которая ищется среди своих провайдеров и в экспортах ввезённых модулей, начинает находиться слишком часто. Правило простое: счёт заметок отдаёт notes, а профиль его запрашивает — или не запрашивает вовсе, и клиент делает два вызова.

common ввозят оба домена — именно ввозят: копия класса в providers обоих дала бы два объекта вместо одного, и это вы уже проходили дважды. В настоящем Nest тот же вопрос решают пометкой над классом вместо импорта в каждом модуле приложения — и боевой API учебника объявляет базу именно так.

Последняя граница в коде не видна вовсе — это уровень доступа. Он назван в договоре, и транспорт читает его, не спрашивая обработчик. Прогноз: пять строк. Три человека — гость, обычный читатель и админ — против трёх процедур разных уровней. Кому что достанется, и что скажут последние две строки про notes.byId.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Матрица собралась целиком из одного поля договора: девять ответов и ни одного if в обработчике. Но последние две строки показывают предел приёма. Договор разрешил Ане notes.byId — он и не мог отказать, строки у него нет. А в строке автор чужой и статус draft: чужой черновик отвечает 404, чужая опубликованная — 403, и решается это только там, где строка уже прочитана. Уровень доступа — граница транспорта, владение — правило домена, и держать их в одном месте не стоит именно потому, что срабатывают они в разные моменты.

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

Карта границ пишется до кода потому, что после кода она уже написана — импортами, и не вами.

  • Сервер начинают не с обработчика, а с трёх документов: договор процедур, схема таблиц и карта границ. Обработчик правится свободно, а эти три — вместе с клиентами, с миграциями и со всеми местами, где класс оказался виден.
  • Договор — обычный объект: имя процедуры, схема входа, уровень доступа. Из него читают все — разбор, компилятор, описание API и клиент, — и потому второй записи одного правила не появляется.
  • Выход описывают схемой так же, как вход. Без неё ответ собирается из строк таблицы, и первое же соединение с автором уносит наружу passwordHash; схема выхода ловит и лишнее поле, и забытое.
  • Схема таблиц из договора не выводится: договор говорит, что сервер согласен принять, схема — что обязано остаться верным всегда. Уникальность, ссылку на автора и список статусов держит база, потому что вторая дверь к таблице всегда пишется позже первой.
  • Первая миграция ложится в базу раньше первого обработчика. В прогоне свежая база приняла две записи из семи: четыре отказа пришли с именем нарушенного правила, а неверное значение для типа (22P02) — без него.
  • Уровень доступа — свойство процедуры и потому живёт в договоре; владение строкой договором не выражается вовсе и решается там, где строка прочитана. Границы модулей однонаправленны: notes знает про auth, обратно — нет.

1/4Почему схему таблиц не выводят из схем договора?

2/4В ответе карточки оказался passwordHash автора. Где искать причину?

3/4Что из перечисленного обязано лежать в схеме таблиц, а не в коде сервиса?

4/4Почему уровень доступа процедуры записан в договоре, а владение строкой — нет?

Процедура, которой нет в договоре

Имя процедуры приходит строкой из адреса запроса, и компилятор его не видел: типы кончаются на границе. Сейчас на неизвестном имени разбор падает, а падение в этом месте означает 500 вместо внятного отказа. Сделайте неизвестное имя отказом договора, не меняя ни схем, ни цикла.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Договор — обычный объект, и спросить его о ключе можно до обращения:

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

После правки третья строка становится «notes.publish → notes.publish: такой процедуры в договоре нет», а первые две не меняются. Object.hasOwn здесь важнее, чем name in contract: у in есть прототип, и процедура с именем toString или constructor нашлась бы в договоре, которого её никто не вписывал.

Приведение name as Name после проверки перестаёт быть обманом компилятора: до неё оно утверждало то, чего никто не проверял, а теперь — то, что проверено строкой выше.

Предел заголовка в договоре

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

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Обёртка — такая же схема: она зовёт вложенную и смотрит на её результат.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

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

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

Куда записать правило

Пять обещаний проекта. Для каждого решите, где оно записано: в договоре, в схеме таблиц или в обоих местах — и почему. 1) Заголовок заметки не длиннее 200 символов. 2) У заметки всегда есть автор, и этот автор существует. 3) Почта учётной записи хранится в нижнем регистре. 4) Статус заметки — draft или published. 5) Страница списка не больше 50 записей.

  • Заголовок до 200 символов — только договор. Предел ввода меняется вместе с клиентом, а данные длинным заголовком бессмысленными не становятся. В колонке остаётся text и check на пустоту.
  • Автор есть и существует — только схема. Во входе authorId нет вовсе: автор берётся из токена, иначе заметку можно было бы подписать чужим именем. А ссылку на существующего автора кодом не удержать — этим занимается внешний ключ.
  • Почта в нижнем регистре — в обоих. Приводит к нижнему регистру сервис (он же нормализует почту на входе), а check в колонке следит, чтобы мимо него ничего не прошло: вторая дверь к таблице всегда появится.
  • Статус из двух значений — в обоих. В договоре это oneOf, который сужает и тип; в базе — перечисление. Первое даёт внятный отказ клиенту, второе не пускает третье значение, откуда бы оно ни пришло.
  • Страница до 50 записей — только договор. Это вообще не про хранение, а про то, сколько мы согласны отдать за один запрос; потолок стоит в схеме входа и правится вместе с ней.

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