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, которые используются в нескольких модулях. Здесь живут те сущности, которые отражают бизнес-реальность и нужны сразу в нескольких частях приложения.
- 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/` — это технический фундамент приложения. Здесь лежит всё то, что не связано напрямую с бизнес-логикой, но обеспечивает стабильную работу, безопасность и удобство разработки.
- Глобальные фильтры ошибок (`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
// 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;
}
}
}
- Стандартизируйте формат логов: Используйте единый стиль сообщений, чтобы их было легко парсить и анализировать.
- Не логируйте чувствительные данные: Никогда не пишите в логи пароли, токены, персональные данные пользователей.
- Используйте уровни логирования: Для обычных событий — `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 с отдельной документацией для каждой версии
Как мы это делаем?
Каждый DTO - не просто класс, это история о том, что он делает:@ApiProperty({
description: 'Идентификатор пользователя в системе',
example: '507f1f77bcf86cd799439011',
required: true
})
userId: string;
Каждый эндпоинт - не просто метод, это инструкция:@ApiOperation({
summary: 'Создание нового пользователя',
description: 'Регистрирует нового пользователя в системе. Требуется подтверждение email.'
})
@ApiResponse({
status: 201,
description: 'Пользователь успешно создан',
type: UserResponseDto
})
- Примеры из реальной жизни: вместо абстрактных значений показываем реальные данные
- Ошибки и их решения: описываем успешные сценарии и что делать, если что-то пошло не так
- Секьюрность: объясняем, как работать с авторизацией, какие токены нужны и где их брать
Представьте, что вы заходите в библиотеку. Книги разложены по полкам, есть указатели, каталоги. Так же и с API:
- Группируем эндпоинты по смыслу (пользователи, платежи, отчеты)
- Добавляем теги для быстрой навигации
- Создаем иерархию: от общего к частному
Документация - это не разовая акция, а постоянный процесс:
- В 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();
});
});
# .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
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 и постоянного рефакторинга. Следуйте этим принципам — и ваш проект будет радовать и команду, и бизнес!