open atlas
↑ К треку
NestJS с нуля до senior NEST · 09 · 01

Capstone: типизированный, защищённый, протестированный API

Собрать весь трек воедино: feature-модули поверх Controller -> Service -> Repository, core-модуль, привязывающий глобальные guard/pipe/interceptor/filter, типы на границах и чек-лист production-готовности, который падает рано.

NEST Senior ◷ 20 min
Уровень
ОсновыJuniorMiddleSenior

Сервис заказов выкатывается, и три недели всё нормально. Потом пятничный деплой добавляет STRIPE_KEY, который команда забыла прописать в staging, — приложение поднимается зелёным, проходит liveness и не отдаёт 404 ни на чём, потому что ключ читается только при первом чекауте, в 16:00, в проде. Дежурный инженер, копая, находит ещё три мины: контроллер, который сам делает проверку роли и сам валидирует, сервис, который открывает сырой запрос вне всякой транзакции, и synchronize: true, тихо меняющий схему на каждом рестарте. Ни одно из этого — не хитрый баг. Каждое — это задача, которая уползла из слота, уже данного фреймворком. Этот урок — сборочный чертёж: каждая прежняя деталь трека, собранная в один сервис, которому ты бы реально доверял на дежурстве.

Скелет: feature-модули поверх типизированного core

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

В сервисе, о котором можно рассуждать, два рода модулей. Feature-модули (OrdersModule, UsersModule) владеют одним срезом домена и следуют одной внутренней форме: Controller (HTTP-граница, без логики), Service (бизнес-операции, единственное место, где живут правила) и Repository (персистентность, типизированный Repository<T>). Единственный core-модуль владеет всем сквозным: типизированным ConfigModule, глобальным ValidationPipe, глобальным логирующим interceptor, глобальным exception filter, auth-guard и health-контроллером. Feature-модули импортируют core; ничто не импортирует внутренности feature-модуля.

Core-модуль — это место, где lifecycle привязывается один раз, через DI-осведомлённые токены-провайдеры, чтобы guard/filter/interceptor могли сами инжектить зависимости:

import { Module } from '@nestjs/common';
import { APP_GUARD, APP_PIPE, APP_FILTER, APP_INTERCEPTOR } from '@nestjs/core';
import { ConfigModule } from '@nestjs/config';
import { ValidationPipe } from '@nestjs/common';
import { JwtAuthGuard } from './auth/jwt-auth.guard';
import { AllExceptionsFilter } from './filters/all-exceptions.filter';
import { TransformInterceptor } from './interceptors/transform.interceptor';
import { validateEnv } from './config/env.validation';

@Module({
  imports: [
    ConfigModule.forRoot({ isGlobal: true, validate: validateEnv }), // fail-fast на boot
  ],
  providers: [
    { provide: APP_GUARD, useClass: JwtAuthGuard },                  // authn на каждом маршруте
    {
      provide: APP_PIPE,
      useValue: new ValidationPipe({ whitelist: true, transform: true }),
    },
    { provide: APP_INTERCEPTOR, useClass: TransformInterceptor },     // конверт + тайминг
    { provide: APP_FILTER, useClass: AllExceptionsFilter },           // одна форма ошибки
  ],
})
export class CoreModule {}

whitelist: true срезает свойства без DTO-декоратора (защита от mass-assignment — атаки, при которой клиент передаёт лишние поля и перезаписывает данные, которые не должен трогать); transform: true превращает обычное JSON-тело в настоящий инстанс DTO и приводит примитивы. Привязка через токены APP_* — а не через app.useGlobalGuards() — держит эти глобалы внутри DI-контейнера, так что guard может инжектить Reflector, а filter — твой Logger.

Один запрос, фиксированный lifecycle как хребет

Каждый запрос проходит ту же цепочку, что ты выучил раньше, — теперь читай её как хребет всего дизайна. JwtAuthGuard (аутентификация) выполняется первым и читает метаданные @Public(), чтобы /health и /login могли отписаться. RolesGuard (авторизация) читает @Roles() и решает доступ — улажено до того, как разобрано любое тело. ValidationPipe заводит типизированный DTO внутрь. Хендлер тонкий: он зовёт сервис и возвращает. Service выполняет бизнес-операцию внутри dataSource.transaction, разделяя транзакционный EntityManager, так что каждая запись коммитится или откатывается вместе. Interceptor оборачивает ответ в конверт и снимает тайминг на выходе. AllExceptionsFilter формирует всё, что выброшено на любом этапе.

// orders.controller.ts — тонкая граница, без бизнес-логики
@Controller('orders')
export class OrdersController {
  constructor(private readonly orders: OrdersService) {}

  @Post()
  @Roles('customer')
  create(@Body() dto: CreateOrderDto, @CurrentUser() user: AuthUser) {
    return this.orders.place(dto, user.id); // DTO уже валидирован глобальным pipe
  }
}

// orders.service.ts — правила живут здесь, персистентность — в одной транзакции
@Injectable()
export class OrdersService {
  constructor(private readonly dataSource: DataSource) {}

  place(dto: CreateOrderDto, userId: string): Promise<OrderView> {
    return this.dataSource.transaction(async (manager) => {
      const repo = manager.getRepository(Order);                 // типизированный Repository<Order>
      const stock = await manager.findOne(Product, { where: { id: dto.productId } });
      if (!stock || stock.qty < dto.quantity) throw new ConflictException('out of stock');
      const order = repo.create({ userId, ...dto });
      await repo.save(order);                                    // та же tx, что и чтение остатка
      return toOrderView(order);                                 // domain -> response DTO
    });
  }
}

Хендлер никогда не видит ни транзакции, ни проверки роли, ни формы ошибки — они живут в своих слотах lifecycle. Сервис никогда не видит HTTP. В этом разделении весь смысл: каждый этап ссылается на нужный прежний урок — guards для authz, pipe для валидации, общий транзакционный менеджер для персистентности, interceptor для конверта, filter для ошибок.

Типы на границах, fail-fast на boot

Контракт этого сервиса — его типы, проверяемые там, где данные пересекают границу. Три DTO, не один: request DTO (валидированный вход), доменная сущность (то, что держит база) и response DTO (то, что видят вызывающие, — никогда не утекай сущностью). Конфиг тоже типизирован: registerAs плюс ConfigType даёт сервису строго типизированный срез вместо stringly-typed обращений к process.env, а validate отвергает плохое окружение на boot, а не при первом использовании.

// config/database.config.ts
import { registerAs, ConfigType } from '@nestjs/config';

export const databaseConfig = registerAs('db', () => ({
  url: process.env.DATABASE_URL!,
  poolSize: Number(process.env.DB_POOL_SIZE ?? 10),
}));
export type DatabaseConfig = ConfigType<typeof databaseConfig>;

// где-то в провайдере:
constructor(@Inject(databaseConfig.KEY) private readonly db: DatabaseConfig) {}
// db.poolSize — number, db.url — string — без `as`, без `process.env` на местах вызова

Чек-лист production-готовности — то сеньорское суждение, что отделяет «компилируется» от «доверяю на дежурстве»:

ЗадачаProduction-готовый ответКакой сбой это предотвращает
ConfigEnv валидирован на boot (validate); секреты через config, никогда в JWTПропущенная переменная падает при первом использовании в проде, а не на деплое
СхемаТолько миграции; synchronize: falsesynchronize: true тихо меняет/дропает колонки на рестарте
LifecycleGraceful shutdown (enableShutdownHooks); readiness гейтит трафикIn-flight запросы теряются; трафик идёт до того, как зависимости прогреты
HealthLiveness = процесс жив; readiness = БД/зависимости доступныLiveness, проверяющий БД, запускает цикл рестартов на сбое БД
ObservabilityСтруктурные логи с correlation id на каждый запросНечем трассировать один запрос между сервисами после инцидента
IdempotencyИдемпотентные хендлеры там, где доставка at-least-onceПереотправленное сообщение списывает или создаёт дважды

Все шесть пунктов образуют единую стратегию раннего падения: чем раньше проявляется неправильная конфигурация — на boot, а не при чекауте; на балансировщике, а не внутри запущенного пода — тем меньше радиус поражения. Пропустишь любой из них — именно эта ночь будет твоей дежурной.

Стратегия тестирования зеркалит слои: unit-тестируй сервисы с мокнутыми репозиториями — быстро, без БД, проверяя бизнес-правила в изоляции — и e2e-тестируй собранный pipeline (интеграционные тесты поверх реальной инфраструктуры) с реальной тестовой БД и реальным токеном, так что guard, pipe, транзакция, interceptor и filter все отрабатывают ровно как в проде.

Почему это работает

Почему разделять liveness и readiness, а не один /health? Они отвечают на разные вопросы разным наблюдателям. Liveness отвечает «не заклинило ли этот процесс?» — если он падает, оркестратор рестартит под, поэтому он должен зависеть только от самого процесса, никогда от базы. Readiness отвечает «должен ли трафик идти сюда прямо сейчас?» — если он падает, балансировщик перестаёт маршрутизировать, но не рестартит, поэтому это верное место проверять БД и нижестоящие зависимости. Схлопни их в один probe, который пингует базу, — и краткий сбой БД станет штормом рестартов: все поды разом проваливают liveness, все рестартятся, а теперь холодные пулы соединений делают сбой хуже.

Выбери лучший вариант

Ты структурируешь фичу размещения заказа так, чтобы она оставалась поддерживаемой и тестируемой по мере роста команды и правил. Как ты расположишь код?

Викторина

В готовом дизайне в каком порядке сквозные компоненты касаются одного успешного запроса POST /orders?

Викторина

Почему привязывать глобальный ValidationPipe с { whitelist: true, transform: true } через APP_PIPE, а не создавать его в main.ts, и почему важен whitelist?

Вспомните перед уходом
  1. 01
    Опиши скелет модулей и слоёв готового сервиса и где привязывается каждая сквозная задача.
  2. 02
    Проведи один запрос POST /orders через фиксированный lifecycle и назови пункты production-готовности, которые держат его надёжным.
Итог

Capstone собирает весь трек в один сервис, которому ты бы доверял на дежурстве. Скелет — это feature-модули (OrdersModule, UsersModule) поверх фиксированного внутреннего слоения — Controller (тонкая HTTP-граница), Service (единственный дом бизнес-правил и владелец транзакции), Repository (типизированный Repository<T>) — плюс единственный core-модуль, привязывающий каждую сквозную задачу один раз через DI-осведомлённые токены: APP_GUARD для authn/authz, APP_PIPE для глобального ValidationPipe с whitelist и transform, APP_INTERCEPTOR для конверта ответа и тайминга, APP_FILTER для одной формы ошибки. Фиксированный request lifecycle — это хребет: guard -> guard -> pipe -> handler -> service-в-транзакции -> interceptor -> filter, каждый этап ссылается на свой прежний урок. Контракт — это типы на границах: request DTO, доменная сущность и response DTO, никогда не схлопнутые в один, с ConfigType и типизированным Repository, убирающими stringly-typed обращения. Production-готовность — это сеньорское суждение: валидируй env на boot, чтобы падать рано, только миграции с выключенным synchronize, graceful shutdown с readiness, гейтящим трафик, liveness, который никогда не трогает БД, структурные логи с correlation id, секреты в config, а не в токенах, и идемпотентные хендлеры там, где доставка at-least-once (гарантия «не меньше одного раза», при которой сообщение может прийти повторно). Тестируй слоение так, как строил: unit-тестируй сервисы с мокнутыми репозиториями, e2e-тестируй собранный pipeline против реальной БД с реальной аутентификацией. Теперь, когда встретишь контроллер с проверкой роли, сервис с сырым запросом вне транзакции или synchronize: true в конфиге TypeORM, ты будешь знать, какой слот пропущен, — и куда перенести задачу.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 5 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources4
expand
  1. 01
  2. 02
  3. 03
  4. 04

Trademarks belong to their respective owners. Editorial reference only.