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-контура, затем закрывают ресурсы приложения.
Что почитать дальше
- Контроллеры и маршрутизация NestJS — Express, Fastify и архитектурный слой NestJS.
- Тестирование NestJS — тестовый модуль и e2e-сценарии.
- Интеграционные тесты Node.js — Testcontainers и настоящая база.