Проект: контракты, схема, границы
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Часть дошла до проекта: каркас, который отвечает 200, 404 и 500, валидатор границы, таблица с уникальностью и токен с настоящей подписью — всё это уже написано вашими руками, и рядом разобрано, где то же самое лежит в настоящем Nest. Осталось сложить части в одно приложение: API учебника — учётные записи читателей и их заметки. Новых механизмов в этой главе нет, есть порядок, в котором механизмы складывают. Соблазн открыть редактор и написать первый контроллер силён — и именно он через три недели делает проект неремонтопригодным: договор оказывается написан случайно, в подписи обработчика, схема выведена из первого запроса, а границы модулей встали там, куда попали импорты. Поэтому в этой статье не будет ни одного обработчика. Будут три документа, которые пишутся раньше, — договор, схема и карта границ, — и первая миграция, которая ложится в базу до того, как приложение ответит хоть на один запрос.
Проект главы — API самого учебника, с тем же доменом, что у боевого apps/api: читатели и их заметки. Ничего нового вы здесь не напишете. Контейнер, конвейер, валидатор, таблица, токен и проверка личности лежат готовыми; глава соединяет их и ставит рядом ту же работу на настоящем Nest, Drizzle и Postgres. Ценность её не в новом коде, а в форме: закончив главу, вы получите раскладку, которую можно перенести на свой домен, поменяв слова.
Начинать принято с обработчика — он самый осязаемый: два декоратора, return, и в браузере уже что-то видно. Плата приходит позже и не выглядит связанной с началом. Договор с клиентом никто не писал, но он появился: это подпись обработчика и то, что JSON.stringify сделал с возвращённым объектом. Схема таблиц выросла из первого запроса и потому не держит ни одного правила. Границы модулей встали по импортам, а импорт — не решение, а следствие того, что файл оказался рядом.
Поэтому первыми пишут три документа. Договор: какие есть процедуры, что каждая принимает, что отдаёт и кому открыта. Схема: какие таблицы, какие колонки и какие правила база держит сама. Карта границ: какие есть модули, что каждый отдаёт наружу и что ввозит. Все три короткие — вместе они меньше одного обработчика. Дорого в них другое: ни один потом не переписывается в одиночку.
| Решение | Где записано | Чем дорога поздняя правка |
|---|---|---|
| Какие есть процедуры и что они принимают | договор: схемы входа и выхода, общие для сервера и клиентов | у договора два конца провода, и телефон на старом бандле остаётся в строю после выката сервера |
| Что и как хранится, какие правила держит база | схема таблиц и файлы миграций | момент, когда старый код работает на новой схеме, случается при каждом выкате, который схему меняет |
| Кто кого видит: провайдеры, экспорт, ввоз | карта границ — три списка в каждом модуле | переезд класса тянет за собой все места, где он оказался виден; невидимый класс ломать нечем |
| Как именно отвечает обработчик | сервис и роутер раздела | ничем: снаружи не видно, как внутри, — единственный из четырёх, который правится свободно |
Обработчики от этого не пропадают, они просто пишутся четвёртыми, и следующая статья главы занимается только ими. Здесь же — первые три документа, и каждый в своём разделе.
Порядок решений и порядок написания кода — разные вещи. Код пишется в любом порядке; решение, принятое вторым, уже не свободно.
Схему как значение вы уже писали: тип входа выводится из самой схемы, и status в нём сужен до двух литералов, потому что так написано в схеме. Остался один шаг — собрать схемы всех процедур в одно место и дать этому месту имя. Это и есть договор.
Устроен он до скуки просто: объект, у которого ключ — имя процедуры, а значение — её схема входа и уровень доступа. Читателей у такого объекта несколько, и каждый берёт своё. Разбор тела берёт схему. Компилятор берёт из неё тип входа и раздаёт его и обработчику, и клиенту; без общей записи у клиента появляется своя — ровно как интерфейс ответа, написанный второй раз, который расходится с обработчиком молча. Описание API берёт имена и поля — тем же обходом, которым сборка ищет обработчики. Транспорт берёт уровень доступа.
Прогноз: шесть строк. Первая — имена процедур в том порядке, в каком договор их называет. Дальше договор разбирает три тела: правильное создание заметки, у которого есть лишнее поле authorId, испорченное и правильный запрос списка. Что попадёт в разобранное значение первого тела, сколько причин отказа найдётся у второго — и что ответит разбор на заметке, которую компилятор принять отказался.
Лишнее поле authorId в разобранном значении не появилось, и это не мелочь: обработчик получает ровно то, что перечислено в схеме, и подписать заметку чужим автором через тело запроса физически невозможно. Отказ второго тела назвал обе причины сразу и с адресами полей — форме этого достаточно, чтобы подсветить оба ввода. Причём путь в адресе начинается с имени процедуры: notes.create.title читается как «поле title входа notes.create», и в журнале такая строка понятна без догадок.
Последние две строки — про две стороны одной записи. Тип Input<"notes.create"> компилятор вывел из той же схемы и значение status: "archived" не принял; разбор той же схемы на том же значении отвечает false. Запись одна — расходиться проверке и типу нечем.
Поле access в договоре тоже не украшение. Уровень доступа — свойство процедуры, а не её реализации: «список открыт гостю, создание — только вошедшему» решается в договоре и там же читается клиентом, который прячет кнопку. Проверять его будет страж, ищущий личность на каждом запросе, а третья статья главы доведёт это до конца — вместе с ролями и пределом частоты.
В проекте главы схемы написаны вашим валидатором из главы 6: глава ничего не вводит заново. В боевом API на этом месте zod, и в устройстве это ничего не меняет — разбор обращается к схеме через одну условленную дырочку, и имя библиотеки знает одно место. Единственную зависимость, которую пакету договора позволено иметь, zod ровно и занимает, — поэтому выбор между ним и своим валидатором не двигает ни одной границы: договор по-прежнему уезжает в телефон целиком и документацией вместо кода не становится.
Договор — это объект, а не уговор в переписке. То, чего нельзя прочитать кодом, расходится с кодом молча.
У договора две половины, и вторую забывают чаще. Вход описан схемой, потому что ему не доверяют; выход описывать вроде бы незачем — его составляет свой же код. Беда в том, что составляет он его из строк таблицы, а в строке лежит всё, что в ней хранится. Достаточно одного return row в обработчике карточки и одного соединения с автором, чтобы наружу уехал хеш пароля.
Белый список на выходе вы уже ставили — перехватчиком, который собирает ответ из перечисленных полей, а не вырезает известные секретные. Новое здесь в том, что перечень перестаёт быть отдельным списком в усилителе: он становится второй половиной договора — той же схемой, из которой читается тип ответа, — и лежит рядом со схемой входа, а не в другом файле.
Прогноз: четыре строки. Сначала ответ собран как попало — строка заметки со вложенной строкой автора, и печатаются имена полей верхнего уровня и имена полей автора. Затем тот же ответ проходит через схему выхода, в которой перечислены четыре поля. Что от него останется, что ответит проверка "body" in out — и чем кончится попытка прогнать через ту же схему заметку без автора.
Первая строка — цена одной склейки: семь ключей верхнего уровня, то есть шесть полей заметки и вложенный автор, плюс пять полей самого автора, среди них passwordHash. Ни одно из них не названо в договоре, и ни в одном обработчике нет строки, которая бы их отдавала: они уехали потому, что лежали в объекте. После схемы остаются четыре перечисленных поля — object собирает новый объект и лишнего в него не переносит. Тело заметки не уехало ни в рантайме, ни в типе: компилятор на out.body отказал, а "body" in out отвечает false.
Четвёртая строка — то, чем схема выхода отличается от списка полей в комментарии. Сборщик ответа забыл author, и это стало отказом с адресом поля на сервере, а не пустой подписью под карточкой у читателя. Схема выхода — не документация, а сито, и ловит оно обе беды: лишнее не пропускает, недостающее называет.
В боевом API учебника то же сито стоит ещё раньше — в самом запросе. Репозиторий там одна дверь к строкам, и набор колонок ответа объявлен в нём один раз, сразу на список и на карточку:
Из девяти колонок три взяты у автора, и passwordHash среди них нет — база его даже не читает. Схему выхода это не отменяет: перечисление колонок легко потерять при правке запроса, а схема стоит на границе и срабатывает на каждом ответе. Две линии обороны здесь не роскошь, а разделение обязанностей: запрос решает, что достать, договор — что отдать.
Список полей в комментарии обещает; схема выхода отвечает. Разница видна в первый же раз, когда сборщик ответа забывает поле.
Схема таблиц похожа на договор до обмана: те же слова, почти те же поля. Вывести одну из другой нельзя, и разобрано это подробно — таблица допускает то, чего API не принимает, и наоборот. Для порядка работ отсюда следует вот что: раз документов два, то и вопросов два. Договор отвечает «что нам позволено принять», схема — «что обязано остаться верным всегда, даже если весь код неправ».
Второй список короче и злее. Уникальность почты — не проверка перед вставкой: два одновременных запроса оба увидят, что такой почты нет. Ссылка на автора — не if в сервисе: заметка, оставшаяся без автора, не сломает ни одного теста, пока кто-нибудь не откроет список. Список статусов — не константа в коде: строку в колонку положит и миграция, и psql, и сосед с правами на запись. Правило живёт в таблице, а не в обработчике именно потому, что вторая дверь к таблице всегда пишется позже первой.
Схема проекта — описание значением: две таблицы, два перечисления и четыре правила сверх типов колонок. В прогоне она целиком, а рядом то, ради чего написана, — первая миграция проекта, сделанная и применённая до первого обработчика. Сразу после неё пример просит свежую базу принять семь записей.
Прогноз: семь попыток. Обычная регистрация; та же почта второй раз; почта с заглавными буквами; заметка из одних пробелов; заметка неизвестному автору; заметка со статусом archived и, наконец, заметка по договору. Сколько из семи база примет — и у каких отказов будет имя нарушенного правила, а у каких его не окажется.
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`), ], );
база: postgres://postgres@127.0.0.1:<порт-базы>/app
--- drizzle-kit generate --name init
No config path provided, using default 'drizzle.config.ts'
Reading config file '<пример>/drizzle.config.ts'
2 tables
notes 6 columns 1 indexes 1 fks
users 6 columns 0 indexes 0 fks
[✓] Your SQL migration file ➜ drizzle/0000_init.sql 🚀
--- drizzle/0000_init.sql
CREATE TYPE "public"."note_status" AS ENUM('draft', 'published');--> statement-breakpoint
CREATE TYPE "public"."user_role" AS ENUM('user', 'admin');--> statement-breakpoint
CREATE TABLE "notes" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"author_id" uuid NOT NULL,
"title" text NOT NULL,
"body" text DEFAULT '' NOT NULL,
"status" "note_status" DEFAULT 'draft' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "notes_title_not_blank" CHECK (length(btrim("notes"."title")) > 0)
);
--> statement-breakpoint
CREATE TABLE "users" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"email" text NOT NULL,
"password_hash" text NOT NULL,
"name" text NOT NULL,
"role" "user_role" DEFAULT 'user' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "users_email_unique" UNIQUE("email"),
CONSTRAINT "users_email_lowercase" CHECK ("users"."email" = lower("users"."email"))
);
--> statement-breakpoint
ALTER TABLE "notes" ADD CONSTRAINT "notes_author_id_users_id_fk" FOREIGN KEY ("author_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "notes_author_idx" ON "notes" USING btree ("author_id");
--- drizzle-kit migrate
No config path provided, using default 'drizzle.config.ts'
Reading config file '<пример>/drizzle.config.ts'
Using 'pg' driver for database querying
[~] applying migrations...
[✓] migrations applied successfully!
--- что база теперь не пропустит
обычная регистрация: принято
та же почта второй раз: 23505 users_email_unique
почта с заглавными: 23514 users_email_lowercase
заметка из пробелов: 23514 notes_title_not_blank
заметка неизвестному автору: 23503 notes_author_id_users_id_fk
статус вне списка: 22P02 —
заметка по договору: принятоПринято две записи из семи — первая и последняя. Пять отказов пришли кодами 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.
Матрица собралась целиком из одного поля договора: девять ответов и ни одного if в обработчике. Но последние две строки показывают предел приёма. Договор разрешил Ане notes.byId — он и не мог отказать, строки у него нет. А в строке автор чужой и статус draft: чужой черновик отвечает 404, чужая опубликованная — 403, и решается это только там, где строка уже прочитана. Уровень доступа — граница транспорта, владение — правило домена, и держать их в одном месте не стоит именно потому, что срабатывают они в разные моменты.
Три документа готовы. Следующая статья собирает по ним обработчики и впервые запускает всё вместе, за ней идёт доступ, а последняя статья главы доводит проект до работающего адреса и первого разбора падения.
Карта границ пишется до кода потому, что после кода она уже написана — импортами, и не вами.
- Сервер начинают не с обработчика, а с трёх документов: договор процедур, схема таблиц и карта границ. Обработчик правится свободно, а эти три — вместе с клиентами, с миграциями и со всеми местами, где класс оказался виден.
- Договор — обычный объект: имя процедуры, схема входа, уровень доступа. Из него читают все — разбор, компилятор, описание API и клиент, — и потому второй записи одного правила не появляется.
- Выход описывают схемой так же, как вход. Без неё ответ собирается из строк таблицы, и первое же соединение с автором уносит наружу
passwordHash; схема выхода ловит и лишнее поле, и забытое. - Схема таблиц из договора не выводится: договор говорит, что сервер согласен принять, схема — что обязано остаться верным всегда. Уникальность, ссылку на автора и список статусов держит база, потому что вторая дверь к таблице всегда пишется позже первой.
- Первая миграция ложится в базу раньше первого обработчика. В прогоне свежая база приняла две записи из семи: четыре отказа пришли с именем нарушенного правила, а неверное значение для типа (
22P02) — без него. - Уровень доступа — свойство процедуры и потому живёт в договоре; владение строкой договором не выражается вовсе и решается там, где строка прочитана. Границы модулей однонаправленны:
notesзнает проauth, обратно — нет.
1/4Почему схему таблиц не выводят из схем договора?
2/4В ответе карточки оказался passwordHash автора. Где искать причину?
3/4Что из перечисленного обязано лежать в схеме таблиц, а не в коде сервиса?
4/4Почему уровень доступа процедуры записан в договоре, а владение строкой — нет?
Процедура, которой нет в договоре
Имя процедуры приходит строкой из адреса запроса, и компилятор его не видел: типы кончаются на границе. Сейчас на неизвестном имени разбор падает, а падение в этом месте означает 500 вместо внятного отказа. Сделайте неизвестное имя отказом договора, не меняя ни схем, ни цикла.
Договор — обычный объект, и спросить его о ключе можно до обращения:
После правки третья строка становится «notes.publish → notes.publish: такой процедуры в договоре нет», а первые две не меняются. Object.hasOwn здесь важнее, чем name in contract: у in есть прототип, и процедура с именем toString или constructor нашлась бы в договоре, которого её никто не вписывал.
Приведение name as Name после проверки перестаёт быть обманом компилятора: до неё оно утверждало то, чего никто не проверял, а теперь — то, что проверено строкой выше.
Предел заголовка в договоре
Договор обещает заголовок не длиннее предела, а в валидаторе такой схемы нет: text умеет только «строка и не пустая». Напишите обёртку upTo, которая пропускает результат вложенной схемы, если он не длиннее предела, и отказывает с адресом поля, если длиннее. Заготовка пока просто возвращает вложенную схему.
Обёртка — такая же схема: она зовёт вложенную и смотрит на её результат.
Порядок важен: сначала вложенная схема, потом предел. text обрезает пробелы по краям, и мерить надо обрезанную строку — иначе заголовок с десятком пробелов в конце отвергался бы за длину, которой в хранимом значении нет.
После правки вторая строка становится отказом с адресом поля и сообщением «не длиннее 20 символов», первая и третья не меняются: отказ вложенной схемы обёртка возвращает как есть, своего сообщения к нему не добавляя.
Куда записать правило
Пять обещаний проекта. Для каждого решите, где оно записано: в договоре, в схеме таблиц или в обоих местах — и почему. 1) Заголовок заметки не длиннее 200 символов. 2) У заметки всегда есть автор, и этот автор существует. 3) Почта учётной записи хранится в нижнем регистре. 4) Статус заметки — draft или published. 5) Страница списка не больше 50 записей.
- Заголовок до 200 символов — только договор. Предел ввода меняется вместе с клиентом, а данные длинным заголовком бессмысленными не становятся. В колонке остаётся
textиcheckна пустоту. - Автор есть и существует — только схема. Во входе
authorIdнет вовсе: автор берётся из токена, иначе заметку можно было бы подписать чужим именем. А ссылку на существующего автора кодом не удержать — этим занимается внешний ключ. - Почта в нижнем регистре — в обоих. Приводит к нижнему регистру сервис (он же нормализует почту на входе), а
checkв колонке следит, чтобы мимо него ничего не прошло: вторая дверь к таблице всегда появится. - Статус из двух значений — в обоих. В договоре это
oneOf, который сужает и тип; в базе — перечисление. Первое даёт внятный отказ клиенту, второе не пускает третье значение, откуда бы оно ни пришло. - Страница до 50 записей — только договор. Это вообще не про хранение, а про то, сколько мы согласны отдать за один запрос; потолок стоит в схеме входа и правится вместе с ней.
Общее правило видно по списку: в базу уходит то, без чего данные теряют смысл, в договор — то, что определяет, о чём мы условились с клиентом. Совпадение бывает, и это не дублирование: у двух записей разные поводы к правке.