Версионирование REST API — это не про то, чтобы пририсовать /v2 к URL и отчитаться на дейли. Это про то, как менять API, не роняя клиентов, которые работают на нём уже полгода и обновляться не планируют.
Поначалу иллюзия проста: клиентов один-два, оба внутренние, деплоим вместе. Добавили поле в ответ — поправили в двух местах, развернули. Проходит год. Клиентов двадцать, три из них — внешние партнёры с релизным циклом раз в квартал. Вы добавляете обязательное поле в ответ GET /orders. Код партнёра, который парсил жёстко по индексам, ложится. Звонок в три часа ночи, саппорт пишет, вы делаете ретроградный мерж и теряете полдня.
Если с правилами названий ручек вы уже знакомы и с основными принципами REST API разобрались — версионирование следующий логичный шаг.
Когда версионирование нужно, а когда это паранойя
Не каждое изменение в API требует новой версии. Добавление поля в ответ — обратно совместимо: старый клиент его проигнорирует. Новый эндпоинт — тоже не ломает ничего. А вот что реально убивает обратную совместимость:
- Удаление поля из ответа. Клиент парсил
"discount"— а его больше нет. Парсер падает. - Изменение типа поля. Было
"count": 5(число) — стало"count": "5"(строка). Для слаботипизированного клиента вроде JS-фронтенда разницы может и не быть. Для строготипизированного — гарантированный exception. - Изменение семантики.
POST /ordersраньше создавал заказ сразу, теперь требует подтверждения и возвращает202 Acceptedвместо201 Created. Клиент, ожидавший201, считает, что заказ не создался, и шлёт повтор. - Удаление эндпоинта. Очевидно.
- Добавление обязательного параметра.
GET /ordersработал без?status=, теперь требует — старый код не передаёт параметр и получает400.
Правило простое: если клиент, написанный год назад, отработает с новым API без правок — версионировать не надо. Достаточно пристроить новое и пометить старое как deprecated. Если не отработает — готовьте следующую версию.
Оговорюсь: это не серебряная пуля. Я видел команды, которые мажорили версию ради смены дефолтного таймаута с 30 на 60 секунд. Это как стрелять из пушки по воробьям: больше затрат на поддержку двух параллельных версий, чем реальной пользы.
Три способа версионирования REST API
Как именно сообщить серверу, какую версию клиент хочет? Ответ — в любом из трёх мест HTTP-запроса. Разбираться начнём с самого популярного.
Версионирование через URL
GET /api/v1/ordersGET /api/v2/ordersСамый распространённый способ. Версия зашита прямо в путь — её видно в логах, в curl, в инструментах мониторинга. Swagger спокойно генерит документацию для каждой версии отдельным файлом (или группирует по тегам). API Gateway тривиально рулит запросы: prefix: /v1/ → старый бэкенд, prefix: /v2/ → новый.
Минусы ровно те же, что и плюсы. URL меняется — это новый ресурс по стандартам REST, хотя семантически это тот же самый заказ. Кэши по v1/orders и v2/orders живут отдельно, хотя данные физически одни и те же. При переходе с v1 на v2 клиент обязан поменять все URL в коде, а не только конфигурацию заголовков. И если вы держите 5 мажорных версий — у вас 5 копий всего роутинга и документации.
Когда осмысленно: публичное API, внешние партнёры, простая маршрутизация через API Gateway.
Версионирование через HTTP-заголовки
GET /ordersAccept-Version: v1Или кастомный заголовок — X-API-Version: 2, Api-Version: 2024-01. URL остаётся чистым, ресурс один. Версионирование ложится в конфигурацию HTTP-клиента, а не размазывается по всем URL.
Плата: дебаг усложняется. В логах запрос выглядит как GET /orders, и без копания в заголовках непонятно, v1 это или v2. Swagger требует допиливания, чтобы развести документацию по версиям через селектор. Кэширование: заголовок Vary: Accept-Version в ответе заставляет CDN и браузеры учитывать версию при определении уникальности кэша. Забыли Vary — получили скрещивание данных v1 и v2 в одном кэш-ключе. Боль.
Когда осмысленно: внутренние сервисы, все клиенты под вашим контролем, есть дисциплина в команде.
Версионирование через Content Negotiation (Accept)
GET /ordersAccept: application/vnd.myapi.v1+jsonСамый «академически правильный» способ. Тип контента (media type) включает версию. Ни URL, ни кастомные заголовки не страдают. Стандарт HTTP это поддерживает нативно — RFC 6838.
На практике этим почти никто не пользуется. Причина проста: неудобно. Каждый вызов требует указывать длинный Accept-заголовок с вендорным медиатипом. Отладка через curl превращается в упражнение по набору -H 'Accept: application/vnd.myapi.v2+json' каждый раз. CDN и промежуточные прокси не понимают вендорных типов и кэшируют как попало. Swagger требует ручной настройки разделения схем.
Я за всю практику видел такой подход один раз — в API крупной финтех-компании, где каноничность REST была принципиальной позицией архитектора. Остальные 99% команд в какой-то момент переезжали на URL или заголовки, потому что поддержка content negotiation съедала больше времени, чем само версионирование.
Когда осмысленно: если у вас в команде есть человек, который готов защищать этот подход перед разработчиками, тестировщиками и техподдержкой каждый спринт.
Дерево решений: какую стратегию выбрать
Вместо того чтобы пересказывать плюсы-минусы словами — схема. Проходите по стрелкам в зависимости от вашего контекста.
Диаграмма показывает главную развилку. Обратно совместимые изменения (зелёная ветка) — едем без версионирования, просто добавляем новое. Ломающие (синие узлы) — выбираем стратегию в зависимости от аудитории API и инфраструктуры. Внешние партнёры почти всегда диктуют URL-версии: им нужен curl, Postman и Swagger без танцев с заголовками. Внутри своей компании — пространство для манёвра шире.
Конечная точка у всех стратегий одна: настройка депрекации устаревших полей и версий. К этому вернёмся через пару разделов.
Обратная совместимость: как эволюционировать без /v2
Не каждый «слом» требует мажорной версии. Часто достаточно соблюдать несколько правил при изменениях — и API живёт без версионирования годами. Вот что работает на практике.
Добавлять поля — да. Новое поле в JSON-ответе не ломает клиента, который его не ждёт. Клиент парсит то, что знает, остальное игнорирует. Это базовый принцип, но я видел, как команды всё равно мажорили версию ради поля middleName — от перестраховки.
Удалять поля — только через депрекацию. Хотите выпилить "legacyDiscount" из ответа? Сначала задокументируйте, что поле устарело, добавьте в ответ заголовок Deprecation: true (или Sunset), продержите месяц-два. Только потом удаляйте. Или просто никогда — если поле не создаёт проблем, пусть висит.
Менять тип поля — никогда. Это ломающая операция, тут только новая версия.
Добавлять эндпоинт — свободно. GET /orders/\{id\}/history не трогает существующие URL.
Удалять эндпоинт — через 410 Gone. Сначала возвращайте 410 с телом, объясняющим миграцию, потом удаляйте.
Менять обязательность параметров — добавлять можно, убирать нельзя. Сделали необязательный параметр обязательным — всё, кто не передавал, получают 400. Разворачивайте.
Отдельный нюанс — enum-значения. Добавили "ARCHIVED" в статус заказа? Если клиент жёстко проверяет соответствие enum’у — он упадёт на незнакомом значении. Если просто выводит строку — переживёт. Совет: в контракте явно пишите, что клиент должен толерантно обрабатывать неизвестные значения enum’ов. Не все читают контракты, но те, кто читают, скажут спасибо.
Жизненный цикл версии: от запуска до выключения
Версию мало запустить. Её нужно сопроводить до выключения — и вот тут начинается то, о чём забывают в 80% проектов.
Диаграмма показывает три фазы. Первая — v1 живёт и работает. Вторая — запущена v2, v1 объявлена устаревшей: каждый ответ содержит заголовок Sunset с датой отключения и Warning: 299 с описанием. Период миграции — минимум месяц, а для внешних партнёров лучше три. Третья фаза — после даты Sunset: v1 возвращает 410 Gone (или 301 Moved Permanently с редиректом на v2, если семантика совместима).
Трюк с Sunset-заголовком — RFC 8594, поддержан не всеми клиентами, но хотя бы в документации и в ответах API он должен быть. Клиент, который читает заголовки, увидит дату отключения и запланирует миграцию. Клиент, который не читает — всё равно не заметит, пока не упадёт на 410. Но это уже не ваша проблема.
Sunset на практике
HTTP/1.1 200 OKSunset: Sat, 01 Feb 2027 00:00:00 GMTWarning: 299 - "v1 устарела, переходите на v2. Документация: https://api.example.com/docs/v2/migration"Пара замечаний из опыта:
- Деплойте
Sunsetотдельно от запускаv2. Сначалаv2появляется и работает без пометок оv1. Убедились, чтоv2стабильна — только тогда объявляете дату отключения старой версии. - Предупреждайте клиентов не только через заголовок. Email, changelog, личное сообщение в Slack/Telegram ключевым партнёрам. Заголовок — это минимум, а не единственный канал.
- Не отключайте сразу после
Sunset. Если клиент ещё шлёт запросы вv1— дайте неделю-две «песочницы», где410приходит с телом-объяснением и ссылкой на миграцию. Жёсткое выключение без объяснений — верный способ получить эскалацию на уровне CTO партнёра.
Таблица: сравнение стратегий версионирования
| Критерий | URL (/v1/) | Заголовок (Accept-Version) | Content Negotiation (Accept) |
|---|---|---|---|
| Простота отладки | Высокая: версия в URL | Низкая: нужно смотреть заголовки | Очень низкая |
| Работа с API Gateway | Тривиально: роутинг по префиксу | Нужна маршрутизация по заголовкам | Сложно: кастомные правила |
| Swagger/OpenAPI | Генерится из коробки | Нужен селектор версий | Ручная настройка |
| Чистота URL | Низкая: /v1/ — новый ресурс | Высокая: URL не меняется | Высокая |
| Кэширование (CDN) | Разные кэш-ключи | Vary: Accept-Version обязателен | CDN не понимает вендорных типов |
| Совместимость с REST | Спорная: разные ресурсы | Соответствует HATEOAS | Канонически правильно |
| Порог входа для клиента | Низкий: curl без доп. флагов | Средний: нужен -H | Высокий: длинный -H |
| Когда выбирать | Внешние партнёры, простота | Внутренние сервисы | Почти никогда |
Чек-лист для аналитика
При проектировании версионирования зафиксируйте в требованиях ответы на эти вопросы. На архитектурном ревью они сэкономят вам и команде полчаса.
- Ломает ли изменение обратную совместимость? Если нет — версионирование не нужно, эволюционируйте без смены версии.
- Кто потребители API? Внешние партнёры → URL-версии. Внутренние сервисы → можно заголовками.
- Сколько версий поддерживать параллельно? Правильный ответ — две: текущая и одна предыдущая. Три и больше — симптом того, что вы боитесь отключать старые версии.
- Срок жизни предыдущей версии. Для внешнего API — 3–6 месяцев. Для внутреннего — 1–2 месяца.
- Как клиенты узнают о депрекации? Sunset-заголовок + email + changelog. Каналы дублировать.
- Что отвечать после отключения версии?
410 Goneс телом-объяснением или301с редиректом, если семантика совместима. - Как мониторить, кто ещё сидит на старой версии? Метрики по версиям в Prometheus/Grafana, алерт при остаточном трафике после Sunset.
Главный вывод
Версионирование — это не технический вопрос. Это договорённость с клиентами о том, как вы будете меняться и не ломать им жизнь. URL-версии — честный и прямой способ сказать «API изменился». Заголовки — более элегантный, но требующий дисциплины. Content negotiation — академически безупречный, но на практике почти не используемый.
Ключевое — не стратегия сама по себе, а то, что вы о ней думаете до того, как API пошёл в прод с первым внешним клиентом. Поменять /v1 на заголовки через год после запуска — больно. Выбрать и придерживаться с самого начала — рутина.
PS. Если ваш API до сих пор не имеет ни одной версии и меняется «по согласованию в Slack» — время кончилось. Первый же партнёр, который не читает ваш Slack, превратит деплой в пожар. Я проверял.