
Middleware в Express превратился в источник скрытых зависимостей и неявных модификаций объектов request-response. Это создаёт проблемы с трассировкой данных и тестированием. Современный подход — явная передача контекста вместо расширения глобальных объектов.
В этой статье разберём, почему паттерн middleware стал антипаттерном, как это влияет на архитектуру приложений, и как мигрировать на Fastify с явной передачей контекста. Это касается каждого, кто серьёзно относится к качеству кода.
Почему middleware Express это проблема
В Express middleware работает как цепочка ответственности из паттернов Gang of Four. Звучит красиво, но на практике каждый middleware может незаметно добавить данные в объект request или response. Ты пишешь обработчик маршрута и не знаешь, какие поля и методы уже добавили предыдущие middleware.
Представь: логирование добавляет req.id, аутентификация добавляет req.user, валидация добавляет req.validated, а какой-то свой middleware добавляет req.context. Всё это неявно, в разных местах кода. Когда ты ищешь баг или пишешь тесты, ты должен помнить о всех этих скрытых расширениях. Это просто боль.
Вот к чему это приводит на практике:
- Неявные зависимости: функция зависит от данных в
req, но это не видно из сигнатуры функции - Сложность тестирования: нужно мокировать весь объект request со всеми добавленными полями
- Проблемы с типизацией: TypeScript не может проверить, какие свойства есть на request в каждой точке цепочки
- Трудность отладки: откуда взялось это поле на объекте? Какой middleware его добавил? Нужно искать по всему коду
- Хрупкость кода: добавил новый middleware — и вдруг что-то сломалось, потому что он перезаписал какое-то поле
От неявности к явности: как работает контекст
Вместо того чтобы полагаться на неявное расширение объектов, современный подход — это явная передача контекста как аргумента функции. Контекст содержит всё, что нужно обработчику: информацию о пользователе, логгер, конфигурацию, состояние запроса.
Это не просто синтаксический сахар. Явная передача контекста делает код более предсказуемым и удобным для статического анализа. Когда ты видишь async (ctx) => { }, сразу понятно, какие данные нужны функции. IDE подскажет все доступные поля. TypeScript будет ругаться, если ты обращаешься к несуществующему полю.
Как это выглядит в коде:
// Express: неявная зависимость
app.use((req, res, next) => {
req.userId = extractUserId(req.headers);
next();
});
app.get('/api/user', (req, res) => {
// откуда взялся req.userId? Нужно помнить про middleware выше
const user = getUserById(req.userId);
res.json(user);
});
// Fastify: явная передача
app.addHook('preHandler', async (req, reply) => {
req.userId = extractUserId(req.headers);
});
app.get('/api/user', async (req, reply) => {
const user = getUserById(req.userId);
return user;
});
// Лучший вариант: явный контекст
interface AppContext {
userId: string;
logger: Logger;
db: Database;
}
const handler = async (ctx: AppContext) => {
const user = await ctx.db.user.findById(ctx.userId);
return user;
};
Замечу: явность не значит, что нужно передавать параметры через 10 уровней функций. Контекст — это один объект, который содержит всё необходимое. Вместо fn(req, res, next, logger, db, config, ...) ты пишешь fn(ctx). Это чище.
Fastify vs Express: архитектурные различия
Fastify изначально разработан с учётом современных подходов. Его декоратор (hooks и плагины) работают иначе, чем Express middleware. Fastify не полагается на расширение объектов request-response, хотя технически это возможно.
В Fastify ты можешь работать с контекстом через встроенные механизмы, которые делают это явным:
| Аспект | Express | Fastify |
|---|---|---|
| Middleware | Расширяет req-res объекты | Hooks работают с контекстом |
| Типизация | Сложная типизация расширений req | Встроенная поддержка типов |
| Плагины | Модификация глобального приложения | Инкапсулированные области видимости |
| Контекст | Неявный, через req | Явный, через параметры хука |
| Декораторы | Добавляются на prototype | Регистрируются в приложении |
Вот что особенно крутого в Fastify:
- Области видимости плагинов: каждый плагин имеет свой scope, данные не утекают в другие части приложения
- Reply-объект чище: ты не расширяешь его, а просто возвращаешь данные
- Встроенная сериализация: Fastify сериализует ответ на основе JSON-схемы, никакой магии
- Контекст через декораторы:
fastify.decorate()чётко показывает, что ты добавляешь в приложение
Пошаговая миграция с Express на Fastify
Миграция не означает переписывать всё с нуля. Можно делать это постепенно, заменяя middleware на явные обработчики контекста.
Шаг 1: Создай интерфейс контекста
Определи, какие данные нужны в контексте запроса:
interface RequestContext {
userId?: string;
user?: User;
requestId: string;
logger: Logger;
db: Database;
cache: Cache;
}
Шаг 2: Замени middleware на hooks в Fastify
// Вместо Express middleware
app.addHook('preHandler', async (req, reply) => {
req.context = {
requestId: req.id,
logger: logger.child({ requestId: req.id }),
db: db,
cache: cache
};
});
// Аутентификация
app.addHook('preHandler', async (req, reply) => {
const token = req.headers.authorization?.replace('Bearer ', '');
if (token) {
req.context.userId = decodeToken(token);
req.context.user = await req.context.db.user.findById(req.context.userId);
}
});
Шаг 3: Обнови обработчики маршрутов
// До миграции (Express)
app.get('/api/user/:id', async (req, res) => {
const user = await req.db.user.findById(req.params.id);
req.logger.info('User fetched', { userId: req.params.id });
res.json(user);
});
// После миграции (Fastify)
app.get<{ Params: { id: string } }>('/api/user/:id', async (req, reply) => {
const ctx = req.context as RequestContext;
const user = await ctx.db.user.findById(req.params.id);
ctx.logger.info('User fetched', { userId: req.params.id });
return user;
});
Шаг 4: Избавься от магии, используй явные функции
Итоговый паттерн: обработчик маршрута получает контекст явно, а логика находится в отдельных функциях:n
const fetchUser = async (ctx: RequestContext, userId: string) => {
const user = await ctx.db.user.findById(userId);
ctx.logger.info('User fetched', { userId });
return user;
};
app.get<{ Params: { id: string } }>('/api/user/:id', async (req, reply) => {
const user = await fetchUser(req.context, req.params.id);
return user;
});
Логика бизнеса теперь отделена от фреймворка. Функции легко тестировать, они не зависят от Express или Fastify.
Реальные проблемы, которые решает явный контекст
Это не просто вопрос эстетики или чистоты кода. Явный контекст решает реальные проблемы:
Проблема 1: Утечки данных между запросами
В Express легко случайно сохранить данные одного запроса в другом, если middleware использует замыкания или глобальные переменные. С явным контекстом каждый запрос имеет свой scope.
Проблема 2: Сложность интеграции новых инструментов
Когда приходит новый инструмент (трейсинг, метрики, аналитика), нужно добавить ещё один middleware. С явным контекстом ты просто добавляешь новое поле в интерфейс и всё работает.
Проблема 3: Тестирование обработчиков
Тестировать функцию, которая получает контекст аргументом, проще, чем мокировать весь объект request. Вот реальный пример:
// Тест с явным контекстом
const mockCtx: RequestContext = {
userId: 'test-user',
requestId: 'req-123',
logger: mockLogger,
db: mockDb,
cache: mockCache
};
const result = await fetchUser(mockCtx, 'user-1');
assert(result.id === 'user-1');
Сравни это с мокированием всего Express request-объекта — это кошмар.
Проблема 4: Отладка и логирование
Знаешь, куда попадают данные? В контекст. Знаешь, где контекст создаётся? В hooks. Логирование становится простым и предсказуемым.
Как структурировать приложение при явном контексте
После миграции на явный контекст логично переструктурировать приложение. Вот что рекомендуется:
- Слой handlers: функции, которые получают контекст и параметры запроса, возвращают результат
- Слой services: бизнес-логика, которая использует контекст (логгер, БД, кэш)
- Слой data: работа с БД, кэшем, внешними API
- Слой utils: вспомогательные функции, которые не нуждаются в контексте
Пример структуры:
src/
handlers/
user.ts // обработчики маршрутов
services/
user.ts // бизнес-логика
data/
user.ts // запросы к БД
types/
context.ts // интерфейсы контекста
app.ts // создание приложения
Каждый слой знает только о слое ниже, не наоборот. Контекст проходит сверху вниз, неся всё необходимое.
Оставить Express или переходить?
Если у тебя уже есть большой проект на Express, не обязательно срочно мигрировать. Но новые проекты лучше начинать с Fastify или другого современного фреймворка, который изначально поддерживает явный контекст.
Есть ещё альтернативы: Oak для Deno, Hono для edge, Elysia для TypeScript. Все они построены на принципе явного контекста и не требуют манипуляций с объектами request-response.
Главное — осознать, что неявная передача данных через расширение объектов это не масштабируется и не тестируется. Явность всегда выигрывает в долгосрочной перспективе.