Миграция с Express на Fastify без поломки middleware
Планируете миграцию с Express на Fastify? Посмотрите, что переносится без проблем, что нужно переписать и как измерить latency и throughput до начала переписывания.

Почему эта миграция кажется простой, пока не окажется сложнее
На первый взгляд Express и Fastify достаточно похожи, и команде легко кажется, что можно быстро просто заменить один фреймворк на другой. Оба работают с маршрутами, запросами, ответами и плагинами. Именно поэтому миграция с Express на Fastify часто начинается слишком уверенно.
Проблемы появляются, когда вы смотрите на то, как приложение действительно живет каждый день. Во многих кодовых базах на Express middleware — это не мелкая деталь. Оно формирует все приложение. Авторизация, логирование, парсинг тела запроса, валидация, обработка ошибок, контекст запроса и даже бизнес-правила часто живут в длинной цепочке app.use().
Такой подход не переносится безболезненно. У Fastify есть hooks и plugins, но это не просто другие названия для того же самого. Fastify любит более четкие границы. Ему ближе схемы маршрутов, изолированные plugins и явные шаги жизненного цикла. Если ваше Express-приложение зависит от того, что middleware меняет req на лету, переписывание быстро становится запутанным.
Именно здесь команды теряют время. Они считают, что тормозит фреймворк, переписывают большой кусок приложения и только потом понимают, что основную задержку создавали база данных, медленный провайдер авторизации или тяжелая подготовка ответа. Новый код может выглядеть чище и при этом не ощущаться быстрее для пользователей.
Гораздо больше скажет маленький тест, а не полное переписывание. Возьмите один загруженный маршрут с простой бизнес-логикой. Реализуйте его в Fastify. Оставьте ту же базу данных, тот же payload и ту же инфраструктуру. Затем сравните latency, throughput, частоту ошибок и усилия разработчиков. Если маршрут стал заметно быстрее, а код остался удобным в поддержке, у вас есть причина продолжать.
Такой осторожный подход не выглядит эффектно, но экономит недели. Oleg Sotnikov делал такую архитектурную работу на production-масштабе, и паттерн обычно один и тот же: сначала измерьте, потом перенесите один фрагмент, и только потом принимайте решение. Команды, которые так работают, избегают дорогого переписывания на догадках.
Что обычно переносится без проблем
Многое из того, что вам действительно важно, не требует полного переписывания. В типичной миграции с Express на Fastify безопаснее всего те части, которые уже живут отдельно от req и res.
Обработчики маршрутов часто переносятся с небольшими правками. Если обработчик уже принимает входные данные, вызывает сервис и возвращает JSON, обычно достаточно заменить вызовы ответа Express на методы reply в Fastify и поправить несколько имен свойств запроса. Бизнес-логика внутри обработчика может остаться прежней.
Валидация тоже обычно переносится без боли, если она не привязана к middleware Express. Если схемы лежат в обычных модулях с Zod, Joi, Yup или своими функциями, их можно переиспользовать. Позже вы, возможно, перенесете часть проверок в систему схем Fastify, но это уже улучшение, а не препятствие.
Сервисный слой обычно переносится легче всего. Функция вроде createInvoice, sendResetEmail или calculateQuote не должна зависеть от того, какой web-фреймворк ее вызвал. Если ваши сервисы принимают обычные аргументы и возвращают обычный результат, они останутся на месте.
То же самое касается кода работы с базой данных. Query builders, ORM-модели, модули репозиториев и помощники для транзакций обычно никак не зависят от Express. Если файл только обращается к PostgreSQL, Redis или другому API, Fastify почти ничего там не меняет.
Утилиты почти никогда не требуют внимания. Хелперы для дат, форматтеры, мапперы, проверки прав, помощники для повторных попыток и загрузчики конфигурации должны переезжать без изменений. Это хороший знак. Значит, в приложении уже есть здоровое разделение ответственности.
Простой пример делает это понятнее. Допустим, у вас есть endpoint POST /users. Обработчик в Express читает тело запроса, вызывает userService.create и возвращает нового пользователя. В Fastify файл маршрута немного изменится, но userService.create, правила пароля, email-хелпер и вставка в базу могут остаться теми же.
Если большая часть кода находится в сервисах, слое данных и обычных helper-модулях, миграция обычно оказывается меньше, чем кажется сначала. Боль начинается там, где объекты запроса и ответа протекают во все остальное.
Где старые привычки middleware ломаются
Большая часть проблем при миграции появляется там, где старый Express-код предполагает, что каждый запрос проходит через один и тот же изменяемый pipeline. Fastify умеет делать то же самое, но хочет более четких границ и меньше побочных эффектов.
Первый сюрприз — это req и res. В Express многие команды относятся к ним как к открытым контейнерам. Они добавляют req.user, req.tenant, res.ok или маленькие helper-методы и ждут, что следующий middleware все это увидит. В Fastify есть request и reply, но они не совпадают с Express один к одному. Какие-то знакомые поля существуют, какие-то переезжают, а для каких-то паттернов нужны decorators или hooks вместо прямого изменения объектов.
Разница становится еще заметнее, когда приложение зависит от одной длинной глобальной цепочки. Express позволяет легко выстроить middleware в нужном порядке и надеяться, что каждый шаг немного изменит запрос до того, как сработает маршрут. Fastify предпочитает hooks, plugins и опции на уровне маршрута. Эта структура строже, и обычно это хорошо, но старый код, зависящий от скрытого порядка, ломается быстро.
Типичный пример выглядит безобидно: middleware для авторизации парсит токен, следующий middleware загружает аккаунт, третий добавляет собственный helper для ответа, а маршрут предполагает, что все три уже отработали. В Fastify такой поток лучше сделать явным. Если оставить его неявным, одно изменение при регистрации маршрута может незаметно убрать часть цепочки.
Monkey patching дает самые неприятные баги. Кажется удобным прикрепить к объекту запроса или ответа свои поля, но такая привычка усложняет миграцию и делает тестирование слабее. Fastify дает более безопасные способы расширять поведение:
- используйте decorators для общих методов или свойств
- используйте hooks для шагов жизненного цикла запроса
- используйте plugins, чтобы ограничить поведение частью приложения
- держите обработчики маршрутов простыми, если логика локальная
Обработка ошибок тоже меняет форму. Express часто опирается на next(err) и финальный error middleware, который ловит все подряд. Fastify ожидает, что вы будете бросать ошибки или передавать их через собственный путь обработки ошибок. Если копировать обработчики Express как есть, можно получить двойные ответы, проглоченные ошибки или странные изменения status codes.
Именно здесь многие планы миграции с Express на Fastify замедляются. Если маршрут зависит от скрытых изменений req, подмены методов ответа или catch-all error middleware, лучше воспринимать его как небольшое переосмысление, а не как прямой перенос.
Как распространенные паттерны Express переходят в Fastify
Большинство Express-приложений растет вокруг app.use(). Во время миграции с Express на Fastify эту привычку нужно пересмотреть первой. В Fastify правильнее спрашивать не "куда мне воткнуть middleware?", а "когда это должно сработать и для каких маршрутов?".
Если логика должна влиять на целую часть приложения, зарегистрируйте plugin для этой области. Если код должен выполниться перед обработчиком, используйте hook вроде onRequest, preParsing или preHandler. Если он нужен только одному маршруту, добавьте поведение на уровне этого маршрута, а не делайте его глобальным. Такая маленькая смена подхода часто убирает много скрытой связанности.
res.locals тоже нужен новый дом. Fastify не полагается на общий контейнер ответа в том же виде. Более чистый паттерн — прикреплять данные, относящиеся к конкретному запросу, к request, обычно после авторизации или поиска, и читать их позже в обработчике. Например, если проверка токена загружает текущего пользователя, сохраните этого пользователя в request и держите данные привязанными только к этому запросу.
Авторизация, логирование и CORS в Fastify обычно становятся чище. Проверки авторизации хорошо ложатся в onRequest или preHandler, в зависимости от того, нужен ли сначала распарсенный body. Логирование лучше строить на встроенном logger Fastify, чтобы у каждого запроса был свой контекст. Для CORS лучше использовать plugin Fastify, а не писать заголовки вручную. Ручной CORS-код часто ломается на preflight-запросах и в редких браузерных сценариях.
Парсинг тела запроса — еще одно место, где старые привычки остаются надолго. Express-приложения часто строят цепочку из body-parser, middleware для валидации и собственного error handling. Fastify уже парсит JSON и умеет валидировать тело запроса через схемы маршрута. Если добавить схему к POST /orders, Fastify сможет отклонять плохие данные еще до запуска обработчика.
Хорошее правило — разложить каждое Express middleware по области действия, моменту выполнения и назначению. После этого сопоставление становится понятнее: что-то превращается в plugin, что-то — в hook, а что-то исчезает, потому что Fastify уже делает это сам.
Безопасный способ начать с одного маршрута
Начните с одного маршрута, который получает много трафика и мало меняет данные. Read-heavy endpoint дает более чистые сигналы. Если что-то пойдет не так, можно обычно быстро вернуться назад без борьбы с дублирующимися записями или сломанным состоянием.
Выберите маршрут с простыми входными данными. Запрос вроде GET /products/:id лучше, чем маршрут, где смешаны проверки авторизации, загрузка файлов и обновление базы данных. Нужен узкий случай, где вы сможете уверенно сказать, что версия на Fastify ведет себя так же.
Пока не переписывайте бизнес-логику. Оставьте сервисный код, запросы к базе и преобразование ответа максимально близкими к версии на Express. В миграции с Express на Fastify первая победа — это совпадение поведения, а не чистка кода. Если вы одновременно меняете и фреймворк, и логику, вы не поймете, что именно вызвало баг или ускорение.
Соберите тонкую обертку Fastify вокруг этого маршрута и добейтесь точного совпадения ответа. Сохраните те же status codes, поля JSON, формат ошибок и все заголовки, на которые рассчитывают клиенты. Маленькие различия имеют значение. Мобильное приложение или другой backend может опираться на заголовок, о котором вы уже забыли.
Обычно простой пилот выглядит так:
- выберите один GET-маршрут с понятными параметрами или query-значениями
- перенесите обработчик в Fastify без изменения сервисного слоя
- сравните body ответа, заголовки и status codes с Express
- прогоните те же тесты и добавьте несколько проверок бок о бок
- выкатите под feature flag или на очень маленький кусок трафика
Последний шаг сильно снижает боль. Отдайте 1 процент трафика или сначала включите Fastify-маршрут только для внутренних пользователей. Несколько дней наблюдайте за ошибками, latency и странным поведением клиентов. Если маршрут ведет себя спокойно, у вас появился рабочий шаблон для следующего.
Именно так опытные CTO снижают риск миграции в реальных системах: один маршрут, та же логика, измеренный запуск, затем повторение.
Как доказать выигрыш в скорости до полного переписывания
Бенчмарк полезен только тогда, когда оба приложения делают одну и ту же работу. Разместите маршрут на Express и маршрут на Fastify на одной машине, с одной версией Node, одинаковым размером payload, заголовками, вызовами базы данных и keep-alive настройками. Если одна сторона пропускает валидацию или отправляет меньший ответ, результат бесполезен.
Перед измерением обязательно прогрейте оба приложения. Первые прогоны часто включают JIT-компиляцию, свежие соединения и пустые кэши. Это поведение запуска, а не обычный трафик. Запустите каждый тест несколько раз и берите средний результат, а не лучший.
Начинайте с одного маршрута, а не со всего API. Выберите маршрут, который важен в реальной работе, например GET /users/:id или POST /orders. Сначала измеряйте только его. Потом прогоните смешанную нагрузку, похожую на production, где вместе происходят чтения, записи, проверки авторизации и логирование.
Отслеживайте не только requests per second:
- p50 и p95 latency
- throughput при стабильной concurrency
- частоту ошибок и таймаутов
- использование CPU и рост памяти
p50 показывает обычный случай. p95 показывает медленные запросы, которые замечают пользователи. CPU говорит о другом. Если Fastify дает похожую latency, но использует намного меньше CPU, это тоже важно. Вы можете избежать большего счета за серверы или оставить больше запаса для фоновых задач.
Будьте строги к условиям теста. Не сравнивайте локальный запуск сегодня и облачную виртуальную машину завтра. Фоновые процессы могут скрыть реальную разницу. Запишите уровень concurrency, длительность теста, наборы payload, заголовки и Node flags, а потом повторяйте тот же план после каждого изменения.
Для миграции с Express на Fastify умеренный, но повторяемый выигрыш уже достаточен. Если один маршрут дает ниже p95, меньше ошибок и меньше CPU на том же железе, это доказательство, что переписывание может окупиться. Если выигрыш виден только в пиковых requests per second и исчезает под смешанной нагрузкой, лучше оставить текущий код и не переписывать.
Реалистичный пример с небольшим API
Возьмем небольшой сервис на Express для приложения с задачами. В нем есть логин, несколько CRUD-маршрутов для проектов и задач, логирование запросов, обработка cookies и один endpoint для загрузки файлов аватара. Это обычная точка старта для миграции с Express на Fastify, потому что приложение достаточно большое, чтобы показать слабые места, но еще достаточно маленькое, чтобы изменить его за день-два.
Самая простая часть для переноса — бизнес-логика. Если ваши обработчики уже вызывают service layer вроде taskService.create(), а SQL лежит в отдельных файлах, оставьте все это как есть. Меняйте только web-слой. На практике многие команды переписывают слишком много и теряют время на тестирование кода, который вообще не нужно было трогать.
Первый проход обычно выглядит так:
- оставьте того же database client и те же SQL-запросы
- оставьте те же проверки авторизации и service-функции
- перепишите только регистрацию маршрутов и работу с request или reply
- перенесите логирование запросов в hooks Fastify или logging plugin
- добавьте plugins для cookies и multipart-загрузок
Логирование — хороший пример кода, который можно сократить. В Express команды часто ставят logger middleware ближе к началу, а затем добавляют свою логику времени в другом месте. В Fastify hook вроде onRequest или onResponse может закрыть большую часть этого одним подходом. Настройка plugin тоже помогает убрать повторяющийся код, потому что поведение регистрируется один раз и работает на всех маршрутах.
Есть нюанс. У plugins Fastify есть правила. Их нужно регистрировать в правильном порядке, и некоторые возможности появляются только после загрузки plugin. Это ощущается строже, чем Express. Но после этого систему легче понимать.
Допустим, старое Express-приложение делает для авторизации вот что: читает cookie, проверяет токен, прикрепляет req.user, а затем продолжает выполнение. В Fastify это часто становится decorator плюс pre-handler. Логика авторизации остается той же, но меняется форма. То же самое с логированием. Hook заменяет старую привычку ставить middleware за middleware.
Сложные проверки лучше прогонять руками, а не только автоматическими тестами. Откройте приложение и проверьте несколько реальных запросов:
- загрузите файл и убедитесь, что лимиты размера и сообщения об ошибках работают
- отправьте плохую cookie и проверьте ответ авторизации
- вызовите ошибку валидации и сравните JSON body
- создайте, обновите и удалите одну запись
Этот шаг важнее, чем многие ожидают. Загрузки файлов, cookies и ответы об ошибках — именно те места, где обычно проступают привычки Express. Если там все совпадает, миграция обычно стоит на твердой основе. Если нет, исправление часто небольшое, но лучше найти его рано.
Ошибки, которые только отнимают время
Большая часть потерь времени при миграции с Express на Fastify возникает, когда ее воспринимают как перенос файлов. Команды копируют каждый middleware-файл, переименовывают несколько импортов и ждут того же самого потока запроса. Fastify может делать те же задачи, но ему нужен более чистый раздел между hooks, plugins, decorators и schemas. Если переносить все как есть, вы сохраняете старый хаос и теряете большую часть пользы.
Еще одна медленная ошибка — менять поведение маршрута одновременно со сменой фреймворка. Маршрут логина, который иначе обрезает поля, более строгая валидация или новый формат ошибки могут превратить одну миграцию в охоту за багами. Сначала зафиксируйте поведение. Оставьте те же входы, выходы, status codes и правила авторизации, пока маршрут не заработает и тесты не пройдут.
Schemas важны сильнее, чем многие команды на Express ожидают. Если их пропустить, у Fastify будет меньше информации для валидации, типизации и сериализации. Тогда баги всплывают в странных местах, а виноватым кажется фреймворк, хотя проблема уже была в старом приложении. Маршрут с request schema и response schema часто переносится легче, чем маршрут, построенный на скрытых побочных эффектах middleware.
Плохие бенчмарки тоже съедают дни. Сравнение локальных запусков с разными версиями Node, разными настройками логов или разными payload ничего толком не доказывает. Сделайте тест простым и честным:
- используйте одну и ту же версию Node
- отправляйте на тот же маршрут те же payload
- либо оставьте доступ к базе реальным в обоих приложениях, либо замокайте его в обоих
- выключите шумные логи на время измерения
- прогрейте оба сервера перед замерами
Самая большая ошибка — переписать все приложение до того, как один маршрут пройдет тесты. Возьмите маршрут с реальным трафиком, но ограниченной бизнес-логикой. Перенесите его, протестируйте, измерьте и сравните поведение в diff. Если один маршрут стал проще и быстрее, продолжайте. Если нет — остановитесь и исправьте план миграции, прежде чем трогать остальной API.
Быстрые проверки перед тем, как принять решение
Спешное переписывание обычно проваливается по скучной причине: команда не знает, от чего на самом деле зависит текущее приложение. Перед переносом хотя бы одного маршрута выпишите все middleware, которые используются в Express, и пометьте каждое: "native Fastify feature", "plugin", "custom rewrite" или "remove". Такая простая карта сразу покажет, где лежит настоящая работа.
Особое внимание уделите маршрутам, которые делают больше, чем просто возвращают JSON. Загрузки файлов, streaming responses, webhooks и WebSocket endpoints часто выглядят маленькими в кодовой базе, но ломаются первыми, когда старые привычки middleware переносятся в Fastify. Если продукт стримит AI-ответы или большие отчеты, проверьте этот путь отдельно, а не как обычный запрос.
Короткий аудит перед миграцией экономит дни позже:
- Составьте список каждого middleware и его замены в Fastify или пометьте его на удаление.
- Отметьте маршруты, где есть загрузки, потоки, фоновые задачи или sockets.
- Проверьте тесты на ошибки авторизации, плохой ввод, отсутствующие заголовки и rate limits.
- Убедитесь, что бенчмарк использует реальные размеры payload, заголовки и concurrency.
- Заранее задайте четкую границу успеха, например более низкий p95 latency или меньшее потребление CPU.
Тесты важнее, чем кажется. Многие команды покрывают happy path и пропускают неприятные случаи: истекшие токены, слишком большие body, битый JSON, повторные запросы или отключение клиента посреди потока. Если сейчас эти сценарии не покрыты, миграция вызовет сомнения даже тогда, когда код в порядке.
Ваш бенчмарк тоже должен напоминать production, а не игрушечную демонстрацию. Используйте одинаковое поведение keep-alive, одинаковые размеры body, auth headers и смесь быстрых и медленных маршрутов. Скрипт с названием вроде "benchmark Express vs Fastify", который бьет один крошечный GET endpoint 100000 раз, может дать красивый график, но почти ничего не скажет о реальном трафике.
Поставьте финишную черту простыми цифрами. Для миграции с Express на Fastify это может быть на 20 процентов ниже p95 latency, меньше зависимостей и отсутствие регрессий в авторизации или streaming. Если вы не можете сказать, что считать победой, подождите неделю и сначала определите это.
Что делать дальше
Не начинайте с полного переписывания. Выберите один маршрут с реальным трафиком, с одной или двумя слоями middleware и с понятным способом измерения. Этот пилот покажет команде, что на самом деле меняется при миграции с Express на Fastify, а не то, что вы ожидаете увидеть.
Запишите различия, пока они еще свежие. Кратко. Одной страницы достаточно, если она отвечает на простые вопросы: какое middleware перенеслось без проблем, какие части потребовали hooks или decorators, что сломалось в тестах и насколько код стал проще или сложнее для чтения.
Небольшая карта миграции экономит много повторной работы позже. В ней должны быть авторизация, валидация, обработка ошибок, контекст запроса и логирование. Когда следующий маршрут будет переезжать, команде не придется заново разбираться с тем же выбором plugin или порядком hooks.
Хорошо работает простой чек-лист:
- Перенесите один маршрут с реальными зависимостями
- Отметьте каждый паттерн Express, которому понадобилась альтернатива в Fastify
- Прогоните один и тот же бенчмарк до и после
- Сравните latency, throughput, использование памяти и частоту ошибок
- Решите, стоит ли выигрыш более широких изменений
Бенчмарк важнее ощущения. Некоторые команды переходят только потому, что Fastify выглядит чище. Этого мало. Если ваш маршрут обрабатывает запросы на 20–30 процентов быстрее, использует меньше памяти и остается простым в отладке, аргументы становятся сильнее. Если выигрыш совсем небольшой, а цена переписывания высокая, остановитесь на этом и пока оставьте Express.
Именно на этом этапе внешняя оценка может сэкономить деньги. Хороший Fractional CTO может посмотреть на выбор plugins, настройку бенчмарка, риски запуска и привычки команды еще до того, как вы решите делать более широкий переход. Oleg Sotnikov делает такую работу для стартапов и малого бизнеса, особенно когда цель — сократить потери, сохранить стабильный uptime и не переписывать то, что и так не было проблемой.
Если пилотный маршрут прошел хорошо, расширяйте миграцию по одному фрагменту за раз. Если нет, вы все равно получили полезный опыт, не ставя на кон весь API.