May 1

NestJS Backend: опыт, архитектура и лучшие практики для зрелых проектов

Вступление: зрелость как инженерный выбор

В мире разработки слишком часто побеждает спешка. Мы привыкли “делать MVP”, “выкатить как-нибудь”, “потом разберёмся”. Но любой, кто хоть раз сталкивался с поддержкой растущего продукта, знает: хаос не прощает легкомыслия.

Архитектура — это не набор функций, это инфраструктура доверия, ответственности и предсказуемости - фундамент, на котором строится бизнес, команда, будущее продукта.

В какой-то момент мы с командой осознали: если мы хотим не просто “выживать”, а развиваться, нам нужно не латать старое, а строить новое — осознанно, системно, с прицелом на годы вперёд.
Мы выбрали путь зрелой архитектуры, где каждый модуль, каждый слой, каждый паттерн — не случайность, а результат анализа, обсуждений и опыта.

NestJS стал для нас платформой для внедрения инженерной культуры: модульность, строгая типизация, прозрачность зависимостей, поддержка лучших практик из мира TypeScript и Node.js.

Для кого эта статья

- от хаоса к гармонии

Когда-то мы начинали с быстрых MVP: “главное, чтобы заработало”. Но время шло, проекты росли, а с ними росли и требования — к скорости, безопасности, масштабируемости. И вот мы оказались на распутье: либо продолжать латать дыры, либо построить что-то по-настоящему крутое. Мы выбрали второе.

NestJS стал нашим верным спутником в этом приключении. Этот фреймворк — как швейцарский нож для разработчика: модульный, строгий, с поддержкой TypeScript и кучей готовых инструментов. Но даже с таким помощником без правильного подхода можно заблудиться. Поэтому мы взялись за дело с умом: продумали архитектуру, внедрили лучшие практики и отточили процессы. И знаете что? Получилось нечто, чем хочется поделиться с вами.

Эта статья содержит опыт, выстраданный через десятки итераций, бессонные ночи и горячие споры в команде. Здесь вы найдёте всё: от структуры проекта до мелочей вроде логирования и тестов. А ещё — немного вдохновения, чтобы ваш следующий проект стал шедевром.

Эта статья пишется для тех, кто устал от хаоса и хочет порядка. Для тех, кто строит не “ещё один сервис”, а платформу, которую не стыдно показать коллегам, инвесторам, самому себе через год.


Здесь не будет банальных советов и поверхностных решений. Только проверенные подходы, реальные грабли, архитектурные решения, которые выдержали нагрузку и время. Это систематизация опыта: от структуры папок до CI/CD, от DTO-first API до Docker-окружения, от логирования до тестов.

Здесь вы найдёте не только “как”, но и “зачем” — мотивацию, причины, последствия каждого решения.

Если вы ищете не волшебную таблетку, а рабочую систему — добро пожаловать.
Пусть ваш backend станет надёжным и удобным для команды, прозрачным для бизнеса, готовым к росту и переменам.

Введение:

В современном мире backend-разработки требования к качеству, безопасности и поддерживаемости кода постоянно растут. Мы прошли путь от “быстрого MVP” до архитектуры, которая выдерживает нагрузку, легко расширяется и радует как разработчиков, так и бизнес. В этой статье я расскажу, как мы шаг за шагом строили современный backend на NestJS, довели его до уровня, соответствующего требованиям крупных production-проектов, и какие подходы, паттерны и инструменты мы внедрили для удобства, безопасности и масштабируемости.

Статья будет полезна: - начинающим и опытным backend-разработчикам,
- тимлидам и архитекторам,
- всем, кто хочет понять, как строить поддерживаемые и надёжные серверные приложения.

1. Архитектура проекта: фундамент для роста

1.1. Модули и слои: разделяй и властвуй

Всё начинается с архитектуры. Мы строим проект по принципу “один бизнес-домен — один модуль”. Это значит, что у нас есть отдельные модули для пользователей, проектов, аутентификации и т.д.

В каждом модуле:

- controllers/ — только маршрутизация и валидация входных данных. Здесь нет бизнес-логики!


- services/ — бизнес-логика, работа с БД, валидация бизнес-правил. Только здесь решается, “можно ли” и “как именно”.


- dto/ — только DTO, специфичные для этого модуля. DTO — это “контракт” между backend и frontend.


- entities/ — только TypeORM-сущности. Это “отражение” таблиц в базе.

Пример структуры:

src/ modules/ users/ controllers/ services/ dto/ entities/ projects/ auth/ shared/ dto/ common/ filters/ utils/ dto/



Почему это важно?

Такой подход позволяет легко масштабировать проект, добавлять новые фичи, не боясь “сломать всё”. Каждый модуль — как мини-приложение, но с едиными стандартами.

1.2. Shared и Common: не дублируй, а делись

В зрелом проекте архитектура должна не только обеспечивать масштабируемость, но и минимизировать дублирование кода, облегчать сопровождение и ускорять разработку новых фич. Для этого мы чётко разделяем два слоя: shared и common.

Shared/ — библиотека бизнес-логики

Папка `shared/` — это не “склад” общих файлов, а настоящая библиотека бизнес-типов и DTO, которые используются в нескольких модулях. Здесь живут те сущности, которые отражают бизнес-реальность и нужны сразу в нескольких частях приложения.

Что кладём в shared:

- DTO для пагинации (`PaginateDto`, `PaginatedResult<T>`)


- Общие идентификаторы (`IdentifierDto`)


- Базовые бизнес-ответы (например, `SuccessResponseDto`, `ErrorResponseDto`)

- Общие типы и интерфейсы, которые описывают бизнес-логику (например, `UserRole`, `StatusEnum`)


- DTO для фильтрации, сортировки, поиска, если эти механизмы повторяются в разных модулях

Зачем это нужно: Когда бизнес-логика повторяется в разных модулях (например, пагинация нужна и для пользователей, и для проектов, и для заказов), мы не копируем код, а используем единый контракт. Это гарантирует согласованность API, облегчает поддержку фронта и ускоряет внедрение новых сущностей.

Пример: // shared/dto/paginate.dto.ts export class PaginateDto { page?: number = 1; limit?: number = 10; }

// shared/dto/paginated-result.dto.ts export interface PaginatedResult<T> { items: T[]; total: number; page: number; limit: number; }

Common/ — инфраструктурный слой

Папка `common/` — это технический фундамент приложения. Здесь лежит всё то, что не связано напрямую с бизнес-логикой, но обеспечивает стабильную работу, безопасность и удобство разработки.

Что кладём в common:

- Глобальные фильтры ошибок (`HttpExceptionFilter`)


- Middleware (например, логирование, CORS, трекинг запросов)


- Глобальные константы (например, лимиты, дефолтные значения)


- Утилиты и хелперы (например, функции для работы с датами, генерация токенов, парсинг переменных окружения)


- Глобальные guards, interceptors, pipes


- Базовые технические DTO (например, для health-check)

Всё, что касается инфраструктуры, технических аспектов и “склеивания” приложения, должно быть централизовано. Это позволяет быстро вносить изменения, не затрагивая бизнес-логику, и обеспечивает единые стандарты по всему проекту.

Пример:
// common/filters/http-exception.filter.ts @Catch(HttpException) export class HttpExceptionFilter implements ExceptionFilter { // ...логика логирования и форматирования ошибок... }


// common/utils/get-env-var.ts export function getEnvVar(key: string, fallback?: string): string { // ...логика получения переменной окружения с учётом контура... }

Философия: “Не дублируй, а делись”

- Всё, что может понадобиться в нескольких местах — выносится в shared.
- Всё, что обеспечивает техническую инфраструктуру — в common.
- В модулях остаётся только то, что уникально для конкретного бизнес-домена.

Преимущества такого подхода:

- Единые стандарты: фронт и бэкенд всегда “говорят на одном языке”.


- Минимум дублирования: меньше багов, проще поддержка.


- Быстрый онбординг: новому разработчику легко понять, где искать нужный тип или утилиту.


- Гибкость: легко расширять проект, не боясь “сломать” что-то в другом модуле.

Чёткое разделение shared и common — это не формальность и не вопрос организации файловой системы. Это проявление зрелого инженерного мышления, осознанного подхода к переиспользованию и стратегической заботы о поддерживаемости и будущем продукта. Придерживаясь этого принципа, избавляетесь от хаоса и закладываете фундамент прозрачности кода — и ваш код станет чище, а команда — эффективнее.



2. DTO-first API: безопасность, прозрачность, удобство

2.1. Почему DTO-first?

- Безопасность: наружу никогда не уходит entity, только DTO. Даже если в entity появится новое поле (например, “isAdmin”), оно не “утечёт” наружу случайно.


- Явная структура: фронтенд и документация всегда знают, что вернёт API. Swagger всегда актуален.


- Гибкость: можно легко менять структуру ответа, не трогая базу. Например, добавить computed-поле или убрать лишнее.

2.2. Пример DTO для пользователя


// src/modules/users/dto/user-response.dto.ts import { ApiProperty } from '@nestjs/swagger';

export class UserResponseDto { @ApiProperty({ example: 1 }) id: number;

@ApiProperty({ example: 'user123' }) username: string; }

В сервисе:
async findAll(): Promise<UserResponseDto[]> { const users = await this.usersRepository.find(); return users.map(({ password, ...user }) => user as UserResponseDto); }

Примечание:

Пароль никогда не попадёт наружу, даже если кто-то забудет про exclude

DTO для параметров пагинации:


// src/shared/dto/paginate.dto.ts import { ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { IsPositive, Min } from 'class-validator';

export class PaginateDto { @ApiPropertyOptional({ example: 1, default: 1 }) @Type(() => Number) @IsPositive() page?: number = 1;

@ApiPropertyOptional({ example: 10, default: 10 }) @Type(() => Number) @Min(1) limit?: number = 10; }

DTO для результата пагинации:
// src/shared/dto/paginated-result.dto.ts export interface PaginatedResult<T> { items: T[]; total: number; page: number; limit: number; }

Использование в сервисе:
async findAllPaginated(page = 1, limit = 10): Promise<PaginatedResult<UserResponseDto>> { const [items, total] = await this.usersRepository.findAndCount({ skip: (page - 1) * limit, take: limit, order: { id: 'ASC' }, }); return { items: items.map(({ password, ...user }) => user as UserResponseDto), total, page, limit, }; }


Примечание: Пагинация становится стандартной и одинаковой для всех сущностей. Фронт всегда знает, что ожидать.

Вот расширенная и более глубокая версия главы о логировании, с примерами, практическими советами и акцентом на инженерную культуру:

---

3. Логирование: не только для дебага

Логирование — это не типа “выводить что-то в консоль”, чтобы отловить баг. Это полноценный инструмент управления проектом, который помогает видеть невидимое, анализировать поведение системы и принимать обоснованные решения.

Почему логирование — это важно

- Аудит: Логи фиксируют все значимые действия в системе — кто, когда и что сделал. Это важно для расследования инцидентов, анализа истории изменений и соответствия требованиям безопасности.


- Поиск и устранение ошибок: Хорошо структурированные логи позволяют быстро находить причину сбоя, даже если ошибка проявилась не сразу.

- Мониторинг и алерты: Логи — основа для систем мониторинга (например, Sentry, ELK, Prometheus), которые могут автоматически оповестить команду о критических ошибках или аномалиях.


- Аналитика и оптимизация: По логам можно анализировать производительность, выявлять узкие места, отслеживать частоту и причины ошибок, строить отчёты по бизнес-событиям.

Практика: как мы логируем

- Используем встроенный `Logger` из `@nestjs/common` — он интегрируется с экосистемой NestJS, поддерживает уровни логирования (log, error, warn, debug, verbose) и легко расширяется.


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


- В фильтрах ошибок обязательно логируем stack trace — это ускоряет диагностику и помогает понять, где именно произошёл сбой.


- Для сложных сценариев используем контекст (context) — чтобы в логах всегда было понятно, из какого модуля или сервиса пришло сообщение.

Пример:
import { Logger } from '@nestjs/common';

@Injectable() export class UsersService { private readonly logger = new Logger(UsersService.name);

async create(username: string, password: string): Promise<UserResponseDto> { this.logger.log(`Создание пользователя: ${username}`); try { // ... логика создания пользователя ... this.logger.log(`Пользователь успешно создан: ${username}`); } catch (error) { this.logger.error(`Ошибка при создании пользователя: ${username}`, error.stack); throw error; } } }

Советы и best practices

- Стандартизируйте формат логов: Используйте единый стиль сообщений, чтобы их было легко парсить и анализировать.


- Не логируйте чувствительные данные: Никогда не пишите в логи пароли, токены, персональные данные пользователей.


- Используйте уровни логирования: Для обычных событий — `log`, для подозрительных — `warn`, для ошибок — `error`, для отладки — `debug`.


- Интегрируйте логи с внешними системами: Используйте централизованные системы сбора логов (например, ELK, Graylog, Sentry), чтобы не терять важную информацию и быстро реагировать на инциденты.


- Добавляйте контекст: Указывайте, из какого модуля, сервиса или запроса пришёл лог — это ускоряет поиск нужной информации.

Логирование — не “дополнительная опция”, а неотъемлемая часть зрелого backend-проекта. Грамотно выстроенная система логов превращает хаос в управляемую систему, помогает команде быстрее реагировать на проблемы, анализировать поведение пользователей и строить действительно надёжные сервисы.

Инвестируйте время в логи — и они не раз спасут ваш проект.

Вот более развернутая, строгая и профессионально оформленная версия раздела о валидации и обработке ошибок, с акцентом на инженерную культуру и безопасность:

---

4. Валидация и обработка ошибок: доверяй, но проверяй

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

- Валидация данных: Для всех входных DTO мы используем возможности `class-validator`. Это гарантирует, что на уровень бизнес-логики никогда не попадут “грязные” или некорректные данные. Валидация происходит автоматически, а правила описываются декларативно прямо в классах DTO.

Пример DTO с валидацией:
import { IsString, MinLength } from 'class-validator';

export class CreateUserDto { @IsString() username: string;

@IsString() @MinLength(8) password: string; }

Такой подход устраняет ошибки на входе и превращает API в динамично проверяемый и самоописательный интерфейс, правила валидации становятся частью кода и документации одновременно. Это позволяет команде быстро внедрять изменения, интегрироваться без лишних согласований и строить надёжные сервисы с минимальными коммуникационными издержками.

Обработка ошибок: Все ошибки, возникающие в приложении, перехватываются глобальным фильтром.

Мы реализуем собственный фильтр (`HttpExceptionFilter`), который:


- Логирует ошибку и stack trace для последующего анализа.


- Формирует структурированный и предсказуемый ответ для клиента.


- Никогда не раскрывает внутренние детали реализации, стек вызовов или чувствительную информацию наружу.

Пример глобального фильтра ошибок:

@Catch(HttpException) export class HttpExceptionFilter implements ExceptionFilter { private readonly logger = new Logger(HttpExceptionFilter.name);

catch(exception: HttpException, host: ArgumentsHost) { // Логируем ошибку для внутреннего аудита this.logger.error(exception.message, exception.stack);

// Формируем безопасный и понятный ответ для клиента const ctx = host.switchToHttp(); const response = ctx.getResponse<Response>(); const status = exception.getStatus();

response.status(status).json({ statusCode: status, message: exception.message, // Можно добавить поле errors для передачи детальной информации о валидации }); } }

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

Жёсткая валидация и централизованная обработка ошибок — это не просто “best practice”, а фундамент надёжности, безопасности и предсказуемости вашего backend-приложения.

5. Swagger-документация: всегда актуальна

Зачем нам это нужно?

Представьте: новый разработчик приходит в команду и ему нужно разобраться с API. Или клиент хочет интегрироваться с вашим сервисом. Что они видят? Если это куча непонятных эндпоинтов без объяснений - это путь к бесконечным вопросам и ошибкам.

Swagger - это не документация, это ваш способ общения с внешним миром. И как любое общение, оно должно быть понятным и приятным.

Основные принципы

  • Полная документация: Каждый эндпоинт, DTO и модель должны быть полностью документированы с помощью Swagger-аннотаций
  • Автоматическая валидация: Документация проверяется автоматически при сборке проекта
  • Интерактивность: Swagger UI предоставляет возможность тестирования API прямо из браузера
  • Версионирование: Поддержка версионирования API с отдельной документацией для каждой версии

Как мы это делаем?

1. Пишем документацию как код

Каждый DTO - не просто класс, это история о том, что он делает:

@ApiProperty({ description: 'Идентификатор пользователя в системе', example: '507f1f77bcf86cd799439011', required: true }) userId: string;

Каждый эндпоинт - не просто метод, это инструкция:

@ApiOperation({ summary: 'Создание нового пользователя', description: 'Регистрирует нового пользователя в системе. Требуется подтверждение email.' }) @ApiResponse({ status: 201, description: 'Пользователь успешно создан', type: UserResponseDto })


2. Делаем документацию живой

- Примеры из реальной жизни: вместо абстрактных значений показываем реальные данные


- Ошибки и их решения: описываем успешные сценарии и что делать, если что-то пошло не так


- Секьюрность: объясняем, как работать с авторизацией, какие токены нужны и где их брать

3. Организуем информацию

Представьте, что вы заходите в библиотеку. Книги разложены по полкам, есть указатели, каталоги. Так же и с API:

- Группируем эндпоинты по смыслу (пользователи, платежи, отчеты)
- Добавляем теги для быстрой навигации
- Создаем иерархию: от общего к частному

4. Автоматизируем процесс

Документация - это не разовая акция, а постоянный процесс:

- В CI/CD проверяем, что документация соответствует коду
- При мердж-реквесте автоматически проверяем наличие описаний
- Генерируем клиентские SDK на основе документации

Что получаем в итоге?

1. Для команды:
- Меньше вопросов "а как это работает?"
- Быстрее ввод новых разработчиков
- Меньше ошибок при интеграции
- Понятный интерфейс для тестирования API
- Готовые примеры кода
- Четкое понимание ограничений и возможностей

2. Для бизнеса:
- Меньше времени на поддержку
- Более быстрая интеграция партнеров
- Профессиональный имидж

Советы из практики

1. Пишите для людей:
- Используйте простой язык
- Добавляйте примеры из реальной жизни
- Объясняйте неочевидные моменты

2. Держите в актуальном состоянии:
- Сделайте обновление документации частью процесса разработки
- Проверяйте актуальность при каждом изменении API
- Собирайте обратную связь от пользователей

3. Делайте красиво:
- Добавьте логотип и фирменные цвета
- Структурируйте информацию логично
- Используйте форматирование для лучшей читаемости

Важный совет:

Хорошая документация - это как хороший гид в незнакомом городе. Каждый перекресток (интеграция) снабжен понятными указателями, а все подводные камни (ограничения и особенности) заранее отмечены на карте. Хотите превратить вашу документацию из скучного справочника в увлекательный путеводитель? Стремитесь создавать документацию, которая не просто объясняет, а вдохновляет разработчиков на использование вашего API!

6. Безопасность: не доверяй даже себе

В современной разработке безопасность - это философия, которая должна пронизывать каждый аспект разработки. Давайте поговорим о том, как мы подходим к этому вопросу в нашем проекте.

Основной принцип, которым мы руководствуемся - это принцип минимальных привилегий. Каждый компонент системы должен иметь доступ только к тем ресурсам, которые ему действительно необходимы для работы. Это касается как кода, так и инфраструктуры.

Имея дело с аутентификацией, используй JWT с умом. Токены имеют ограниченное время жизни, регулярно обновляются, а их секреты хранятся в защищённом окружении. Но самое главное - мы никогда не доверяем клиенту полностью. Каждый запрос проходит через строгую валидацию и проверку прав доступа. Работа с паролями - это отдельная история. Мы хешируем их перед сохранением и используем современные алгоритмы с солью и итерациями.

Все секреты, ключи и конфигурации живут только в .env файлах, которые никогда не попадают в репозиторий. Для разработчиков мы предоставляем .env.example с безопасными тестовыми значениями.

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

Хранение паролей: только хеши, никогда исходные значения

Давайте разберем, как это работает на практике:


@Injectable() export class PasswordService { private readonly SALT_ROUNDS = 12;

async hashPassword(password: string): Promise<string> { // Генерируем уникальную соль для каждого пароля const salt = await bcrypt.genSalt(this.SALT_ROUNDS); // Хешируем пароль с солью return bcrypt.hash(password, salt); }

async verifyPassword(password: string, hash: string): Promise<boolean> { // Сравниваем введенный пароль с хешем из базы return bcrypt.compare(password, hash); } }

Когда пользователь регистрируется, мы сразу хешируем его пароль:


@Injectable() export class AuthService { async register(createUserDto: CreateUserDto) { const hashedPassword = await this.passwordService.hashPassword(createUserDto.password); const user = await this.userRepository.create({ ...createUserDto, password: hashedPassword // Сохраняем только хеш });

// Никогда не возвращаем пароль, даже хешированный const { password, ...safeUser } = user; return safeUser; } }

Этот подход обеспечивает несколько уровней защиты:

1. Даже если злоумышленник получит доступ к базе данных, он не сможет восстановить исходные пароли


2. Использование соли предотвращает атаки с использованием радужных таблиц


3. Современные алгоритмы хеширования:

Argon2 - победитель Password Hashing Competition 2015, предлагающий три варианта:

  • Argon2d - максимизирует устойчивость к GPU-атакам
  • Argon2i - оптимизирован для защиты от side-channel атак
  • Argon2id - гибридный подход, сочетающий преимущества обоих вариантов

scrypt - алгоритм, специально разработанный для противодействия аппаратным атакам, требующий большого объема памяти для вычислений.

PBKDF2 (Password-Based Key Derivation Function 2) - широко используемый стандарт, поддерживающий различные хеш-функции и итерации.

Balloon Hashing - современный алгоритм, использующий псевдослучайные перестановки памяти для создания "вычислительного барьера".

Важно отметить, что мы не только не храним пароли, но и не логируем их ни в каком виде. Даже в случае ошибок аутентификации мы записываем только факт попытки, без каких-либо деталей о введенных данных.

При аутентификации мы сравниваем хеш введенного пароля с хешем в базе:

@Injectable() export class AuthService { async hashPassword(password: string): Promise<string> { const salt = await bcrypt.genSalt(12); return bcrypt.hash(password, salt); }

async validateUser(email: string, password: string) { const user = await this.usersService.findByEmail(email); if (!user) return null; const isValid = await Argon2.verify(user.password, pass); if (!isValid) return null; // Никогда не возвращаем пароль const { password: _, ...safeUser } = user; return safeUser; } }

Этот подход к хранению паролей является отраслевым стандартом и обеспечивает надежную защиту данных пользователей даже в случае компрометации базы данных.



## Защита эндпоинтов

Защищенные маршруты используют кастомные guards и декораторы:


@UseGuards(JwtAuthGuard, RolesGuard) @Roles(UserRole.ADMIN) @Controller('admin') export class AdminController { @Get('users') async getUsers() { // Только админы могут получить доступ } }

Практические рекомендации:

1. Работа с секретами
- Используйте `.env` для всех конфиденциальных данных
- Регулярно ротируйте ключи и пароли
- Храните разные секреты для разных окружений

2. Защита от атак
- Внедрите rate limiting
- Настройте CORS политики
- Используйте helmet для HTTP-заголовков

3. Процессы разработки
- Включайте проверку безопасности в CI/CD
- Проводите регулярные аудиты кода
- Обучайте команду основам безопасностиё

Реальный пример защиты API:


@Controller('api/v1') export class SecureController { @UseGuards(JwtAuthGuard) @UseInterceptors(ClassSerializerInterceptor) @Get('user/profile') async getProfile(@CurrentUser() user: User) { // Сериализуем только безопасные поля return new UserResponseDto(user); }

@UseGuards(RolesGuard) @Roles(UserRole.ADMIN) @Post('admin/users') async createUser(@Body() createUserDto: CreateUserDto) { // Проверяем права и валидируем данные return this.userService.create(createUserDto); } }

Безопасность - это постоянный процесс улучшения и адаптации. не достаточно просто следовать лучшим практикам, следует развивать их и адаптировать под реальные нужды. Каждый день мы учимся чему-то новому и применяем эти знания для защиты наших систем и данных пользователей.

7. Тесты: не только для CI

В современной разработке тестирование эволюционировало из простой проверки кода в мощный инструмент обеспечения качества и надежности системы. Давайте посмотрим, как мы реализуем современный подход к тестированию.

- Для всех сервисов и контроллеров пишем unit- и e2e-тесты.
- Тесты проверяют не только happy path, но и ошибки, edge cases.
- Покрытие тестами — не самоцель, а инструмент уверенности в коде.

Пример современного подхода к написанию тестов:


import { Test, TestingModule } from '@nestjs/testing'; import { faker } from '@faker-js/faker'; import { PrismaService } from '@prisma/client'; import { UserService } from './user.service'; import { createMock } from '@golevelup/ts-jest'; import { mockDeep } from 'jest-mock-extended';

describe('UserService', () => { let service: UserService; let prisma: DeepMockProxy<PrismaService>;

beforeEach(async () => { prisma = mockDeep<PrismaService>(); const module: TestingModule = await Test.createTestingModule({ providers: [ UserService, { provide: PrismaService, useValue: prisma, }, ], }).compile();

service = module.get(UserService); });

it('should create user with valid data', async () => { const userData = { email: faker.internet.email(), password: faker.internet.password({ length: 12 }), };

prisma.user.create.mockResolvedValue({ id: faker.string.uuid(), ...userData, createdAt: faker.date.past(), updatedAt: faker.date.recent(), });

const result = await service.create(userData); expect(result).toMatchObject({ email: userData.email, }); expect(result.password).toBeUndefined(); }); });

Интеграция с CI/CD


# .github/workflows/test.yml name: Modern Test Pipeline on: [push, pull_request]

jobs: test: runs-on: ubuntu-latest strategy: matrix: node-version: [18.x, 20.x] steps: - uses: actions/checkout@v3 - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v3 with: node-version: ${{ matrix.node-version }} cache: 'npm' - name: Install dependencies run: npm ci - name: Run unit tests run: npm run test:unit env: NODE_ENV: test - name: Run e2e tests run: npm run test:e2e env: NODE_ENV: test TEST_DATABASE_URL: ${{ secrets.TEST_DATABASE_URL }} - name: Run mutation tests run: npm run test:mutation - name: Upload coverage uses: codecov/codecov-action@v3 with: token: ${{ secrets.CODECOV_TOKEN }} - name: Performance test run: npm run test:performance

Современное тестирование - это комплексный подход к обеспечению качества, включающий:
- Автоматизированное тестирование на разных уровнях
- Мониторинг производительности
- Проверку контрактов API
- Тестиование мутаций
- Интеграцию с современными инструментами CI/CD

Это позволяет находить ошибки и предотвращать их появление, обеспечивая высокое качество кода и надежность системы.

8. Docker и окружение: dev ≠ prod

8.1. Три контура — dev, stage, prod

Используем default + три docker-compose файла:


docker-compose.yml — базовый, описывает сервисы.


docker-compose.dev.yml — dev-override: монтирует исходники, пробрасывает порты, использует dev-окружение.


docker-compose.stage.yml и `docker-compose.prod.yml` — для stage и prod, с разными портами, переменными, настройками безопасности.

Пример:
# docker-compose.yml services: app: build: context: . dockerfile: Dockerfile depends_on: - db networks: - gprod-network db: image: postgres:13 volumes: - pgdata:/var/lib/postgresql/data networks: - gprod-network

volumes: pgdata:

networks: gprod-network: driver: bridge


# docker-compose.dev.yml services: app: env_file: - .env environment: - NODE_ENV=development - DATABASE_HOST=db - DATABASE_PORT=5432 - DATABASE_USER=${DEV_DATABASE_USER} - DATABASE_PASSWORD=${DEV_DATABASE_PASSWORD} - DATABASE_NAME=${DEV_DATABASE_NAME} - JWT_SECRET=${DEV_JWT_SECRET} ports: - "3000:3000" volumes: - .:/app - /app/node_modules depends_on: - db networks: - gprod-network

db: image: postgres:13 environment: - POSTGRES_USER=${DEV_DATABASE_USER} - POSTGRES_PASSWORD=${DEV_DATABASE_PASSWORD} - POSTGRES_DB=${DEV_DATABASE_NAME} ports: - "5432:5432" networks: - gprod-network

Пояснение: - Для каждого окружения свои переменные, свои порты, свои тома.
- В dev-контуре код монтируется внутрь контейнера для hot-reload.
- В prod — только собранный dist, никакого доступа к исходникам.

8.2. Работа с переменными окружения

- Используем утилиту getEnvVar для гибкой работы с переменными окружения с учётом контура (dev, stage, prod).


- Все секреты и ключи — только в .env, не коммитим в репозиторий.

Пример:

export function getEnvVar(configService: ConfigService, key: string, fallback?: string) { let env = (configService.get<string>('NODE_ENV') || process.env.NODE_ENV || 'development').toLowerCase(); if (env.startsWith('dev')) env = 'dev'; else if (env.startsWith('prod')) env = 'prod'; else if (env.startsWith('stag')) env = 'stage'; env = env.toUpperCase(); return ( configService.get<string>(`${env}_${key}`) || configService.get<string>(key) || fallback ); }

Это позволяет легко деплоить один и тот же код в разные окружения без правок.

Health-check и базовые контроллеры

- В проекте реализованы базовые контроллеры (например, app.controller.ts) для проверки работоспособности сервиса.


- DTO даже для простых ответов (например, HelloResponse) — это удобно для автотестов, мониторинга и Swagger.

9. Пошаговый чек-лист для внедрения best practices

1. Везде используем DTO, entity наружу не возвращаем.** 2. Пагинация и идентификаторы — только через shared/dto.** 3. Логирование — через Logger, логируем всё важное.** 4. Валидация — через class-validator, ошибки — через фильтр.** 5. Swagger — для всех DTO и эндпоинтов.** 6. Безопасность — не возвращаем пароли, используем guards.** 7. Тесты — для всего, что может сломаться.** 8. Docker — для всех окружений, переменные — через .env.** 9. Рефакторинг — всё общее в shared, всё техническое в common.** 10. Проверяем, что проект собирается, тесты проходят, Swagger актуален.** 11. Health-check эндпоинты — для мониторинга и CI/CD.** 12. Паттерн “конструктор с partial” — для entity.** 13. Гибкая работа с конфигами через ConfigService и утилиты.**

---

Бонус: советы из реального опыта

- Не бойтесь удалять и переписывать код. Лучший код — тот, который легко удалить или заменить.


- Документируйте не только API, но и архитектурные решения. Почему выбрали именно такой подход? Это поможет новичкам и будущим вам.


- Внедряйте CI/CD с самого начала. Даже простая проверка тестов и линтера в pull request спасёт от многих багов.


- Общайтесь с командой. Лучшие архитектурные решения рождаются в диалоге.


- Не забывайте про мониторинг и алерты. Логирование — это хорошо, но оповещения о сбоях — ещё лучше.

Финал

Мы построили backend, который:
- легко поддерживать и расширять,
- безопасен и прозрачен для команды,
- удобен для фронта и мобильных приложений,
- легко масштабируется и деплоится в любое окружение.

Этот опыт — результат десятков итераций, исправления ошибок, внедрения best practices и постоянного рефакторинга. Следуйте этим принципам — и ваш проект будет радовать и команду, и бизнес!

Добро пожаловать в будущее !