backend · advanced · 9d
Nest-сервис в продакшен-форме
NestJS вознаграждает за структуру и наказывает за её отсутствие. Собери сервис так, как его выкатила бы команда: feature-модули с настоящими границами, DTO, валидирующие на краю, guards и interceptors, которые держат сквозные заботы, типизированный config, в котором не опечатаешься, и e2e-набор, поднимающий всё приложение целиком. Это и есть разница между «знаю декораторы Nest» и «понимаю, зачем они существуют».
Результат
Работающий NestJS-сервис: минимум два feature-модуля, входные DTO, валидируемые через class-validator, глобально подключённые guard и interceptor, типизированный и проверяемый по схеме config, а также e2e-набор на Jest, который поднимает приложение по HTTP и проверяет как успешные, так и ошибочные сценарии.
Этапы
0/6 · 0%- 01Разметь модули
Не поддавайся искушению свалить всё в AppModule. Возьми две настоящие фичи — скажем, «orders» и «notifications» — и дай каждой свой модуль со своим контроллером и провайдером, экспортируя только то, что другому модулю разрешено трогать. Дисциплина в том, что публичная поверхность модуля — это его exports; всё остальное приватно. Используй DI Nest, чтобы внедрять сервис в контроллер, а не конструировать его руками, — тогда временем жизни управляет контейнер, и в тестах можно подменять реализации. К концу ты должен уметь ткнуть в любой класс и сказать, какой модуль им владеет и почему.
Критерии готовности- Существуют два feature-модуля, каждый экспортирует только свой публичный провайдер; импорт приватного провайдера модуля извне не проходит.
- Сервис внедряется в свой контроллер через DI-контейнер, а не создаётся через new.
- 02Валидируй на краю
Каждый байт от клиента враждебен, пока не доказано обратное. Опиши DTO-классы с декораторами class-validator и включи глобальный ValidationPipe с whitelist и forbidNonWhitelisted, чтобы неизвестные поля отсекались или отклонялись, а не тихо протекали в обработчики. Выгода в том, что появляется единое декларативное место, где форма запроса и есть контракт: плохое тело отлетает с 400, называя поле-нарушитель ещё до того, как запустится бизнес-логика. Осознанно реши, включать ли transform и приведение типов, потому что «whitelist on, transform off» и «оба on» ведут себя по-разному и это всплывёт позже.
Критерии готовности- Запрос с неизвестным или неверно типизированным полем возвращает 400 с сообщением, называющим поле, и никогда не доходит до тела контроллера.
- Валидные запросы проходят с полностью типизированным экземпляром DTO, а не сырым нетипизированным объектом.
- 03Возьми сквозные заботы под контроль
Авторизация и логирование не должны быть размазаны по контроллерам. Напиши guard, который решает, можно ли пропустить запрос — вернёт false или бросит исключение, чтобы отдать 403 ещё до запуска обработчика, — и interceptor, который оборачивает обработчик, чтобы замерить время или преобразовать ответ. Важно понимать порядок: guards выполняются раньше pipes и interceptors, а interceptors обхватывают обработчик с обеих сторон. Подключи хотя бы один глобально, а другой ограничь одним маршрутом — почувствуй разницу между общей политикой приложения и локальным поведением. Смысл в том, что Nest даёт эти швы именно для того, чтобы бизнес-код оставался про бизнес.
Критерии готовности- Guard блокирует неавторизованный запрос с 403 до запуска обработчика; авторизованный проходит дальше.
- Interceptor заметно оборачивает обработчик (например, добавляет заголовок с длительностью или единый конверт ответа) на каждом ответе, который покрывает.
- 04Конфигурация, в которой не опечатаешься
Читать process.env напрямую — мина замедленного действия: опечатка в ключе даёт undefined в три часа ночи, а не на старте. Загружай конфигурацию через ConfigModule и валидируй её по схеме (zod или joi) при загрузке, чтобы отсутствующее или некорректное значение роняло приложение сразу с понятным сообщением, а не падало глубоко внутри обработчика позже. Открой типизированный config-сервис, чтобы вызывающий код спрашивал config.get('database.url') и получал string, а не string | undefined. Цель — fail-fast: процесс должен отказываться стартовать, пока его окружение не доказуемо полно и корректно типизировано.
Критерии готовности- Запуск приложения с отсутствующей или некорректной обязательной env-переменной падает сразу с сообщением, называющим переменную.
- Конфигурация читается через типизированный сервис, у которого типы возврата для обязательных ключей ненулевые.
- 05Подними всё целиком в тестах
Юнит-тесты доказывают логику провайдера; e2e-тесты доказывают связность. Возьми тестовые утилиты Nest, чтобы собрать реальный граф модулей, поднять HTTP-сервер и дёргать эндпоинты через supertest — pipes, guards и interceptors в работе, ровно как в проде. Намеренно покрой несчастливые пути: проверь, что невалидный DTO даёт 400, неавторизованный запрос — 403, а валидный возвращает правильное тело. Этот набор — контракт, против которого можно рефакторить без страха: пока он зелёный, построенные швы держат.
Критерии готовности- E2e-тест поднимает приложение по HTTP и проверяет успешный 2xx с ожидаемым телом.
- Отдельные e2e-тесты проверяют ошибочные пути 400 (невалидный DTO) и 403 (guard).
- 06Сеньорский рывок: очередь и лог, которому можно верить
Настоящие системы не делают всё прямо в запросе. Вынеси вторую фичу за message-транспорт: HTTP-модуль принимает работу и публикует сообщение, а consumer-микросервис (транспорт Redis или RabbitMQ через @nestjs/microservices) обрабатывает её асинхронно. Теперь ты честно сталкиваешься с семантикой доставки — это fire-and-forget, at-least-once, не спишет ли повторная доставка деньги дважды? — и намеренно делаешь consumer идемпотентным. Дополни это структурированным JSON-логированием (pino), которое проносит correlation id от входящего запроса до consumer, чтобы один запрос был одной прослеживаемой историей через два процесса. Именно эта связность превращает «где-то медленно» в «медленно вот здесь».
Критерии готовности- Работа течёт от HTTP-эндпоинта через очередь к consumer-микросервису, который обрабатывает её асинхронно.
- Логи — структурированный JSON, и единый correlation id связывает входящий запрос с его обработкой на стороне consumer.
Рубрика
| Джуниор | Миддл | Сеньор | |
|---|---|---|---|
| Границы модулей и дисциплина DI | Большая часть логики живёт в AppModule; провайдеры конструируются через new или свободно импортируются между модулями, реальной инкапсуляции нет. | Два или более feature-модулей каждый экспортирует только свой публичный провайдер; сервис внедряется через DI-контейнер, а не создаётся руками; импорт приватного провайдера извне модуля падает при сборке. | Scope провайдера выбирается намеренно (DEFAULT / REQUEST / TRANSIENT) с письменным обоснованием каждого не-дефолтного выбора; границы модулей делают граф зависимостей ациклическим, а публичная поверхность каждого модуля — это минимальный API, нужный его потребителям, а не удобный реэкспорт всего, что внутри. |
| Валидация DTO на краю через class-validator | Валидация ручная (if-проверки внутри обработчика) или отсутствует; неизвестные поля тихо протекают в бизнес-логику. | Глобальный ValidationPipe с whitelist: true и forbidNonWhitelisted: true отвергает неизвестные поля на границе; невалидные запросы возвращают 400 с сообщением, называющим поле-нарушитель, ещё до запуска обработчика. | transform: true против transform: false — явное решение, а не случайный дефолт; ты можешь описать, что делает «whitelist on, transform off» с полем типа number, присланным как строка, а классы DTO служат источниками TypeScript-типов — параллельного интерфейса для синхронизации нет. |
| Guards, interceptors и типизированная конфигурация | Логика аутентификации и замер времени размазаны по телам контроллеров; config читается напрямую через process.env.KEY, и опечатка всплывает в рантайме, а не при старте. | Guard возвращает false / бросает исключение до запуска обработчика для неавторизованных запросов; interceptor оборачивает вызов обработчика; ConfigModule валидирует схему при загрузке и падает с именованной ошибкой при отсутствующей переменной. | Guard подключён глобально через один экземпляр уровня приложения, а не продублирован на каждом контроллере; rxjs-конвейер tap / catchError interceptor'а обрабатывает ошибки без их проглатывания; типизированный ConfigService возвращает не-nullable типы для обязательных ключей, так что вызывающий код никогда не получает string | undefined. |
| Покрытие e2e-связности и внеполосная доставка | Существуют только юнит-тесты; связность модулей и порядок ValidationPipe / guard никогда не тестируются от начала до конца. | E2e-набор поднимает реальный граф модулей по HTTP (supertest) и проверяет успешный 2xx, ошибочный DTO 400 и неавторизованный 403 — pipes, guards и interceptors всё в работе. | Вторая фича подключена за message-транспортом; correlation id проходит от HTTP-запроса через очередь до лога consumer'а; consumer идемпотентен при повторной доставке, что доказывается тестом, отправляющим одно и то же сообщение дважды и проверяющим единственный побочный эффект. |
Эталонный разбор (спойлер)
Почему границы модулей важны: модульная система Nest делает граф зависимостей явным и верифицируемым при старте. Без неё циклическая зависимость или случайно публичный провайдер всплывают как рантайм-ошибка глубоко в пользовательском запросе, а не как ошибка связности при загрузке. Пометить провайдер как не-экспортируемый — это самый дешёвый способ закрепить «это внутренняя деталь реализации».
Компромисс whitelist у ValidationPipe: whitelist: true тихо убирает неизвестные поля; forbidNonWhitelisted: true отклоняет их с 400. Правильный выбор зависит от контракта: SDK-клиент под твоим контролем оправдывает жёсткий отказ, чтобы сломанное изменение API сразу всплыло; браузерная форма может оправдывать обрезку. Случайный выбор — не то же самое, что осознанный.
Порядок выполнения под нагрузкой: guards выполняются до pipes, которые выполняются до обработчика, который оборачивается с обеих сторон interceptor'ами. Медленный tap interceptor'а добавляет задержку к каждому маршруту, который он покрывает. Guard, бросающий исключение безусловно, делает обработчик недостижимым в тестах — именно поэтому e2e-тесты, поднимающие реальный граф, ловят то, что юнит-тесты никогда не увидят.
Семантика внеполосной доставки не бесплатна: перенос работы за message-транспорт означает, что at-least-once доставка — дефолт для большинства транспортов: повторная доставка может перевыполнить обработчик. Сделать consumer идемпотентным (ключ дедупа в транзакционной записи) — не оптимизация, это требование корректности в момент, когда повторная доставка возможна.
Сделай по-сеньорски
- Сделай consumer устойчивым к повторной доставке: дай каждому сообщению ключ, дедуплицируй по нему и докажи тестом, что обработка одного и того же сообщения дважды даёт тот же эффект, что и один раз.
- Добавь глобальный exception filter, превращающий брошенные доменные ошибки в единый конверт ошибки, и проверь его форму в e2e-тестах, чтобы ответы об ошибках были частью контракта, а не случайностью.
- Подключи эндпоинт /health и interceptor длительности запроса, затем нагрузь путь через очередь, чтобы увидеть, куда смещается задержка, когда работа уходит с потока запроса.