← назад к разделу

Fastify подходит, когда нужен HTTP API на Node.js без архитектурного каркаса NestJS. Он даёт маршруты, JSON Schema-валидацию, хуки, логирование и тестирование через inject, а модули приложения и внедрение зависимостей команда проектирует сама.

Соберём небольшой production-контур: от валидации входа до закрытия приложения.

Установка и точка сборки

npm install fastify
npm install -D typescript tsx @types/node

Не создавайте Fastify-приложение глобальным синглтоном в каждом файле. Точка сборки принимает зависимости и возвращает готовый экземпляр:

import Fastify, { LogController } from "fastify";

export interface ProductRepository {
  create(input: { name: string; price: number }): Promise<{
    id: number;
    name: string;
    price: number;
  }>;
  byId(id: number): Promise<{
    id: number;
    name: string;
    price: number;
  } | null>;
}

export function buildApp(repository: ProductRepository) {
  const app = Fastify({
    logger: {
      level: process.env.LOG_LEVEL ?? "info",
    },
    logController: new LogController({
      disableRequestLogging: process.env.NODE_ENV === "test",
    }),
    ajv: {
      customOptions: { removeAdditional: false },
    },
  });

  app.post("/products", {
    schema: {
      body: {
        type: "object",
        required: ["name", "price"],
        additionalProperties: false,
        properties: {
          name: { type: "string", minLength: 1, maxLength: 300 },
          price: { type: "number", exclusiveMinimum: 0 },
        },
      },
    },
  }, async (request, reply) => {
    const product = await repository.create(request.body);
    return reply.code(201).send(product);
  });

  return app;
}

Тип request.body выводится из схемы. При необходимости его можно дополнительно описать через generic маршрута, но источником правды по формату входа остаётся JSON Schema.

Валидация отличается от NestJS

В NestJS часто используют DTO-классы и class-validator. Fastify работает с JSON Schema напрямую:

  • required перечисляет обязательные поля;
  • additionalProperties: false запрещает лишние поля в схеме;
  • minLength, exclusiveMinimum, minimum, maximum задают ограничения;
  • params, querystring, headers валидируются так же, как body;
  • response описывает формат ответа и уменьшает объём сериализации.

По умолчанию Ajv в Fastify удаляет дополнительные свойства из тела, а не отклоняет запрос. Поэтому в примере задано removeAdditional: false: тогда лишнее поле возвращает 400 VALIDATION_ERROR, а не исчезает молча.

Пример маршрута с параметром:

app.get("/products/:id", {
  schema: {
    params: {
      type: "object",
      required: ["id"],
      properties: {
        id: { type: "integer", minimum: 1 },
      },
    },
  },
}, async (request) => {
  return repository.byId(request.params.id);
});

Ошибка валидации возвращается клиенту до вызова репозитория. Это не просто проверка типов: она защищает границу приложения.

Авторизация через preHandler

Проверку API-ключа или JWT выносят в отдельный хук и подключают только к защищённым маршрутам:

import type { FastifyReply, FastifyRequest } from "fastify";

export async function requireApiKey(
  request: FastifyRequest,
  reply: FastifyReply,
) {
  if (request.headers["x-api-key"] !== process.env.API_KEY) {
    return reply.code(401).send({
      error: {
        code: "UNAUTHORIZED",
        message: "Недействительный API-ключ",
      },
    });
  }
}
app.post("/products", {
  preHandler: requireApiKey,
  schema: createProductSchema,
}, createProductHandler);

Не включайте проверку глобально на /health и /ready: оркестратор должен видеть состояние процесса без пользовательского токена. Право на действие проверяют после идентификации, отдельно от проверки самого токена.

Единый формат ошибок

Доменная ошибка не должна превращаться в случайный HTML или stack trace. Добавьте тип ошибки и центральный обработчик:

export class ProductNotFound extends Error {}

app.setErrorHandler((error, request, reply) => {
  if (error instanceof ProductNotFound) {
    return reply.code(404).send({
      error: { code: "PRODUCT_NOT_FOUND", message: error.message },
    });
  }

  if (error.validation) {
    return reply.code(400).send({
      error: { code: "VALIDATION_ERROR", message: error.message },
    });
  }

  request.log.error(error);
  return reply.code(500).send({
    error: { code: "INTERNAL_ERROR", message: "Внутренняя ошибка" },
  });
});

Пользователь видит стабильный код ошибки, а детали остаются в логах. Не возвращайте секреты, SQL и внутренний стек.

Тестирование без сетевого порта

app.inject() выполняет запрос внутри процесса. Это быстрее сетевых тестов и не требует выделять порт:

import { afterEach, describe, expect, it } from "@jest/globals";

describe("POST /products", () => {
  const app = buildApp(new MemoryProductRepository());

  afterEach(async () => {
    await app.close();
  });

  it("создаёт товар", async () => {
    const response = await app.inject({
      method: "POST",
      url: "/products",
      headers: { "x-api-key": process.env.API_KEY },
      payload: { name: "Кружка", price: 100 },
    });

    expect(response.statusCode).toBe(201);
    expect(response.json()).toMatchObject({
      name: "Кружка",
      price: 100,
    });
  });

  it("отклоняет лишнее поле", async () => {
    const response = await app.inject({
      method: "POST",
      url: "/products",
      payload: { name: "Кружка", price: 100, role: "admin" },
    });

    expect(response.statusCode).toBe(400);
    expect(response.json().error.code).toBe("VALIDATION_ERROR");
  });
});

MemoryProductRepository подходит для проверки HTTP-контракта. Интеграцию с PostgreSQL проверяют отдельно на настоящей базе через Testcontainers: один тест не заменяет другой.

Корректное завершение

При SIGTERM перестают принимать новые соединения и дожидаются текущих операций:

async function shutdown() {
  await app.close();
  await repository.close();
}

process.on("SIGTERM", () => {
  void shutdown().finally(() => process.exit(0));
});

Сначала закрывают HTTP-вход, затем пулы и воркеры. Не вызывайте process.exit до завершения app.close, иначе активные запросы оборвутся.

Когда выбрать Fastify, а когда NestJS

Fastify уместен для небольшого API, изолированного сервиса или команды, которая хочет минимальную инфраструктуру и понятные JSON Schema-контракты.

NestJS уместен для большого приложения с модулями, DI, декораторами, guards, interceptors и общей архитектурой на многие разработчики.

Fastify можно использовать и как транспорт внутри NestJS. Выбор между ними не обязан быть выбором между скоростью и порядком: разница прежде всего в количестве архитектуры, которую команда хочет получить из коробки.

Коротко

  • Fastify валидирует вход через JSON Schema, без DTO и декораторов NestJS.
  • preHandler подключает авторизацию только к нужным маршрутам.
  • Ошибки приводят к стабильному формату, а внутренние детали оставляют в логах.
  • app.inject() тестирует HTTP без открытия порта, PostgreSQL проверяют через Testcontainers.
  • app.close() ждёт завершение HTTP-контура, затем закрывают ресурсы приложения.

Что почитать дальше