Домен целиком: ваш каркас обслуживает настоящие сценарии
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Три документа главы готовы: договор процедур, схема таблиц и карта границ — их пишет первая статья. Дальше обработчики, а с ними первый запуск всего сразу. До сих пор каждая миниатюра работала в одиночку: каркас отвечал 200, 404 и 500 выдуманному контроллеру, валидатор разбирал выдуманное тело, таблица держала выдуманные строки. Здесь они держат друг друга: контейнер собирает репозиторий и сервис, процедура разбирает вход своей схемой, сервис отказывает доменным кодом, а статус этому коду подбирает одна таблица. Новых механизмов в статье нет ни одного — есть порядок, в котором складываются старые, и три места, где сложить их можно неправильно.
Приложение проекта — четыре процедуры: список, карточка, создание и правка. Написать их можно по функции на процедуру: взять тело, сходить в таблицу, сложить ответ. Так и пишут, пока процедур четыре. Платить приходится на пятой: правило «черновик виден только автору» оказывается написано в четырёх местах из пяти, и пятое — то, через которое чужой черновик и утечёт.
Поэтому под процедурами три слоя, и у каждого свой вопрос. Репозиторий отвечает на «какие строки», сервис — на «что с ними можно», транспорт — на «как это выглядит снаружи». Деление не украшение: правило, попавшее не в свой слой, срабатывает не в тот момент, когда нужно.
| Слой | Что знает | Чего не знает | Какое правило живёт здесь |
|---|---|---|---|
| репозиторий | таблицы, условие видимости, порядок, набор колонок ответа | про HTTP — ничего; про читателя — только его номер | черновик видит автор; правка идёт с владельцем в условии |
| сервис | репозиторий и правила домена, список доменных кодов | статусов и кодов HTTP не знает вовсе | «нет такой» против «не твоя»; черновик становится опубликованным |
| транспорт | имена процедур, схемы входа, перевод отказа в ответ | ни одного правила домена | чего нет — 404, что не разобралось — 400, чем отказал домен — по таблице |
Одного слоя в этой таблице нет намеренно — того, что отвечает на вопрос «кто пришёл». Во всех примерах статьи читатель приезжает готовым номером: viewer — обычный аргумент. Откуда он берётся, чем подтверждён и что делать с гостем, разбирает следующая статья; здесь важно одно — домен получает номер сверху и сам за ним не ходит.
Так же устроен и боевой API учебника. Транспорт достаёт номер читателя из контекста и передаёт сервису аргументом: у процедуры, открытой гостю, это ctx.userId ?? null, у закрытой — ctx.userId без запасного значения.
Декораторы в выдержке чужие — это nestjs-trpc, и почему часть VI их не берёт, разобрано в главе 8. На слои это не влияет: сервис и там получает номер читателя аргументом, а решение о том, кто пришёл, принято до него.
Слой — это ответ на один вопрос. Если в слое два вопроса, второй рано или поздно получит второй ответ.
Репозиторий как единственную дверь к строкам вы уже собирали: условие, порядок и экранирование переехали в одно место. В проекте к этому добавляется вторая таблица. Людей и заметки движок хранит порознь, а в ответе должно оказаться имя автора — поля, которого нет ни в одной строке заметки. Его приходится склеивать, и это первое место, где слои путают.
В базе проекта пять заметок. У Ани две опубликованные, «Хлеб и молоко» и «Идеи на лето», и черновик «План побега». У Боба опубликованный «Отчёт за март» и черновик «Ремонт кухни». Номера строкам движок выдаёт по порядку вставки, поэтому у «Плана побега» номер 2 и по прямой ссылке он открывается как /notes/2. У людей нумерация своя: Аня — 1, Боб — 2, а гость приходит с нулём, которого нет ни у кого.
Прогноз: десять строк. Сначала первая страница у гостя, у Ани и у Боба — по три заметки, от новых к старым. Потом Боб и Аня открывают /notes/2 по прямой ссылке, потом Боб правит ту же заметку. Дальше Аня создаёт заметку, и оба списка печатаются ещё раз. Последняя строка — поля, которые уехали наружу.
Три списка собрал один и тот же where с одним условием: разница между ними — только в аргументе viewer. Гость получил три опубликованные; у Ани вместо «Хлеба и молока» встал «План побега», потому что он новее; у Боба — свой «Ремонт кухни». Ни одного if про статус за пределами #visible в классе нет, и это главное: условие написано один раз, а не в четырёх обработчиках.
Карточка по прямой ссылке — то же условие плюс номер, поэтому «Боб открыл /notes/2» отвечает «нет такой», а Аня — заголовком. Пары «список прячет, а прямая ссылка отдаёт» здесь не может случиться: обе двери — одна дверь. Правка устроена так же — владелец стоит в условии, а не в проверке перед ним, — и это тот самый способ «одним запросом, а не двумя».
Заметку Ани движок принял черновиком, и оба списка это подтверждают: у Ани она встала первой, у Боба не появилась. Обратите внимание на последнюю строку: наружу уехали пять полей, а authorId и createdAt — нет. Это тот же белый список на выходе, только собранный в репозитории: набор полей перечислен один раз и одинаков у списка и у карточки.
Дверь к строкам одна не ради красоты. Вторую дверь пишут, когда первая чем-то не подошла, и условие видимости в неё не переезжает.
Репозиторий отвечает null, когда строки нет. Клиенту нужен не null, а ответ, и ответов здесь два разных. Чужой черновик — «такой заметки нет»: сказать про неё что-то определённое значит выдать её существование. Чужая опубликованная — «заметка есть, она не ваша»: 404 на неё был бы не защитой, а путаницей. Это одно правило, а не два исключения, и в сервисе оно занимает три строки.
Отказывает сервис доменным кодом, а не статусом: NOT_FOUND и NOT_YOUR_NOTE — из того же списка, что и EMAIL_TAKEN. Слова «404» в этом слое нет вовсе, и это проверяемо: поищите его в примере.
Прогноз: семь попыток. Аня правит свой черновик; Боб правит его же, потом чужую опубликованную, потом заметку с номером 99; Боб читает чужой черновик; Аня публикует свой черновик; Боб читает её же снова. У каждой попытки — либо заголовок и статус, либо доменный код.
Двух проверок владения в сервисе нет — есть один вопрос и один уточняющий. Правка не нашла строку, и причин этому две: строки не видно вовсе или она чужая. Различает их та же дверь: если byId с тем же читателем тоже ничего не нашёл, читатель про строку не знает — NOT_FOUND; если нашёл, строка существует и просто не его — NOT_YOUR_NOTE. Заметка с номером 99 попадает в первую ветку без всякого особого случая. В главе 10 сервис читал статус сам — своей двери к строкам у него ещё не было; здесь условие уехало в репозиторий, и писать его второй раз незачем.
Две последние строки — про то, зачем это правило вообще одно. Пока «План побега» черновик, Боб получает на него NOT_FOUND. Аня его публикует, и тот же запрос Боба отвечает заголовком. Ни одной строчки кода между этими двумя ответами не поменялось: поменялся статус строки, а правило прочитало его само.
Сервис, в котором есть слово «404», уже не сервис: он решил за транспорт, и второй транспорт получит тот же ответ, даже если у него статусов нет.
Три класса, четыре процедуры и один перевод отказа — это и есть приложение. Собирает его контейнер: зависимость ищется среди своих провайдеров, а потом в экспортах ввезённых модулей. Карту границ из первой статьи главы теперь можно не пересказывать словами, а спросить у контейнера — он ответит или откажет.
Подклассы Tables, Repo и Notes в примере не добавляют ни строчки поведения: они нужны ровно затем, чтобы приписать классу список зависимостей. Декоратор ставится там, где класс объявлен, а репозиторий и сервис объявлены разделами выше. В настоящем проекте @Injectable стоит прямо над ними, и подклассов нет.
Роутер собирается на каждый запрос, и это не расточительство. Номер читателя входит в процедуру замыканием — значит, во входе его нет и подделать его нечем. Имя процедуры приезжает строкой, а вход — чем угодно, и «чем угодно» до личности не достаёт. Правка в роутере одна — публикация: у неё своё правило домена и свой вход, а правка заголовка разбирается второй задачей.
Прогноз: одиннадцать строк. Сначала список процедур, собранный из самого роутера, и две просьбы к контейнеру — за сервисом и за репозиторием. Дальше восемь запросов: два списка, чужой черновик, публикация чужой заметки, создание с пустым заголовком, создание, публикация своей и имя процедуры, которого нет. У каждого — статус и тело.
Ни один из восьми ответов никто не складывал отдельно. 404 на чужой черновик вырос из NOT_FOUND сервиса, 403 — из NOT_YOUR_NOTE, и статус им подобрала таблица, в которой перевод кода в ответ живёт один раз. 400 — отказ схемы, 404 на notes.remove — отказ транспорта: имени нет в роутере, и разбирать тело незачем. Место, где отказ превращается в статус, в приложении одно.
Просьба за репозиторием из корня кончилась отказом, и это единственный способ проверить границу. NotesModule ввозит CommonModule и держит двух провайдеров, а наружу отдаёт одного — сервис. Пока граница живёт только в карте, её нарушение выглядит как обычный импорт; стоит записать её в exports, и контейнер отвечает сам.
Один шов остался сырым, и это видно прямо в коде ловца: 400 достаётся любому TypeError. Отказ схемы приезжает именно им — parse бросает TypeError, — но тем же исключением упадёт и собственный недосмотр в обработчике, и клиент получит «вы прислали не то» вместо пятисотки. Развести их — последняя задача статьи.
Слои и границы переносятся один в один, и это стоило проверить. Модуль с providers и exports — это модуль Nest, он же папка; подклассы исчезают, потому что @Injectable стоит над самим сервисом. Роутер, собранный на запрос, — это роутер tRPC, которому сервис достают у контейнера тем же app.get. Перевод отказа в ответ переезжает из ловца в фильтр.
У модуля заметок боевого API нет ни одного exports: @Module({ providers: [NotesRouter, NotesService, NotesRepository] }) — наружу из него не уезжает вообще ничего, а процедуры складывает роутер внутри модуля. Ограничение сильнее нашего: у нас сервис хотя бы экспортирован, потому что роутер собирается снаружи.
Приложение собралось не потому, что части написаны хорошо, а потому, что у каждой был один вход и один выход.
Модель таблицы честно повторяет базу в главном и не повторяет в частностях — так было и в главе 9. Значит, те же четыре сценария надо снять на настоящем PostgreSQL. Схема проекта, описанная значением, ложится в пустую базу тем же drizzle-kit, который печатал её в главе 9; запросы идут через drizzle-orm. Репозиторий тот же по устройству: условие видимости одним значением, владелец в условии правки, набор колонок один на список и карточку.
Смотреть стоит на две вещи: первые три строки списка — и последнюю, с запросом, который Drizzle собрал.
import { and, desc, eq, or } from 'drizzle-orm'; import type { NodePgDatabase } from 'drizzle-orm/node-postgres'; import { notes, users } from '../p6-first-migration/schema.ts'; export type NoteOut = { title: string; body: string | null; status: 'draft' | 'published'; author: string }; export type NotePatch = { title?: string; body?: string; status?: 'draft' | 'published' }; const PAGE = 3; // Набор колонок ответа один на список и на карточку: хеш пароля база не читает const колонки = { id: notes.id, title: notes.title, body: notes.body, status: notes.status, author: users.name }; export class NotesRepo { #db: NodePgDatabase; constructor(db: NodePgDatabase) { this.#db = db; } // То же условие видимости, что у модели, только считает его база #visible(viewer: string | null) { return viewer === null ? eq(notes.status, 'published') : or(eq(notes.status, 'published'), eq(notes.authorId, viewer)); } // Автор приезжает тем же запросом: склейка — join, а не второй поход в базу #page(viewer: string | null, n: number) { return this.#db .select(колонки) .from(notes) .innerJoin(users, eq(users.id, notes.authorId)) .where(this.#visible(viewer)) .orderBy(desc(notes.createdAt), desc(notes.id)) .limit(PAGE) .offset(n * PAGE); } page(viewer: string | null, n: number): Promise<NoteOut[]> { return this.#page(viewer, n); } запросСтраницы(): string { return this.#page(null, 0).toSQL().sql; } async byId(viewer: string | null, id: string): Promise<NoteOut | null> { const rows = await this.#db .select(колонки) .from(notes) .innerJoin(users, eq(users.id, notes.authorId)) .where(and(eq(notes.id, id), this.#visible(viewer))); return rows[0] ?? null; } async create(authorId: string, input: { title: string; body: string }): Promise<string> { const rows = await this.#db.insert(notes).values({ ...input, authorId }).returning({ id: notes.id }); return rows[0].id; } // Владелец — часть условия правки, а не проверка перед ней: сколько строк // изменилось, столько и вернётся async update(viewer: string, id: string, patch: NotePatch): Promise<number> { const rows = await this.#db .update(notes) .set(patch) .where(and(eq(notes.id, id), eq(notes.authorId, viewer))) .returning({ id: notes.id }); return rows.length; } }
база: postgres://postgres@127.0.0.1:<порт-базы>/app гость: Идеи на лето | Отчёт за март | Хлеб и молоко Аня: Идеи на лето | Отчёт за март | План побега Боб: Идеи на лето | Ремонт кухни | Отчёт за март Боб открыл черновик Ани: нет такой Аня открыла свой черновик: План побега Боб правит черновик Ани: строк изменено 0 Аня правит свой черновик: строк изменено 1 Аня создала: Список покупок (draft, автор Аня) запрос страницы: select "notes"."id", "notes"."title", "notes"."body", "notes"."status", "users"."name" from "notes" inner join "users" on "users"."id" = "notes"."author_id" where "notes"."status" = $1 order by "notes"."created_at" desc, "notes"."id" desc limit $2
Первые три строки списка совпали с моделью дословно: те же заголовки в том же порядке у гостя, у Ани и у Боба. Это не удача. Условие видимости в обоих случаях одно и то же, только в модели его считает where на массиве, а в базе — WHERE в запросе; порядок задан теми же двумя ключами. Дальше строки расходятся — и расхождения полезнее совпадений.
Правка в базе отвечает не строкой, а числом изменённых строк: 0 у Боба и 1 у Ани. Ровно за это владельца и держат внутри условия: ни лишнего запроса, ни окна между чтением и записью. Модель тот же ответ выражает через null — у неё update отдаёт строку, а не число. Различать «нет такой» и «не твоя» вторым вопросом приходится и там, и там: приём сливает три разных случая в один ответ, и это его известная цена.
Склейка автора у базы — inner join в том же запросе: имя приезжает вместе со строкой. В модели выборку имён по списку номеров пришлось написать руками, и это единственное место, где модель выглядит сложнее базы. Зато в напечатанном SQL видно, что в список выбираемых колонок не попало ничего лишнего: из users база берёт одно name, а password_hash в запросе не упоминается вовсе. Набор колонок перечислен в репозитории один раз, и запрос собран по нему.
inner join, limit и offset в модели собраны руками из sort и slice — это ещё терпимо. Дальше начинается то, чего в ней нет вовсе: версии строк, из-за которых два читателя видят разные снимки; план запроса, который выбирает индекс сам; внешний ключ, который держит связь заметки с автором. Последний закрывает случай из первой задачи статьи: удалить человека и оставить его заметки база не даст — они уедут вместе с ним.
Модель нужна, чтобы понимать, за что база берёт деньги. Проверять на ней можно только то, что в ней есть.
- Проект складывается в три слоя, и у каждого один вопрос: репозиторий отвечает, какие строки видны, сервис — что с ними можно, транспорт — как это выглядит снаружи. Правило, попавшее не в свой слой, срабатывает не в тот момент, когда нужно.
- Условие видимости живёт в репозитории одной строкой и обслуживает и список, и карточку. Поэтому пары «список прячет, а прямая ссылка отдаёт» в проекте не бывает, а правка идёт с владельцем в самом условии.
- Форма ответа перечислена в репозитории один раз: наружу уезжают
id,title,body,statusи имя автора. Имя склеивается из второй таблицы одной выборкой на страницу, а в настоящей базе —inner joinв том же запросе. - Сервис отказывает доменным кодом и слова «404» не знает. «Нет такой» и «не твоя» он различает вторым вопросом к той же двери: не видно на чтение —
NOT_FOUND, видно —NOT_YOUR_NOTE. - Транспорт собирает роутер на каждый запрос, чтобы номер читателя входил в процедуру замыканием, а не через вход от клиента. Отказы всех трёх видов — схемы, домена и самого транспорта — становятся статусом в одном месте.
- Границу модуля видно только просьбой к контейнеру:
NotesModuleотдаёт наружу сервис и не отдаёт репозиторий. В боевом API учебника у модуля заметок нет ни одногоexports— наружу не уезжает ничего.
1/4Почему условие видимости живёт в репозитории, а не в сервисе?
2/4Сервис не нашёл строку для правки. Откуда он знает, ответить NOT_FOUND или NOT_YOUR_NOTE?
3/4Зачем роутер процедур собирается заново на каждый запрос?
4/4В приложении из последнего примера отказ схемы и своя же ошибка в обработчике отвечают одним 400. Почему?
Автор, которого удалили
Боб ушёл из учебника, и его строку из users убрали. Заметки остались. Посмотрите, что после этого приезжает в ответе, и скажите, чем виновата склейка. Потом исправьте функцию собрать так, чтобы такой ответ не уехал клиенту молча.
В ответе про «Отчёт за март» нет поля author — и это хуже, чем отказ. names.get(row.authorId) вернул undefined, восклицательный знак пообещал компилятору, что этого не бывает, а JSON.stringify ключ со значением undefined просто выбросил. Клиент нарисует карточку с пустой подписью и ничего не заметит.
Падение здесь правильнее отказа: NOT_FOUND сказал бы клиенту, что заметки нет, хотя она есть, а пятисотка и запись в журнал говорят правду — данные в базе противоречат сами себе.
В настоящем проекте до этого не доходит. У колонки author_id схемы стоит внешний ключ с onDelete: 'cascade': удалить человека и оставить его заметки база не даст, они уедут вместе с ним. Проверка в склейке остаётся ради модели и ради того дня, когда кто-нибудь поправит схему.
Правка, которая публикует заодно
Аня правит заголовок черновика и присылает в теле ещё и status. Сейчас тело едет в правку целиком — посмотрите, что вышло со статусом. Напишите схему входа этой процедуры и соберите по ней правку так, чтобы правка заголовка меняла только заголовок.
Черновик опубликовался правкой заголовка: в NotePatch есть status, и приведение as отдало туда всё тело запроса. Публикация — отдельное действие с отдельным правилом, и попадать в неё через процедуру правки она не должна.
Схема здесь работает дважды. Разобранное значение собирается заново из перечисленных полей, поэтому status в нём не появляется вовсе — напечатанный статус это и показывает. А тип input выведен из той же схемы, и обратиться к input.status компилятор не даст.
Правка всё равно собирается по имени — { title: input.title }, а не input целиком. Передать input компилятор бы позволил: лишние поля он проверяет только у литерала, а переменная проходит. Явное перечисление стоит одной строки и не зависит от того, что в схему добавят завтра.
Отказ схемы и ошибка программы
Два запроса, и оба отвечают 400: в первом клиент прислал id строкой, во втором виноват обработчик — длину просят у номера, а не у заголовка. Сделайте так, чтобы отказ схемы отвечал 400, а ошибка программы — 500 и записью в журнал. Ни call, ни toResponse трогать нельзя: первый — ваш код из главы 6, второй — единственное место перевода.
Общий catch не может отличить отказ схемы от чужого TypeError: к моменту ловли известен только класс исключения. Значит, переводить надо там, где отказ возник, — в самой схеме.
После этого в ловце остаётся одна строка — return toResponse(error): отказ схемы приезжает готовым HttpError, а всё остальное — чужое исключение, которому toResponse и отвечает пятисоткой, записав причину в журнал.
Обёртку строго объявляем функцией, а не стрелкой с параметром типа. Стрелка <T>(…) => … в песочнице не компилируется: угловая скобка в начале выражения разбирается как JSX, и вместо схемы выходит React.createElement.
Приём тот же, которым в главе 6 работала труба-валидатор: она тоже превращала отказ схемы в 400 там же, где он возник. У процедур граница другая, а место перевода то же — там, где схема отказала.