Repository Guide
Руководство по организации и ведению backend-репозитория на NestJS + Nx монорепо.
1. Общая архитектура
Заголовок раздела «1. Общая архитектура»Тип проекта
Заголовок раздела «Тип проекта»Монорепо с использованием Nx как build-системы. Репозиторий содержит несколько независимых микросервисов и общие библиотеки.
Структура директорий
Заголовок раздела «Структура директорий»project-root/├── apps/ # Приложения (микросервисы)│ ├── <service-name>/ # Конкретный сервис│ └── <service-name>-e2e/ # E2E тесты для сервиса├── libs/ # Разделяемые библиотеки│ └── apis/│ ├── configs/ # Конфигурационные модули│ ├── providers/ # Бизнес-провайдеры (один на домен)│ ├── utils/ # Утилитарные библиотеки (DB, cache, storage)│ └── shared/ # Общие утилиты (декораторы, guards, фильтры)├── docs/ # Документация└── tasks/ # Nx task-конфигурацииПринцип разделения
Заголовок раздела «Принцип разделения»- apps/ — точки входа. Только bootstrap-код, минимум логики.
- libs/ — вся реальная логика. Каждая библиотека экспортирует публичный API через
src/index.ts. - Приложение импортирует библиотеки, библиотеки не импортируют приложения.
2. Технологический стек
Заголовок раздела «2. Технологический стек»| Слой | Технология |
|---|---|
| Framework | NestJS (последняя стабильная) |
| Runtime | Node.js LTS (Alpine для Docker) |
| Language | TypeScript |
| ORM | Prisma |
| Database | PostgreSQL |
| Cache | Redis |
| Message Queue | RabbitMQ (amqplib) |
| Real-time | Socket.io |
| Auth | JWT + Passport |
| Storage | AWS S3 |
| @nestjs-modules/mailer + Nodemailer | |
| Validation | class-validator + class-transformer |
| Testing | Jest |
| Build | Nx + Webpack + SWC |
| Process manager | PM2 (в production) |
3. Организация кода
Заголовок раздела «3. Организация кода»Структура библиотеки (провайдера)
Заголовок раздела «Структура библиотеки (провайдера)»Каждый домен — отдельная библиотека в libs/apis/providers/<domain>:
libs/apis/providers/<domain>/├── src/│ ├── lib/│ │ ├── <domain>.module.ts # NestJS модуль│ │ ├── <domain>.controller.ts # HTTP-слой│ │ ├── <domain>.service.ts # Бизнес-логика│ │ ├── <domain>.repository.ts # Слой данных (Prisma)│ │ ├── constants/ # Константы домена│ │ └── strategies/ # Passport-стратегии (если нужны)│ └── index.ts # Публичный barrel-экспорт└── project.json # Nx-конфиг проектаСтруктура data-access (DTO + константы)
Заголовок раздела «Структура data-access (DTO + константы)»libs/apis/providers/<domain>-data-access/├── src/│ ├── lib/│ │ ├── dtos/ # Data Transfer Objects│ │ └── constants/ # Публичные константы│ └── index.ts└── project.jsonПравило: разделение DTO и логики
Заголовок раздела «Правило: разделение DTO и логики»DTO и интерфейсы хранятся в отдельном *-data-access пакете, чтобы их можно было переиспользовать между сервисами без подтягивания бизнес-логики.
4. Слои архитектуры
Заголовок раздела «4. Слои архитектуры»Controller (HTTP-слой)
Заголовок раздела «Controller (HTTP-слой)»- Принимает запрос, вызывает Service, возвращает DTO.
- Не содержит бизнес-логики.
- Использует
@UseGuards(),@HttpCode(), Swagger-декораторы.
@Controller('v1/resource')@ApiTags('Resource')@UseGuards(SomeGuard)@ApiBearerAuth(TOKEN_STRATEGY_NAME)export class ResourceController { constructor(private readonly service: ResourceService) {}
@Post() @HttpCode(HttpStatus.CREATED) @ApiCreatedResponse({ type: ResourceResponseDto }) async create( @GetTokenPayload() payload: TokenPayload, @Body() dto: CreateResourceDto, ): Promise<ResourceResponseDto> { return this.service.create(payload.sub, dto); }}Service (бизнес-логика)
Заголовок раздела «Service (бизнес-логика)»- Содержит всю логику приложения.
- Вызывает Repository для работы с данными.
- Использует
@Transactional()для операций, требующих атомарности. - Оборачивает результаты через
MapperService.
@Injectable()export class ResourceService { constructor( private readonly repository: ResourceRepository, private readonly mapperService: MapperService, ) {}
@Transactional() async create(userId: string, dto: CreateResourceDto): Promise<ResourceResponseDto> { const entity = await this.repository.create({ ...dto, userId }); return this.mapperService.toResponse(entity, ResourceResponseDto); }}Repository (слой данных)
Заголовок раздела «Repository (слой данных)»- Только Prisma-вызовы. Никакой бизнес-логики.
- Поддерживает пагинацию через хелпер
findManyAndPaginate.
@Injectable()export class ResourceRepository { constructor(private readonly prisma: TransactionHost<TransactionalAdapterPrisma>) {}
async create(data: Prisma.ResourceCreateInput) { return this.prisma.tx.resource.create({ data }); }
async findManyAndPaginate(params: FindManyParams) { // returns { data, meta } }}5. DTO и валидация
Заголовок раздела «5. DTO и валидация»Базовый паттерн DTO
Заголовок раздела «Базовый паттерн DTO»export class ResourceResponseDto { @Expose() id!: string; @Expose() title!: string; @Expose() createdAt!: Date;}
export class CreateResourceDto { @IsString() @MinLength(3) @MaxLength(255) @Trim() title!: string;}
export class UpdateResourceDto extends PartialType( OmitType(CreateResourceDto, ['someField'] as const),) {}Правила
Заголовок раздела «Правила»@Expose()— только на полях, которые нужно отдать клиенту (используется сexcludeExtraneousValues: true).@Trim()— на всех строковых input-полях.PartialType/OmitType/PickTypeиз@nestjs/swagger— для производных DTO.- Никогда не возвращать raw Prisma-объект из сервиса.
6. Аутентификация и авторизация
Заголовок раздела «6. Аутентификация и авторизация»Многотипная аутентификация
Заголовок раздела «Многотипная аутентификация»Если в системе несколько типов пользователей (например, Customer и Employee), для каждого типа создаётся свой набор:
- Passport-стратегия (access + refresh token)
- Guard
- JWT-конфиг (отдельный секрет и время жизни)
Структура токенов
Заголовок раздела «Структура токенов»- Access token: короткоживущий JWT из заголовка
Authorization: Bearer ... - Refresh token: долгоживущий JWT из HttpOnly-cookie
Guard-паттерн
Заголовок раздела «Guard-паттерн»@Injectable()export class CustomerGuard extends AuthGuard(CUSTOMER_ACCESS_TOKEN_STRATEGY_NAME) { handleRequest(err, user, info) { if (err || !user) throw new UnauthorizedException(); return user; // JWT payload }}Декоратор для извлечения payload
Заголовок раздела «Декоратор для извлечения payload»export const GetTokenPayload = createParamDecorator( (data: unknown, ctx: ExecutionContext): TokenPayload => { const request = ctx.switchToHttp().getRequest(); return request.user; },);Fingerprint устройства + IP используются для привязки сессии. При выдаче refresh-токена сессия записывается в БД.
7. Конфигурации
Заголовок раздела «7. Конфигурации»Концепция
Заголовок раздела «Концепция»Каждая внешняя зависимость (база данных, кеш, email, очередь и т.д.) имеет отдельный конфиг-модуль в libs/apis/configs/. Конфиг-модуль не устанавливает соединение — он только читает переменные окружения и предоставляет их как типизированный сервис.
Клиент (utils/) подключается к внешней зависимости и инжектирует конфиг-модуль для получения параметров подключения.
ConfigModule → читает .env → ConfigServiceClientModule → импортирует ConfigModule → использует ConfigService для подключенияСтруктура конфиг-модуля
Заголовок раздела «Структура конфиг-модуля»libs/apis/configs/<name>/├── src/│ ├── lib/│ │ ├── <name>.config.ts # registerAs() — схема переменных│ │ ├── <name>-config.module.ts # NestJS модуль│ │ ├── <name>-config.service.ts # Типизированный доступ к значениям│ │ └── index.ts│ └── index.ts└── project.jsonАнатомия конфиг-модуля
Заголовок раздела «Анатомия конфиг-модуля»1. Файл схемы — определяет namespace и маппинг из env-переменных:
export const redisConfig = registerAs('redis', () => ({ host: process.env.REDIS_HOST, port: Number(process.env.REDIS_PORT) || 6379, password: process.env.REDIS_PASSWORD ?? '',}));2. Модуль — регистрирует конфиг, валидирует переменные через Joi:
@Module({ imports: [ ConfigModule.forRoot({ load: [redisConfig], validationSchema: Joi.object({ REDIS_HOST: Joi.string().required(), REDIS_PORT: Joi.number().required(), REDIS_PASSWORD: Joi.string().allow('').required(), }), }), ], providers: [RedisConfigService], exports: [RedisConfigService],})export class RedisConfigModule {}3. ConfigService — типизированный доступ к значениям:
@Injectable()export class RedisConfigService { constructor(@InjectConfig(redisConfig.KEY) private config: ConfigType<typeof redisConfig>) {}
get host(): string { return this.config.host; } get port(): number { return this.config.port; } get password(): string { return this.config.password; }}Правила для конфиг-модулей
Заголовок раздела «Правила для конфиг-модулей»- Один конфиг-модуль на внешнюю зависимость. Не смешивать Redis и Rabbit в одном модуле.
- Конфиг-модуль ничего не инициализирует — только читает env и предоставляет значения.
- Валидация через Joi в момент загрузки. Приложение не стартует при отсутствии обязательных переменных.
- Конфиг-модуль экспортирует только ConfigService. Конфиг-объект (
registerAs) не экспортируется напрямую. - Потребитель конфига — всегда клиент, не бизнес-сервис. Бизнес-сервис не знает об env-переменных.
8. Утилиты и клиенты
Заголовок раздела «8. Утилиты и клиенты»Концепция
Заголовок раздела «Концепция»libs/apis/utils/ содержит клиенты к внешним сервисам — обёртки, которые устанавливают соединение, управляют его жизненным циклом и предоставляют удобный API поверх сырой библиотеки.
Каждый клиент:
- Инжектирует соответствующий ConfigModule
- Устанавливает и разрывает соединение через lifecycle-хуки NestJS (
OnModuleInit,OnModuleDestroy,OnApplicationShutdown) - Экспортирует Service с методами, скрывающими детали работы с библиотекой
Структура клиентского модуля
Заголовок раздела «Структура клиентского модуля»libs/apis/utils/<name>-client/├── src/│ ├── lib/│ │ ├── <name>-client.module.ts # NestJS модуль│ │ ├── <name>-client.service.ts # Сервис с методами│ │ └── index.ts│ └── index.ts└── project.jsonКогда создавать клиент/утилиту, а когда нет
Заголовок раздела «Когда создавать клиент/утилиту, а когда нет»Ключевой вопрос при разработке: куда отнести код — в бизнес-провайдер или в утилитарную библиотеку?
Создавай клиент в utils/, если:
Заголовок раздела «Создавай клиент в utils/, если:»| Признак | Пример |
|---|---|
| Код оборачивает внешний сервис или библиотеку | redis, amqplib, @aws-sdk/* |
| Логика переиспользуется в 2+ провайдерах | отправка email нужна и в auth, и в user |
| Код управляет соединением (connect/disconnect) | Prisma, Redis, RabbitMQ |
| Код не знает о бизнес-доменах | S3ClientService не знает, что такое “пользователь” |
| Это инфраструктурный код | логирование, трассировка, хеширование |
Оставляй в провайдере, если:
Заголовок раздела «Оставляй в провайдере, если:»| Признак | Пример |
|---|---|
| Код специфичен для одного домена | ProjectStatusValidator — только для проектов |
| Код использует Prisma-модели конкретного домена | UserRepository работает только с моделью User |
| Логика нужна только одному сервису | расчёт стоимости заказа — только в order |
| Код связывает несколько доменных объектов | создание проекта + уведомление владельца |
Серая зона: domain-specific методы на клиентах
Заголовок раздела «Серая зона: domain-specific методы на клиентах»Иногда клиент знает о доменах верхнего уровня — это допустимо, если это технические методы, а не бизнес-логика:
// ДОПУСТИМО: S3ClientService знает о структуре путейbuildUserAvatarPath(userId, filename) → 'users/{id}/avatar/{file}'
// НЕДОПУСТИМО: S3ClientService не должен знать о бизнес-правилахasync replaceAvatarIfExists(userId, filename) // ← это бизнес-логика, в UserServiceРешение через вопросы
Заголовок раздела «Решение через вопросы»Задай себе три вопроса:
- “Это соединение с внешним сервисом?” → если да, это клиент →
utils/ - “Это используется в 2+ бизнес-доменах?” → если да, это утилита →
utils/илиshared/ - “Это знает о конкретной Prisma-модели или бизнес-правиле?” → если да, это провайдер →
providers/
9. Shared-библиотека
Заголовок раздела «9. Shared-библиотека»libs/apis/shared/ содержит cross-cutting concerns — код, который не принадлежит ни одному домену, но нужен повсеместно.
Декораторы (decorators/)
Заголовок раздела «Декораторы (decorators/)»@GetTokenPayload() — param-декоратор для контроллеров.
Извлекает JWT payload из request.user (устанавливается Guard’ом).
@HeaderFingerprint() — param-декоратор.
Читает x-fingerprint из заголовков. Бросает BadRequestException если отсутствует.
@HeaderSessionId() — param-декоратор.
Читает x-session-id из заголовков. Бросает BadRequestException если отсутствует.
@Trim(options?) — property-декоратор для DTO.
Обрезает пробелы у строк (start/end/both). Основан на class-transformer.
Guards (guards/)
Заголовок раздела «Guards (guards/)»Набор готовых guards. Для каждого типа пользователя — своя пара (access + refresh):
CustomerAccessTokenGuard/CustomerRefreshTokenGuardEmployeeAccessTokenGuard/EmployeeRefreshTokenGuardCustomerGuard— проверяет токен + существование пользователя в БДEmployeeGuard— проверяет токен + запись в БД (поддержка отзыва токенов)RoleGuard(...roles)— проверяет наличие роли у пользователяEmployeeRoleGuard(...roles)— объединяет EmployeeGuard + RoleGuard
Фильтры (filters/)
Заголовок раздела «Фильтры (filters/)»PrismaExceptionFilter — глобальный фильтр исключений.
| Prisma Error | HTTP Status | Когда |
|---|---|---|
P2002 | 409 Conflict | Нарушение unique constraint |
P2025 | 404 Not Found | Запись не найдена (опционально) |
| Validation Error | 400 Bad Request | Неверный запрос к Prisma |
Регистрируется глобально в main.ts: app.useGlobalFilters(new PrismaExceptionFilter()).
Сервисы (services/)
Заголовок раздела «Сервисы (services/)»MapperService — маппинг сущностей в DTO.
.toResponse(source, DestinationClass)— маппинг одного объекта.toArrayResponse(sources, DestinationClass)— маппинг массива.toPaginateResponse(paginated, DestinationClass)— маппинг пагинированного ответа
Использует class-transformer с strategy: 'excludeAll' — в DTO экспонируются только поля с @Expose().
Хелперы (helpers/)
Заголовок раздела «Хелперы (helpers/)»| Хелпер | Описание |
|---|---|
generatePasswordHashBy(password) | bcrypt-хеш + соль (rounds: 10) |
createHashSync(data, algorithm?) | MD5 или другой хеш через Node crypto |
extractTokenFromAuthHeader(req) | Достать Bearer {token} из заголовка |
extractEmployeeAccessTokenFromCookieOrAuthHeader(req) | Cookie или header |
extractCustomerRefreshTokenFromCookieOrAuthHeader(req) | Cookie или header |
getCookieOptions(maxAge) | Настройки cookie: httpOnly, sameSite: none, secure |
getPaginate(input) | Конвертация page/perPage → take/skip для Prisma |
toPaginateResponse(input) | Формирование стандартного meta-объекта пагинации |
createSlug(string) | URL-slug через slugify (lowercase, strict) |
createDate(date?) | Обёртка над dayjs |
parseJson(value, defaultValue?) | Безопасный JSON.parse |
getRandomCode(length) | Случайный буквенно-цифровой код (0-9, A-Z) |
getRandomNumber(min, max) | Случайное целое в диапазоне |
Валидаторы (validators/)
Заголовок раздела «Валидаторы (validators/)»@Match(property) — проверяет, что значение поля совпадает с другим полем объекта.
Типичное применение: подтверждение пароля.
export class ResetPasswordDto { @IsString() password!: string;
@IsString() @Match('password') confirmPassword!: string;}Константы (constants/)
Заголовок раздела «Константы (constants/)»Имена стратегий и cookie — единый источник истины, переиспользуются в guards и стратегиях:
EMPLOYEE_ACCESS_TOKEN_STRATEGY_NAME = 'EmployeeAccessToken'EMPLOYEE_REFRESH_TOKEN_STRATEGY_NAME = 'EmployeeRefreshToken'EMPLOYEE_REFRESH_TOKEN_COOKIE_NAME = 'EmployeeRefreshToken'
CUSTOMER_ACCESS_TOKEN_STRATEGY_NAME = 'CustomerAccessToken'CUSTOMER_REFRESH_TOKEN_STRATEGY_NAME = 'CustomerRefreshToken'CUSTOMER_REFRESH_TOKEN_COOKIE_NAME = 'CustomerRefreshToken'Типы (types/)
Заголовок раздела «Типы (types/)»export enum Role { CUSTOMER = 'CUSTOMER', EMPLOYEE = 'EMPLOYEE' }export type Env = Record<string, string>export type Dictionary<T> = { [key: string]: T | undefined }Что помещать в shared, а что нет
Заголовок раздела «Что помещать в shared, а что нет»Помещай в shared:
- Декораторы общего назначения (trim, extract header)
- Guards, не привязанные к домену
- Глобальные фильтры
- Хелперы без бизнес-смысла (хеширование, пагинация, слаги)
- Типы/константы, используемые в 3+ библиотеках
Не помещай в shared:
- Бизнес-логику (даже если она нужна в 2 доменах — лучше вынести в отдельный провайдер)
- Prisma-модели или Repository-зависимости
- Код, который активно меняется вместе с одним доменом
10. База данных
Заголовок раздела «10. База данных»Prisma schema
Заголовок раздела «Prisma schema»- Файл:
apps/core-api/prisma/schema.prisma(один на монорепо, либо по сервису) - Binary targets:
native+linux-musl-openssl-3.0.x(для Alpine Docker) - Enum-типы — для статусов и категорий
- Soft delete — при необходимости через
deletedAt: DateTime?
Миграции
Заголовок раздела «Миграции»# Создать миграциюnx prisma:migrate:dev --name <migration-name>
# Применить к stage/prodnx prisma:db:push --configuration=stageNaming convention для миграций
Заголовок раздела «Naming convention для миграций»--name пишется в snake_case, описывает действие: add_soft_delete_to_projects, remove_budget_from_projects.
11. Транзакции
Заголовок раздела «11. Транзакции»Используется @nestjs-cls/transactional + Prisma-адаптер:
// Регистрация в модулеClsModule.forRoot({ plugins: [new ClsPluginTransactional({ adapter: new TransactionalAdapterPrisma(prisma) })],})
// Использование в сервисе@Transactional()async createWithRelated(dto) { const entity = await this.repo.create(dto); await this.relatedRepo.create({ entityId: entity.id }); return entity;}12. Пагинация
Заголовок раздела «12. Пагинация»Стандартный формат ответа:
{ "data": [...], "meta": { "currentPage": 1, "pageCount": 10, "perPage": 20, "total": 195, "isFirstPage": true, "isLastPage": false }}Query-параметры: ?page=1&perPage=20
13. State Machine для статусов
Заголовок раздела «13. State Machine для статусов»Если у сущности есть несколько статусов с ограниченными переходами:
export const STATUS_TRANSITIONS: Record<StatusEnum, StatusEnum[]> = { [StatusEnum.DRAFT]: [StatusEnum.PUBLISHED], [StatusEnum.PUBLISHED]: [StatusEnum.DRAFT, StatusEnum.ACTIVE], [StatusEnum.ACTIVE]: [StatusEnum.COMPLETED], [StatusEnum.COMPLETED]: [],};
// В сервисеconst allowed = STATUS_TRANSITIONS[current];if (!allowed.includes(next)) { throw new BadRequestException(`Cannot transition from ${current} to ${next}`);}14. Request tracking
Заголовок раздела «14. Request tracking»- Глобальный middleware добавляет
X-Request-Id(UUID) к каждому запросу. nestjs-clsпробрасывает request-scoped данные (request id, user и т.д.) через весь стек вызовов без явной передачи через аргументы.
15. Обработка ошибок
Заголовок раздела «15. Обработка ошибок»Принцип
Заголовок раздела «Принцип»- В сервисах бросать NestJS-исключения (
NotFoundException,BadRequestExceptionи т.д.) - Prisma-ошибки обрабатываются глобальным
PrismaExceptionFilter
Prisma Exception Filter
Заголовок раздела «Prisma Exception Filter»| Prisma Error | HTTP Status |
|---|---|
| P2002 (unique constraint) | 409 Conflict |
| P2025 (record not found) | 404 Not Found |
16. Git workflow
Заголовок раздела «16. Git workflow»| Ветка | Назначение |
|---|---|
main | Production-ready код |
feat/<name> | Новая функциональность |
fix/<name> | Исправление бага |
refactor/<name> | Рефакторинг без изменения поведения |
chore/<name> | Инфраструктурные задачи |
Commit convention
Заголовок раздела «Commit convention»Формат: type(scope): description (всё в lowercase)
Типы:
feat— новая функциональностьfix— исправление багаrefactor— рефакторингmigration— изменения схемы БДdocs— документацияchore— зависимости, конфиги, CI
Примеры:
feat(auth): add refresh token rotationfix(project): validate status transition before updaterefactor(service): extract pagination helpermigration: add soft delete to projects tabledocs: add deployment guide17. Code style
Заголовок раздела «17. Code style»Prettier (.prettierrc)
Заголовок раздела «Prettier (.prettierrc)»{ "singleQuote": true, "bracketSpacing": true, "printWidth": 110}- Flat config (
eslint.config.mjs) - Nx module boundary rules — библиотека не может импортировать “запрещённые” теги
- TypeScript-aware linting
TypeScript (tsconfig.base.json)
Заголовок раздела «TypeScript (tsconfig.base.json)»target: ES2015emitDecoratorMetadata: true(обязательно для NestJS DI)experimentalDecorators: trueskipLibCheck: true- Path aliases для всех библиотек
18. Nx-специфика
Заголовок раздела «18. Nx-специфика»Добавление нового приложения
Заголовок раздела «Добавление нового приложения»nx g @nx/nest:app <app-name>Добавление новой библиотеки
Заголовок раздела «Добавление новой библиотеки»nx g @nx/nest:lib libs/apis/providers/<lib-name>Полезные команды
Заголовок раздела «Полезные команды»nx graph # Визуализация зависимостейnx affected --target=test # Тестировать только изменённоеnx run <app>:serve # Запустить сервис в dev-режимеnx run <app>:build # Собрать для production19. Checklist для нового сервиса
Заголовок раздела «19. Checklist для нового сервиса»- Создать приложение:
nx g @nx/nest:app <name> - Создать провайдер-библиотеки для каждого домена
- Создать
data-accessбиблиотеки с DTO - Настроить ConfigModule для всех внешних зависимостей
- Подключить нужные клиенты (
PrismaClientModule,RedisClientModuleи т.д.) - Настроить глобальные pipes (
ValidationPipe), фильтры, middleware - Добавить Swagger (
DocumentBuilder) - Добавить скрипты
dev:<name>иbuild:<name>вpackage.json - Добавить E2E-приложение
20. Checklist для нового домена (внутри сервиса)
Заголовок раздела «20. Checklist для нового домена (внутри сервиса)»- Создать
<domain>провайдер-библиотеку - Создать
<domain>-data-accessбиблиотеку - Реализовать: Module → Controller → Service → Repository
- Написать DTO: Response, Create, Update, Query (если есть фильтры)
- Подключить Guard для аутентификации
- Добавить Swagger-аннотации на все endpoints
- Зарегистрировать модуль в приложении
- Написать хотя бы базовые unit-тесты для Service