В 2026 году разработка микросервисов и API на Node.js ушла далеко вперед от монолитных обработчиков на устаревшем Express. Индустриальным стандартом стал Fastify 5. Благодаря компиляции схем валидации и Pino-логированию он опережает Express в 3–5 раз по пропускной способности. В связке с трехзвенной слоистой архитектурой, валидацией Zod и корректным Graceful Shutdown вы получаете надежный сервис корпоративного уровня.
Проекты, написанные без четкого разделения ответственности и культуры управления процессами, страдают от тяжелых производственных дефектов:
unhandledRejection) способен «уронить» экземпляр Node.js, прервав обслуживание сотен параллельных клиентов.SIGTERM и SIGINT, оркестратор (Kubernetes / Docker) мгновенно убивает контейнер посреди транзакции записи в базу данных, приводя к повреждению данных и ошибкам 502 Bad Gateway у пользователей.Поток управления входящего HTTP-запроса проходит через строго разграниченные слои:
[ Входящий HTTP-запрос ]
│
▼
┌──────────────────────────────────────────┐
│ Transport & Routing (Fastify Engine) │
│ - Парсинг URL, заголовков и Body │
│ - Валидация Zod / JSON Schema │
└─────────────────┬────────────────────────┘
│ Валидированный DTO
▼
┌──────────────────────────────────────────┐
│ Controller Layer (Обработчики роутов) │
│ - Извлечение параметров req.params/body │
│ - Вызов методов соответствующего Service│
│ - Формирование HTTP-статуса и ответа │
└─────────────────┬────────────────────────┘
│ Чистые аргументы предметной области
▼
┌──────────────────────────────────────────┐
│ Service Layer (Бизнес-логика) │
│ - Проверка бизнес-правил и инвариантов │
│ - Расчеты, вызовы сторонних API │
│ - Управление транзакциями │
└─────────────────┬────────────────────────┘
│ Команды выборки / сохранения
▼
┌──────────────────────────────────────────┐
│ Repository Layer (Доступ к данным) │
│ - Абстракция над СУБД (Postgres, Redis) │
│ - Выполнение SQL / In-Memory операций │
└──────────────────────────────────────────┘Установка Fastify 5, pino, pino-pretty, zod, fastify-type-provider-zod и TypeScript компилятора.
Настройка Fastify с Zod Type Provider для автоматического вывода типов TypeScript из схем валидации роутов.
Создание директорий модуля: schema.ts, repository.ts, service.ts и controller.ts с разделением ответственности.
Регистрация контроллеров через app.register() с изолированным префиксом URL (/api/v1/posts).
Подписка на сигналы SIGINT и SIGTERM для корректного закрытия соединений и остановки сервера без разрыва активных запросов.
Как устроена модель инкапсуляции плагинов в Fastify и почему вызов app.register() по умолчанию изолирует контекст?
Сервер принимает сокетное соединение, парсит заголовки и инициирует сквозной Request ID для структурированного Pino-трейсинга.
Каждый лог автоматически содержит поле reqId для мгновенной корреляции в Kibana/Grafana.
Тело запроса (Body), URL-параметры (Params) и строка запроса (Query) валидируются схемой Zod до вызова обработчика.
Если клиент прислал невалидные данные, Fastify мгновенно вернет статус 400 со списком ошибок без нагрузки на сервис.
Контроллер распаковывает типизированный DTO и вызывает методы сервиса, оперирующего бизнес-правилами и репозиторием.
Контроллер не содержит SQL-запросов и логики валидации цен, он лишь управляет HTTP-транспортом.
Ответ сериализуется оптимизированной схемой и отдается клиенту с HTTP-статусом (200 OK или 201 Created).
Схемная сериализация вдвое быстрее нативного JSON.stringify за счет генерации специализированного JS-кода.
Ниже представлены исходные файлы модульного REST API.
import { z } from 'zod';
// Схема создания публикации
export const CreatePostSchema = z.object({
title: z.string().min(5, 'Title must be at least 5 characters long').max(120),
content: z.string().min(20, 'Content must be at least 20 characters long'),
tags: z.array(z.string().min(2)).default([]),
published: z.boolean().default(false),
});
export type CreatePostInput = z.infer<typeof CreatePostSchema>;
// Схема URL-параметров
export const PostParamsSchema = z.object({
id: z.string().uuid('Post ID must be a valid UUID'),
});
export type PostParams = z.infer<typeof PostParamsSchema>;
// Схема ответа отдельной публикации
export const PostResponseSchema = z.object({
id: z.string().uuid(),
title: z.string(),
content: z.string(),
tags: z.array(z.string()),
published: z.boolean(),
createdAt: z.string().datetime(),
});
export type PostResponse = z.infer<typeof PostResponseSchema>;import { randomUUID } from 'node:crypto';
import { CreatePostInput, PostResponse } from './posts.schema';
export interface IPostsRepository {
findAll(): Promise<PostResponse[]>;
findById(id: string): Promise<PostResponse | null>;
create(data: CreatePostInput): Promise<PostResponse>;
delete(id: string): Promise<boolean>;
}
export class InMemoryPostsRepository implements IPostsRepository {
private posts: Map<string, PostResponse> = new Map();
public async findAll(): Promise<PostResponse[]> {
return Array.from(this.posts.values());
}
public async findById(id: string): Promise<PostResponse | null> {
return this.posts.get(id) ?? null;
}
public async create(data: CreatePostInput): Promise<PostResponse> {
const post: PostResponse = {
id: randomUUID(),
title: data.title,
content: data.content,
tags: data.tags,
published: data.published,
createdAt: new Date().toISOString(),
};
this.posts.set(post.id, post);
return post;
}
public async delete(id: string): Promise<boolean> {
return this.posts.delete(id);
}
}import { IPostsRepository } from './posts.repository';
import { CreatePostInput, PostResponse } from './posts.schema';
export class PostNotFoundError extends Error {
constructor(id: string) {
super(`Post with ID '${id}' was not found`);
this.name = 'PostNotFoundError';
}
}
export class PostsService {
constructor(private readonly repository: IPostsRepository) {}
public async getAllPosts(): Promise<PostResponse[]> {
return this.repository.findAll();
}
public async getPostById(id: string): Promise<PostResponse> {
const post = await this.repository.findById(id);
if (!post) {
throw new PostNotFoundError(id);
}
return post;
}
public async createPost(input: CreatePostInput): Promise<PostResponse> {
// Бизнес-правило: если заголовок содержит слово 'draft', принудительно снимаем публикацию
const isDraft = input.title.toLowerCase().includes('draft');
const sanitizedInput: CreatePostInput = {
...input,
published: isDraft ? false : input.published,
};
return this.repository.create(sanitizedInput);
}
public async removePost(id: string): Promise<void> {
const deleted = await this.repository.delete(id);
if (!deleted) {
throw new PostNotFoundError(id);
}
}
}import { FastifyPluginAsyncZod } from 'fastify-type-provider-zod';
import { z } from 'zod';
import { PostsService, PostNotFoundError } from './posts.service';
import {
CreatePostSchema,
PostParamsSchema,
PostResponseSchema,
} from './posts.schema';
export function createPostsController(service: PostsService): FastifyPluginAsyncZod {
return async (app) => {
// Получение списка всех постов
app.get(
'/',
{
schema: {
response: {
200: z.array(PostResponseSchema),
},
},
},
async (_request, reply) => {
const posts = await service.getAllPosts();
return reply.status(200).send(posts);
}
);
// Получение одного поста по UUID
app.get(
'/:id',
{
schema: {
params: PostParamsSchema,
response: {
200: PostResponseSchema,
404: z.object({ message: z.string() }),
},
},
},
async (request, reply) => {
try {
const post = await service.getPostById(request.params.id);
return reply.status(200).send(post);
} catch (err: unknown) {
if (err instanceof PostNotFoundError) {
return reply.status(404).send({ message: err.message });
}
throw err;
}
}
);
// Создание новой публикации
app.post(
'/',
{
schema: {
body: CreatePostSchema,
response: {
201: PostResponseSchema,
},
},
},
async (request, reply) => {
const createdPost = await service.createPost(request.body);
return reply.status(201).send(createdPost);
}
);
};
}import Fastify from 'fastify';
import {
serializerCompiler,
validatorCompiler,
ZodTypeProvider,
} from 'fastify-type-provider-zod';
import { InMemoryPostsRepository } from './modules/posts/posts.repository';
import { PostsService } from './modules/posts/posts.service';
import { createPostsController } from './modules/posts/posts.controller';
const PORT = Number(process.env.PORT ?? 3000);
const HOST = process.env.HOST ?? '0.0.0.0';
export async function buildApp() {
const app = Fastify({
logger: {
level: process.env.LOG_LEVEL ?? 'info',
transport:
process.env.NODE_ENV !== 'production'
? { target: 'pino-pretty', options: { colorize: true } }
: undefined,
},
disableRequestLogging: false,
}).withTypeProvider<ZodTypeProvider>();
// Настройка компиляторов Zod
app.setValidatorCompiler(validatorCompiler);
app.setSerializerCompiler(serializerCompiler);
// Инициализация слоев зависимостей
const postsRepository = new InMemoryPostsRepository();
const postsService = new PostsService(postsRepository);
const postsController = createPostsController(postsService);
// Регистрация модуля
await app.register(postsController, { prefix: '/api/v1/posts' });
// Healthcheck эндпоинт
app.get('/health', async () => ({ status: 'healthy', timestamp: new Date().toISOString() }));
return app;
}
async function start() {
const app = await buildApp();
try {
await app.listen({ port: PORT, host: HOST });
app.log.info(`Server listening on http://${HOST}:${PORT}`);
} catch (err: unknown) {
app.log.error(err);
process.exit(1);
}
// Graceful Shutdown обработчик
const stopSignals: NodeJS.Signals[] = ['SIGINT', 'SIGTERM'];
for (const signal of stopSignals) {
process.on(signal, async () => {
app.log.warn(`Received signal ${signal}. Starting graceful shutdown...`);
try {
await app.close();
app.log.info('Fastify server closed cleanly.');
process.exit(0);
} catch (err: unknown) {
app.log.error(err, 'Error during graceful shutdown');
process.exit(1);
}
});
}
}
void start();Node.js работает в однопоточном цикле событий (Event Loop). Использование вызовов fs.readFileSync() или парсинг огромных JSON-строк (более 10 МБ) через JSON.parse() полностью замораживает процесс, запрещая серверу обрабатывать любые другие входящие HTTP-запросы. Всегда используйте асинхронные методы fs.promises или потоковые парсеры (Streams).
Регистрация плагинов в Fastify является асинхронной операцией. Если написать app.register(plugin) без await внутри функции инициализации, сервер может начать слушать сокет (app.listen()) до того, как маршруты и декораторы будут полностью смонтированы, что приведет к неожиданным 404 ошибкам при первом обращении.
При получении сигнала завершения SIGTERM в контейнере Docker вызов мгновенного process.exit(0) разрывает все текущие активные HTTP-соединения и транзакции БД. Всегда вызывайте await app.close(). Метод close() перестает принимать новые запросы, дожидается завершения текущих (in-flight requests) и корректно освобождает порт.
Отправка клиенту оригинального error.stack или SQL-запроса при падении сервера раскрывает злоумышленникам структуру таблиц и версии библиотек. В продакшене перехватывайте глобальные ошибки через app.setErrorHandler(), логируйте полный трейс в Pino и возвращайте клиенту обезличенный ответ { error: 'Internal Server Error', statusCode: 500 }.
Оркестратор Kubernetes отправляет сигнал SIGTERM контейнеру с запущенным Fastify API для проведения планового обновления. Что произойдет, если в приложении настроен вызов await app.close()?