Поделиться
Поделиться

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 для разработчика.