Capstone: типизированный, защищённый, протестированный API
Собрать весь трек воедино: feature-модули поверх Controller -> Service -> Repository, core-модуль, привязывающий глобальные guard/pipe/interceptor/filter, типы на границах и чек-лист production-готовности, который падает рано.
Сервис заказов выкатывается, и три недели всё нормально. Потом пятничный деплой добавляет 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-готовый ответ | Какой сбой это предотвращает |
|---|---|---|
| Config | Env валидирован на boot (validate); секреты через config, никогда в JWT | Пропущенная переменная падает при первом использовании в проде, а не на деплое |
| Схема | Только миграции; synchronize: false | synchronize: true тихо меняет/дропает колонки на рестарте |
| Lifecycle | Graceful shutdown (enableShutdownHooks); readiness гейтит трафик | In-flight запросы теряются; трафик идёт до того, как зависимости прогреты |
| Health | Liveness = процесс жив; 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?
- 01Опиши скелет модулей и слоёв готового сервиса и где привязывается каждая сквозная задача.
- 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-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.