Node.js backend в 2026 — это не «просто запустить Express». За годы в экосистеме накопились проверенные паттерны: чёткая архитектура, которую не страшно поддерживать, TypeScript с настройками без поблажек, правильная обработка async-ошибок и инструменты для поиска проблем производительности до того, как они попали в production.
Структура проекта: слоистая архитектура
Хаотичная структура файлов убивает продуктивность команды быстрее любого технического долга. Чёткое разделение на слои решает это.
src/
├── config/ # Конфигурация, env-переменные
├── controllers/ # HTTP-слой: роуты, валидация запроса/ответа
├── services/ # Бизнес-логика (доменный слой)
├── repositories/ # Работа с данными (БД, кеш)
├── middleware/ # Auth, logging, error handling
├── types/ # TypeScript-типы и интерфейсы
├── utils/ # Утилиты без бизнес-логики
└── tests/ # Тесты рядом с кодом или здесь
Пример: контроллер → сервис → репозиторий
// repositories/user.repository.ts
// Только работа с данными, никакой логики
export class UserRepository {
constructor(private readonly db: Prisma) {}
async findById(id: string): Promise<User | null> {
return this.db.user.findUnique({ where: { id } });
}
async findByEmail(email: string): Promise<User | null> {
return this.db.user.findUnique({ where: { email } });
}
async create(data: CreateUserDTO): Promise<User> {
return this.db.user.create({ data });
}
}
// services/user.service.ts
// Бизнес-логика, оркестрация репозиториев
export class UserService {
constructor(
private readonly userRepo: UserRepository,
private readonly emailService: EmailService,
private readonly cache: CacheService
) {}
async registerUser(dto: RegisterUserDTO): Promise<User> {
// Проверяем уникальность email
const existing = await this.userRepo.findByEmail(dto.email);
if (existing) {
throw new ConflictError("Email already registered");
}
// Хешируем пароль
const passwordHash = await bcrypt.hash(dto.password, 12);
// Создаём пользователя
const user = await this.userRepo.create({
email: dto.email,
name: dto.name,
passwordHash
});
// Side effects — не блокируем основной поток
this.emailService.sendWelcome(user.email, user.name).catch(
(err) => logger.error("Failed to send welcome email", { userId: user.id, err })
);
return user;
}
}
// controllers/user.controller.ts
// Только HTTP: парсинг запроса, вызов сервиса, формирование ответа
export async function registerUser(
req: FastifyRequest<{ Body: RegisterUserDTO }>,
reply: FastifyReply
): Promise<void> {
const user = await userService.registerUser(req.body);
reply.code(201).send({
id: user.id,
email: user.email,
name: user.name,
createdAt: user.createdAt
});
}
Fastify vs Express: что выбрать в 2026
| Критерий | Express | Fastify | |---|---|---| | Requests/sec (JSON) | ~15k | ~55k | | JSON сериализация | JSON.stringify | fast-json-stringify (схема) | | Валидация | Нет (нужен joi/zod) | Встроенная (JSON Schema) | | TypeScript | Частичный | Полноценный | | Плагины | Тысячи, разное качество | Экосистема меньше, но стабильнее | | Learning curve | Минимальный | Средний |
Для новых проектов рекомендуем Fastify:
import Fastify from "fastify";
import { Type, Static } from "@sinclair/typebox";
const app = Fastify({
logger: { level: "info" },
ajv: { customOptions: { strict: false } }
});
// Схема — документация + валидация + быстрая сериализация в одном месте
const RegisterBody = Type.Object({
email: Type.String({ format: "email" }),
name: Type.String({ minLength: 2, maxLength: 100 }),
password: Type.String({ minLength: 8 })
});
const UserResponse = Type.Object({
id: Type.String(),
email: Type.String(),
name: Type.String(),
createdAt: Type.String()
});
app.post<{ Body: Static<typeof RegisterBody> }>(
"/users",
{
schema: {
body: RegisterBody,
response: { 201: UserResponse }
}
},
registerUser
);
TypeScript: строгий режим без компромиссов
// tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noUncheckedIndexedAccess": true, // arr[i] → T | undefined
"noImplicitOverride": true,
"exactOptionalPropertyTypes": true,
"noPropertyAccessFromIndexSignature": true,
"useUnknownInCatchVariables": true, // err в catch → unknown
"outDir": "dist",
"rootDir": "src",
"paths": {
"@/*": ["./src/*"]
}
}
}
useUnknownInCatchVariables — особенно важная настройка:
// Без strict: err — any, можно случайно обратиться к несуществующему полю
// Со strict: err — unknown, заставляет проверять тип
async function safeOperation(): Promise<Result> {
try {
return await riskyOperation();
} catch (err: unknown) {
// Нужно явно проверить тип
if (err instanceof AppError) {
logger.warn(err.message, { code: err.code });
throw err;
}
if (err instanceof Error) {
logger.error("Unexpected error", { message: err.message, stack: err.stack });
}
throw new InternalError("Unknown error occurred");
}
}
Async error handling: без необработанных rejection
// middleware/error-handler.ts
import { FastifyError, FastifyRequest, FastifyReply } from "fastify";
export function errorHandler(
error: FastifyError | Error,
req: FastifyRequest,
reply: FastifyReply
): void {
// Fastify validation error
if ("statusCode" in error && error.statusCode === 400) {
reply.status(400).send({
error: "Validation Error",
details: error.message
});
return;
}
if (error instanceof NotFoundError) {
reply.status(404).send({ error: error.message });
return;
}
if (error instanceof ConflictError) {
reply.status(409).send({ error: error.message });
return;
}
// Неожиданные ошибки — логируем и скрываем детали от клиента
logger.error("Unhandled error", {
error: error.message,
stack: error.stack,
url: req.url,
method: req.method
});
reply.status(500).send({ error: "Internal Server Error" });
}
// Глобальные обработчики — последний рубеж
process.on("unhandledRejection", (reason, promise) => {
logger.error("Unhandled Rejection", { reason, promise });
// Graceful shutdown, не process.exit(1) сразу
gracefulShutdown("unhandledRejection");
});
process.on("uncaughtException", (error) => {
logger.error("Uncaught Exception", { error });
gracefulShutdown("uncaughtException");
});
Тестирование с Vitest
Vitest — современная альтернатива Jest: быстрее, нативный TypeScript, совместимый API.
// services/__tests__/user.service.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest";
import { UserService } from "../user.service";
import { UserRepository } from "../../repositories/user.repository";
// Автоматический мок модуля
vi.mock("../../repositories/user.repository");
describe("UserService", () => {
let userService: UserService;
let mockUserRepo: vi.Mocked<UserRepository>;
let mockEmailService: { sendWelcome: vi.Mock };
beforeEach(() => {
mockUserRepo = new UserRepository({} as any) as vi.Mocked<UserRepository>;
mockEmailService = { sendWelcome: vi.fn().mockResolvedValue(undefined) };
userService = new UserService(mockUserRepo, mockEmailService as any, {} as any);
});
describe("registerUser", () => {
it("creates user when email is unique", async () => {
mockUserRepo.findByEmail.mockResolvedValue(null);
mockUserRepo.create.mockResolvedValue({
id: "user-123",
email: "test@example.com",
name: "Test User",
createdAt: new Date()
} as any);
const result = await userService.registerUser({
email: "test@example.com",
name: "Test User",
password: "securepass123"
});
expect(result.id).toBe("user-123");
expect(mockUserRepo.create).toHaveBeenCalledOnce();
});
it("throws ConflictError when email exists", async () => {
mockUserRepo.findByEmail.mockResolvedValue({ id: "existing" } as any);
await expect(
userService.registerUser({
email: "taken@example.com",
name: "User",
password: "pass12345"
})
).rejects.toThrow(ConflictError);
});
});
});
Worker Threads для CPU-задач
Node.js — однопоточный. Тяжёлые вычисления (парсинг CSV, генерация отчётов, шифрование) блокируют event loop.
// workers/report-generator.worker.ts
import { parentPort, workerData } from "worker_threads";
// Этот код выполняется в отдельном потоке
async function generateReport(data: ReportData): Promise<ReportResult> {
// Тяжёлые вычисления не блокируют основной поток
const processed = data.rows.map(row => ({
...row,
metrics: calculateComplexMetrics(row)
}));
return { rows: processed, generatedAt: new Date().toISOString() };
}
generateReport(workerData).then(result => {
parentPort!.postMessage({ success: true, result });
}).catch(err => {
parentPort!.postMessage({ success: false, error: err.message });
});
// services/report.service.ts — запуск воркера из основного потока
import { Worker } from "worker_threads";
import path from "path";
export function generateReportInWorker(data: ReportData): Promise<ReportResult> {
return new Promise((resolve, reject) => {
const worker = new Worker(
path.join(__dirname, "../workers/report-generator.worker.js"),
{ workerData: data }
);
worker.on("message", ({ success, result, error }) => {
if (success) resolve(result);
else reject(new Error(error));
});
worker.on("error", reject);
worker.on("exit", (code) => {
if (code !== 0) reject(new Error(`Worker exited with code ${code}`));
});
});
}
Для частых коротких задач используйте пул воркеров (workerpool или piscina) вместо создания нового воркера на каждый запрос.
Поиск утечек памяти
Утечки памяти в Node.js — коварная проблема: процесс работает неделями, потом внезапно падает.
// Диагностика утечки
import v8 from "v8";
import { writeFileSync } from "fs";
// Снимаем heap snapshot для анализа в Chrome DevTools
app.get("/debug/heap-snapshot", (req, reply) => {
const snapshotStream = v8.writeHeapSnapshot();
reply.send({ file: snapshotStream });
});
// Мониторинг памяти каждые 30 секунд
setInterval(() => {
const mem = process.memoryUsage();
logger.info("Memory usage", {
heapUsed: Math.round(mem.heapUsed / 1024 / 1024) + " MB",
heapTotal: Math.round(mem.heapTotal / 1024 / 1024) + " MB",
rss: Math.round(mem.rss / 1024 / 1024) + " MB",
external: Math.round(mem.external / 1024 / 1024) + " MB"
});
}, 30_000);
Частые причины утечек:
EventEmitterбез удаления listener-ов (removeListener)- Замыкания, удерживающие большие объекты
- Растущий
Map/Setбез удаления старых записей - Неочищенные
setInterval/setTimeout - Накопление в глобальных объектах (логеры с буферами)
// Антипаттерн: утечка через EventEmitter
function problematicSetup() {
const bigData = new Array(100000).fill("data"); // 100k элементов
emitter.on("data", () => {
// bigData захвачено в замыкании и никогда не освобождается
console.log(bigData.length);
});
// listener никогда не удаляется!
}
// Правильно: cleanup
function safeSetup() {
const bigData = new Array(100000).fill("data");
const handler = () => console.log(bigData.length);
emitter.on("data", handler);
// Очищаем при завершении работы
return () => emitter.off("data", handler);
}
Итог
Хороший Node.js backend — это прежде всего предсказуемая архитектура. Разделение на контроллеры/сервисы/репозитории, TypeScript strict mode и централизованная обработка ошибок экономят часы на отладке. Fastify даёт производительность без дополнительных усилий, а Vitest — быстрые тесты без боли с конфигурацией.
Про организацию CI/CD для Node.js проектов читайте в GitLab CI/CD pipeline. Про Docker-окружение для локальной разработки — Docker Compose для разработчика.

