Домен целиком: ваш каркас обслуживает настоящие сценарии

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

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

Три документа главы готовы: договор процедур, схема таблиц и карта границ — их пишет первая статья. Дальше обработчики, а с ними первый запуск всего сразу. До сих пор каждая миниатюра работала в одиночку: каркас отвечал 200, 404 и 500 выдуманному контроллеру, валидатор разбирал выдуманное тело, таблица держала выдуманные строки. Здесь они держат друг друга: контейнер собирает репозиторий и сервис, процедура разбирает вход своей схемой, сервис отказывает доменным кодом, а статус этому коду подбирает одна таблица. Новых механизмов в статье нет ни одного — есть порядок, в котором складываются старые, и три места, где сложить их можно неправильно.

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

Приложение проекта — четыре процедуры: список, карточка, создание и правка. Написать их можно по функции на процедуру: взять тело, сходить в таблицу, сложить ответ. Так и пишут, пока процедур четыре. Платить приходится на пятой: правило «черновик виден только автору» оказывается написано в четырёх местах из пяти, и пятое — то, через которое чужой черновик и утечёт.

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

СлойЧто знаетЧего не знаетКакое правило живёт здесь
репозиторийтаблицы, условие видимости, порядок, набор колонок ответапро HTTP — ничего; про читателя — только его номерчерновик видит автор; правка идёт с владельцем в условии
сервисрепозиторий и правила домена, список доменных кодовстатусов и кодов HTTP не знает вовсе«нет такой» против «не твоя»; черновик становится опубликованным
транспортимена процедур, схемы входа, перевод отказа в ответни одного правила доменачего нет — 404, что не разобралось — 400, чем отказал домен — по таблице

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

Так же устроен и боевой API учебника. Транспорт достаёт номер читателя из контекста и передаёт сервису аргументом: у процедуры, открытой гостю, это ctx.userId ?? null, у закрытой — ctx.userId без запасного значения.

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

Декораторы в выдержке чужие — это nestjs-trpc, и почему часть VI их не берёт, разобрано в главе 8. На слои это не влияет: сервис и там получает номер читателя аргументом, а решение о том, кто пришёл, принято до него.

Слой — это ответ на один вопрос. Если в слое два вопроса, второй рано или поздно получит второй ответ.

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

В базе проекта пять заметок. У Ани две опубликованные, «Хлеб и молоко» и «Идеи на лето», и черновик «План побега». У Боба опубликованный «Отчёт за март» и черновик «Ремонт кухни». Номера строкам движок выдаёт по порядку вставки, поэтому у «Плана побега» номер 2 и по прямой ссылке он открывается как /notes/2. У людей нумерация своя: Аня — 1, Боб — 2, а гость приходит с нулём, которого нет ни у кого.

Прогноз: десять строк. Сначала первая страница у гостя, у Ани и у Боба — по три заметки, от новых к старым. Потом Боб и Аня открывают /notes/2 по прямой ссылке, потом Боб правит ту же заметку. Дальше Аня создаёт заметку, и оба списка печатаются ещё раз. Последняя строка — поля, которые уехали наружу.

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

Три списка собрал один и тот же where с одним условием: разница между ними — только в аргументе viewer. Гость получил три опубликованные; у Ани вместо «Хлеба и молока» встал «План побега», потому что он новее; у Боба — свой «Ремонт кухни». Ни одного if про статус за пределами #visible в классе нет, и это главное: условие написано один раз, а не в четырёх обработчиках.

Карточка по прямой ссылке — то же условие плюс номер, поэтому «Боб открыл /notes/2» отвечает «нет такой», а Аня — заголовком. Пары «список прячет, а прямая ссылка отдаёт» здесь не может случиться: обе двери — одна дверь. Правка устроена так же — владелец стоит в условии, а не в проверке перед ним, — и это тот самый способ «одним запросом, а не двумя».

Заметку Ани движок принял черновиком, и оба списка это подтверждают: у Ани она встала первой, у Боба не появилась. Обратите внимание на последнюю строку: наружу уехали пять полей, а authorId и createdAt — нет. Это тот же белый список на выходе, только собранный в репозитории: набор полей перечислен один раз и одинаков у списка и у карточки.

Дверь к строкам одна не ради красоты. Вторую дверь пишут, когда первая чем-то не подошла, и условие видимости в неё не переезжает.

Репозиторий отвечает null, когда строки нет. Клиенту нужен не null, а ответ, и ответов здесь два разных. Чужой черновик — «такой заметки нет»: сказать про неё что-то определённое значит выдать её существование. Чужая опубликованная — «заметка есть, она не ваша»: 404 на неё был бы не защитой, а путаницей. Это одно правило, а не два исключения, и в сервисе оно занимает три строки.

Отказывает сервис доменным кодом, а не статусом: NOT_FOUND и NOT_YOUR_NOTE — из того же списка, что и EMAIL_TAKEN. Слова «404» в этом слое нет вовсе, и это проверяемо: поищите его в примере.

Прогноз: семь попыток. Аня правит свой черновик; Боб правит его же, потом чужую опубликованную, потом заметку с номером 99; Боб читает чужой черновик; Аня публикует свой черновик; Боб читает её же снова. У каждой попытки — либо заголовок и статус, либо доменный код.

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

Двух проверок владения в сервисе нет — есть один вопрос и один уточняющий. Правка не нашла строку, и причин этому две: строки не видно вовсе или она чужая. Различает их та же дверь: если byId с тем же читателем тоже ничего не нашёл, читатель про строку не знает — NOT_FOUND; если нашёл, строка существует и просто не его — NOT_YOUR_NOTE. Заметка с номером 99 попадает в первую ветку без всякого особого случая. В главе 10 сервис читал статус сам — своей двери к строкам у него ещё не было; здесь условие уехало в репозиторий, и писать его второй раз незачем.

Две последние строки — про то, зачем это правило вообще одно. Пока «План побега» черновик, Боб получает на него NOT_FOUND. Аня его публикует, и тот же запрос Боба отвечает заголовком. Ни одной строчки кода между этими двумя ответами не поменялось: поменялся статус строки, а правило прочитало его само.

Сервис, в котором есть слово «404», уже не сервис: он решил за транспорт, и второй транспорт получит тот же ответ, даже если у него статусов нет.

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

Подклассы Tables, Repo и Notes в примере не добавляют ни строчки поведения: они нужны ровно затем, чтобы приписать классу список зависимостей. Декоратор ставится там, где класс объявлен, а репозиторий и сервис объявлены разделами выше. В настоящем проекте @Injectable стоит прямо над ними, и подклассов нет.

Роутер собирается на каждый запрос, и это не расточительство. Номер читателя входит в процедуру замыканием — значит, во входе его нет и подделать его нечем. Имя процедуры приезжает строкой, а вход — чем угодно, и «чем угодно» до личности не достаёт. Правка в роутере одна — публикация: у неё своё правило домена и свой вход, а правка заголовка разбирается второй задачей.

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

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

Ни один из восьми ответов никто не складывал отдельно. 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 собрал.

прогон под базой PostgreSQL 18.4 · node 26.5.0 · 29 сентября 2026
Список, карточка, создание и правка проекта на настоящем PostgreSQL и SQL страницы, собранный 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;
  }
}
node main.ts
рядом лежит общий код, без него команда не выполнится: ../p6-first-migration/schema.ts

Первые три строки списка совпали с моделью дословно: те же заголовки в том же порядке у гостя, у Ани и у Боба. Это не удача. Условие видимости в обоих случаях одно и то же, только в модели его считает 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 убрали. Заметки остались. Посмотрите, что после этого приезжает в ответе, и скажите, чем виновата склейка. Потом исправьте функцию собрать так, чтобы такой ответ не уехал клиенту молча.

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

В ответе про «Отчёт за март» нет поля author — и это хуже, чем отказ. names.get(row.authorId) вернул undefined, восклицательный знак пообещал компилятору, что этого не бывает, а JSON.stringify ключ со значением undefined просто выбросил. Клиент нарисует карточку с пустой подписью и ничего не заметит.

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

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

В настоящем проекте до этого не доходит. У колонки author_id схемы стоит внешний ключ с onDelete: 'cascade': удалить человека и оставить его заметки база не даст, они уедут вместе с ним. Проверка в склейке остаётся ради модели и ради того дня, когда кто-нибудь поправит схему.

Правка, которая публикует заодно

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

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

Черновик опубликовался правкой заголовка: в NotePatch есть status, и приведение as отдало туда всё тело запроса. Публикация — отдельное действие с отдельным правилом, и попадать в неё через процедуру правки она не должна.

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

Схема здесь работает дважды. Разобранное значение собирается заново из перечисленных полей, поэтому status в нём не появляется вовсе — напечатанный статус это и показывает. А тип input выведен из той же схемы, и обратиться к input.status компилятор не даст.

Правка всё равно собирается по имени — { title: input.title }, а не input целиком. Передать input компилятор бы позволил: лишние поля он проверяет только у литерала, а переменная проходит. Явное перечисление стоит одной строки и не зависит от того, что в схему добавят завтра.

Отказ схемы и ошибка программы

Два запроса, и оба отвечают 400: в первом клиент прислал id строкой, во втором виноват обработчик — длину просят у номера, а не у заголовка. Сделайте так, чтобы отказ схемы отвечал 400, а ошибка программы — 500 и записью в журнал. Ни call, ни toResponse трогать нельзя: первый — ваш код из главы 6, второй — единственное место перевода.

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

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

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

После этого в ловце остаётся одна строка — return toResponse(error): отказ схемы приезжает готовым HttpError, а всё остальное — чужое исключение, которому toResponse и отвечает пятисоткой, записав причину в журнал.

Обёртку строго объявляем функцией, а не стрелкой с параметром типа. Стрелка <T>(…) => … в песочнице не компилируется: угловая скобка в начале выражения разбирается как JSX, и вместо схемы выходит React.createElement.

Приём тот же, которым в главе 6 работала труба-валидатор: она тоже превращала отказ схемы в 400 там же, где он возник. У процедур граница другая, а место перевода то же — там, где схема отказала.