Перейти к содержимому

Repository Guide

Руководство по организации и ведению backend-репозитория на NestJS + Nx монорепо.


Монорепо с использованием 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.
  • Приложение импортирует библиотеки, библиотеки не импортируют приложения.

СлойТехнология
FrameworkNestJS (последняя стабильная)
RuntimeNode.js LTS (Alpine для Docker)
LanguageTypeScript
ORMPrisma
DatabasePostgreSQL
CacheRedis
Message QueueRabbitMQ (amqplib)
Real-timeSocket.io
AuthJWT + Passport
StorageAWS S3
Email@nestjs-modules/mailer + Nodemailer
Validationclass-validator + class-transformer
TestingJest
BuildNx + Webpack + SWC
Process managerPM2 (в production)

Каждый домен — отдельная библиотека в 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-конфиг проекта
libs/apis/providers/<domain>-data-access/
├── src/
│ ├── lib/
│ │ ├── dtos/ # Data Transfer Objects
│ │ └── constants/ # Публичные константы
│ └── index.ts
└── project.json

DTO и интерфейсы хранятся в отдельном *-data-access пакете, чтобы их можно было переиспользовать между сервисами без подтягивания бизнес-логики.


  • Принимает запрос, вызывает 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);
}
}
  • Содержит всю логику приложения.
  • Вызывает 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);
}
}
  • Только 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 }
}
}

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-объект из сервиса.

Если в системе несколько типов пользователей (например, Customer и Employee), для каждого типа создаётся свой набор:

  • Passport-стратегия (access + refresh token)
  • Guard
  • JWT-конфиг (отдельный секрет и время жизни)
  • Access token: короткоживущий JWT из заголовка Authorization: Bearer ...
  • Refresh token: долгоживущий JWT из HttpOnly-cookie
@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
}
}
export const GetTokenPayload = createParamDecorator(
(data: unknown, ctx: ExecutionContext): TokenPayload => {
const request = ctx.switchToHttp().getRequest();
return request.user;
},
);

Fingerprint устройства + IP используются для привязки сессии. При выдаче refresh-токена сессия записывается в БД.


Каждая внешняя зависимость (база данных, кеш, email, очередь и т.д.) имеет отдельный конфиг-модуль в libs/apis/configs/. Конфиг-модуль не устанавливает соединение — он только читает переменные окружения и предоставляет их как типизированный сервис.

Клиент (utils/) подключается к внешней зависимости и инжектирует конфиг-модуль для получения параметров подключения.

ConfigModule → читает .env → ConfigService
ClientModule → импортирует 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-переменных:

redis.config.ts
export const redisConfig = registerAs('redis', () => ({
host: process.env.REDIS_HOST,
port: Number(process.env.REDIS_PORT) || 6379,
password: process.env.REDIS_PASSWORD ?? '',
}));

2. Модуль — регистрирует конфиг, валидирует переменные через Joi:

redis-config.module.ts
@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 — типизированный доступ к значениям:

redis-config.service.ts
@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; }
}
  1. Один конфиг-модуль на внешнюю зависимость. Не смешивать Redis и Rabbit в одном модуле.
  2. Конфиг-модуль ничего не инициализирует — только читает env и предоставляет значения.
  3. Валидация через Joi в момент загрузки. Приложение не стартует при отсутствии обязательных переменных.
  4. Конфиг-модуль экспортирует только ConfigService. Конфиг-объект (registerAs) не экспортируется напрямую.
  5. Потребитель конфига — всегда клиент, не бизнес-сервис. Бизнес-сервис не знает об env-переменных.

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

Когда создавать клиент/утилиту, а когда нет

Заголовок раздела «Когда создавать клиент/утилиту, а когда нет»

Ключевой вопрос при разработке: куда отнести код — в бизнес-провайдер или в утилитарную библиотеку?

ПризнакПример
Код оборачивает внешний сервис или библиотекуredis, amqplib, @aws-sdk/*
Логика переиспользуется в 2+ провайдерахотправка email нужна и в auth, и в user
Код управляет соединением (connect/disconnect)Prisma, Redis, RabbitMQ
Код не знает о бизнес-доменахS3ClientService не знает, что такое “пользователь”
Это инфраструктурный кодлогирование, трассировка, хеширование
ПризнакПример
Код специфичен для одного доменаProjectStatusValidator — только для проектов
Код использует Prisma-модели конкретного доменаUserRepository работает только с моделью User
Логика нужна только одному сервисурасчёт стоимости заказа — только в order
Код связывает несколько доменных объектовсоздание проекта + уведомление владельца

Иногда клиент знает о доменах верхнего уровня — это допустимо, если это технические методы, а не бизнес-логика:

// ДОПУСТИМО: S3ClientService знает о структуре путей
buildUserAvatarPath(userId, filename) → 'users/{id}/avatar/{file}'
// НЕДОПУСТИМО: S3ClientService не должен знать о бизнес-правилах
async replaceAvatarIfExists(userId, filename) // ← это бизнес-логика, в UserService

Задай себе три вопроса:

  1. “Это соединение с внешним сервисом?” → если да, это клиент → utils/
  2. “Это используется в 2+ бизнес-доменах?” → если да, это утилита → utils/ или shared/
  3. “Это знает о конкретной Prisma-модели или бизнес-правиле?” → если да, это провайдер → providers/

libs/apis/shared/ содержит cross-cutting concerns — код, который не принадлежит ни одному домену, но нужен повсеместно.

@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. Для каждого типа пользователя — своя пара (access + refresh):

  • CustomerAccessTokenGuard / CustomerRefreshTokenGuard
  • EmployeeAccessTokenGuard / EmployeeRefreshTokenGuard
  • CustomerGuard — проверяет токен + существование пользователя в БД
  • EmployeeGuard — проверяет токен + запись в БД (поддержка отзыва токенов)
  • RoleGuard(...roles) — проверяет наличие роли у пользователя
  • EmployeeRoleGuard(...roles) — объединяет EmployeeGuard + RoleGuard

PrismaExceptionFilter — глобальный фильтр исключений.

Prisma ErrorHTTP StatusКогда
P2002409 ConflictНарушение unique constraint
P2025404 Not FoundЗапись не найдена (опционально)
Validation Error400 Bad RequestНеверный запрос к Prisma

Регистрируется глобально в main.ts: app.useGlobalFilters(new PrismaExceptionFilter()).

MapperService — маппинг сущностей в DTO.

  • .toResponse(source, DestinationClass) — маппинг одного объекта
  • .toArrayResponse(sources, DestinationClass) — маппинг массива
  • .toPaginateResponse(paginated, DestinationClass) — маппинг пагинированного ответа

Использует class-transformer с strategy: 'excludeAll' — в DTO экспонируются только поля с @Expose().

ХелперОписание
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)Случайное целое в диапазоне

@Match(property) — проверяет, что значение поля совпадает с другим полем объекта. Типичное применение: подтверждение пароля.

export class ResetPasswordDto {
@IsString()
password!: string;
@IsString()
@Match('password')
confirmPassword!: string;
}

Имена стратегий и 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'
export enum Role { CUSTOMER = 'CUSTOMER', EMPLOYEE = 'EMPLOYEE' }
export type Env = Record<string, string>
export type Dictionary<T> = { [key: string]: T | undefined }

Помещай в shared:

  • Декораторы общего назначения (trim, extract header)
  • Guards, не привязанные к домену
  • Глобальные фильтры
  • Хелперы без бизнес-смысла (хеширование, пагинация, слаги)
  • Типы/константы, используемые в 3+ библиотеках

Не помещай в shared:

  • Бизнес-логику (даже если она нужна в 2 доменах — лучше вынести в отдельный провайдер)
  • Prisma-модели или Repository-зависимости
  • Код, который активно меняется вместе с одним доменом

  • Файл: 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/prod
nx prisma:db:push --configuration=stage

--name пишется в snake_case, описывает действие: add_soft_delete_to_projects, remove_budget_from_projects.


Используется @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;
}

Стандартный формат ответа:

{
"data": [...],
"meta": {
"currentPage": 1,
"pageCount": 10,
"perPage": 20,
"total": 195,
"isFirstPage": true,
"isLastPage": false
}
}

Query-параметры: ?page=1&perPage=20


Если у сущности есть несколько статусов с ограниченными переходами:

constants/resource-status-transitions.ts
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}`);
}

  • Глобальный middleware добавляет X-Request-Id (UUID) к каждому запросу.
  • nestjs-cls пробрасывает request-scoped данные (request id, user и т.д.) через весь стек вызовов без явной передачи через аргументы.

  • В сервисах бросать NestJS-исключения (NotFoundException, BadRequestException и т.д.)
  • Prisma-ошибки обрабатываются глобальным PrismaExceptionFilter
Prisma ErrorHTTP Status
P2002 (unique constraint)409 Conflict
P2025 (record not found)404 Not Found

ВеткаНазначение
mainProduction-ready код
feat/<name>Новая функциональность
fix/<name>Исправление бага
refactor/<name>Рефакторинг без изменения поведения
chore/<name>Инфраструктурные задачи

Формат: type(scope): description (всё в lowercase)

Типы:

  • feat — новая функциональность
  • fix — исправление бага
  • refactor — рефакторинг
  • migration — изменения схемы БД
  • docs — документация
  • chore — зависимости, конфиги, CI

Примеры:

feat(auth): add refresh token rotation
fix(project): validate status transition before update
refactor(service): extract pagination helper
migration: add soft delete to projects table
docs: add deployment guide

{
"singleQuote": true,
"bracketSpacing": true,
"printWidth": 110
}
  • Flat config (eslint.config.mjs)
  • Nx module boundary rules — библиотека не может импортировать “запрещённые” теги
  • TypeScript-aware linting
  • target: ES2015
  • emitDecoratorMetadata: true (обязательно для NestJS DI)
  • experimentalDecorators: true
  • skipLibCheck: true
  • Path aliases для всех библиотек

Окно терминала
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 # Собрать для production

  • Создать приложение: 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