Версионирование REST API: подходы, компромиссы и что выбрать в реальном проекте
Когда новый разработчик впервые слышит «у нас API версии v1», он представляет себе аккуратный мир: есть версия, она зафиксирована, под нее написаны клиенты, через год выйдет v2, все мирно мигрируют. На практике у вас одновременно живут три версии, каждая в чуть разном состоянии, в одной криво документировано поле, в другой нет endpoint, который просят клиенты, и кто-то предлагает сделать «v1.1, чтобы не плодить».
Версионирование REST API — это не про синтаксис URL, а про политику изменений. Само по себе наличие /v1/ в адресе не делает API устойчивым к изменениям. Расскажу про подходы, которые реально применяю, и про правила, которые на длинной дистанции экономят больше всего нервов.
Что вообще такое «новая версия»
Прежде чем выбирать схему, надо понять, в каких случаях вообще нужна новая версия. Изменения делятся на три категории.
- Backwards-compatible (совместимые). Добавление нового поля в ответ, нового опционального параметра, нового endpoint. Существующие клиенты ничего не замечают.
- Backwards-incompatible (несовместимые). Удаление поля, переименование, изменение типа, изменение поведения существующего endpoint, превращение опционального параметра в обязательный.
- Деструктивные. Полное переосмысление модели: «теперь у нас не заказы, а транзакции, поля совсем другие, флоу другой».
Совместимые изменения — повседневная работа, они не требуют новой версии. Их можно выкатывать в текущую и предупреждать клиентов в release notes.
Несовместимые — повод для следующей версии или для аккуратного переходного периода. Сразу выкатывать их в текущую — ломать клиентов.
Деструктивные — повод задуматься, делать ли их вообще. Если очень нужно, это полноценная новая версия, иногда даже отдельный продукт.
Подходы к нумерации
В пути URL: /v1/, /v2/
Самый распространённый и самый простой для понимания. Видно с первого взгляда, в какую версию идёт запрос, можно явно роутить gateway-ом, можно держать две версии на разных сервисах.
GET /v1/orders/42
GET /v2/orders/42
Минусы: формально это нарушает идею «один ресурс — один URI», и с точки зрения чистого REST у /v1/orders/42 и /v2/orders/42 разные адреса для одного бизнес-объекта. На практике — самый практичный вариант, потому что его понимает каждый.
В заголовке (header)
GET /orders/42
Accept: application/vnd.shop.v2+json
Кошерно с точки зрения HTTP, но тяжело в эксплуатации. Сложнее тестировать через браузер, сложнее отлаживать в Kibana, сложнее объяснять интеграторам. Я применяю редко, в основном если API строго b2b и интеграторы пишут на Java/.NET, где работа с заголовками привычна.
В query-параметре
GET /orders/42?api-version=2024-04-01
Промежуточный вариант, бывает у Microsoft и Stripe. Ставит дату/версию параметром. У него две черты, которые могут быть и плюсом, и минусом одновременно: легко переопределить руками, легко забыть и попасть в дефолтную версию.
Дата вместо номера
Подход Stripe — версия как дата релиза: Stripe-Version: 2023-10-16. Каждое несовместимое изменение имеет дату, клиент явно фиксирует, какую он принимает. Удобно, когда несовместимых изменений много и не хочется плодить v3, v4, v5. Дороже в реализации (нужно поддерживать «диффы» между датами).
Сколько версий держать одновременно
Это один из главных вопросов, на который никогда нет универсального ответа. Я ориентируюсь на следующие правила.
- Одновременно поддерживайте максимум 2–3 версии. Больше — резко растёт стоимость поддержки, тестирования, документации.
- Объявляйте sunset заранее. Полгода-год — нормальный период между объявлением и реальным отключением старой версии. Для крупных интеграторов — больше.
- Не выпускайте новую версию из-за одного маленького изменения. Лучше копите несовместимые изменения и выкатывайте новую версию пакетом. Иначе у вас будет v17 через два года.
Тонкие случаи
Изменение поведения без изменения схемы
Самый коварный класс. Например, раньше при отсутствии адреса доставки заказ создавался со статусом pending, а теперь возвращается ошибка 422. Схема не поменялась, контракт нарушился. Это backwards-incompatible изменение, требующее новой версии или хотя бы переходного периода с feature flag-ом.
Сужение значений в enum
Если в enum было пять значений и одно убрали — это несовместимо. Клиент, который умел обрабатывать пять, не сломается, но клиент, который ожидал получить шестое (обратное направление, POST), сломается. Будьте внимательны к enum-ам в обе стороны.
Изменение типов
Превращение string в int, превращение объекта в массив, превращение nullable-поля в обязательное — все это пугающе несовместимо. Делать только в новой версии, никогда в текущей.
Расширение через optional
Добавлять новые поля к ответам можно. Но клиенты должны быть к этому готовы и не падать на «неизвестных» полях. Это не ваша проблема, это их грамотность, но в документации стоит явно написать «мы можем добавлять поля в ответы; не падайте на неизвестных».
Документация и changelog
Версионирование без changelog — это аттракцион «угадай, что поменялось». На каждое изменение должна быть запись:
- Что изменилось.
- Совместимо или нет.
- В какой версии появилось.
- Что делать клиентам, которые сейчас используют старое поведение.
OpenAPI помогает в части схемы, но не в части семантики. Для семантики — обычный markdown changelog в репозитории и/или в публичной документации. Если в первый месяц после релиза изменения не подробно записаны, через год никто не вспомнит, что и зачем поменяли.
Технически: как уживаются две версии
Есть три подхода с разной стоимостью поддержки.
Один сервис, два набора endpoint-ов
Самый простой. В коде есть контроллеры OrderControllerV1 и OrderControllerV2, оба зовут общую доменную логику. Между ними — слой адаптеров, который превращает старый формат в новый и обратно.
@RestController
@RequestMapping("/v1/orders")
class OrderControllerV1(private val service: OrderService) {
@GetMapping("/{id}")
fun get(@PathVariable id: String): OrderV1Dto =
OrderV1Dto.from(service.findById(id))
}
@RestController
@RequestMapping("/v2/orders")
class OrderControllerV2(private val service: OrderService) {
@GetMapping("/{id}")
fun get(@PathVariable id: String): OrderV2Dto =
OrderV2Dto.from(service.findById(id))
}Подходит, когда разница между версиями небольшая (косметика, переименования). Если различия серьёзные — этот путь забивает кодовую базу.
Один сервис, фасад-преобразователь
Сервис говорит на новой версии, отдельный фасад преобразует к старой для тех клиентов, что ещё на v1. Удобно, когда новая версия сильно отличается, и не хочется тащить старую логику дальше.
Два сервиса
Старая версия — отдельный legacy-сервис, новая — отдельный новый. Используется, когда между версиями принципиальная архитектурная разница, или когда старую поддерживают в режиме «не трогать, пока работает».
Deprecation и sunset
Объявление о выводе версии должно жить и в коде, и в документации.
- В заголовках ответа полезно вернуть
Deprecation: trueиSunset: Wed, 31 Dec 2026 23:59:59 GMT. Часть клиентов это даже логирует. - В документации — большое жёлтое предупреждение на странице старой версии.
- В личных кабинетах интеграторов — индивидуальные уведомления, особенно если есть телеметрия по клиентам.
- За пару месяцев до sunset — мониторинг кто ещё ходит. Часто оказывается, что один-два крупных клиента не успевают; договариваетесь индивидуально или сдвигаете дату.
Версионирование внутри организации
Внутренние API между сервисами одной компании заслуживают отдельных правил. Тут вы контролируете и продьюсера, и потребителя, и спешка с deprecation менее критична. Но и дисциплина чаще теряется: «у нас же все свои, договоримся».
Договоренность работает до момента, когда команда A меняет внутренний API без оповещения, а команда B узнаёт об этом из инцидента в проде. Поэтому даже внутри я рекомендую:
- Выпускать совместимые изменения свободно.
- Несовместимые — через переходный период с двумя версиями endpoint-а.
- Объявлять deprecation в обычном Slack-канале, не в устной форме.
Это не громоздко, но избавляет от половины внутренних инцидентов на стыке команд.
Чек-лист при изменении API
- Изменение совместимо? Если да — релиз в текущую версию, запись в changelog.
- Если нет — есть ли способ сделать его совместимым (новое поле вместо переименования, новый endpoint вместо изменения существующего)?
- Если нет и так — это либо новая версия, либо переходный период с явным флагом.
- Документация обновлена до релиза.
- Клиенты, которых это касается, оповещены и есть план миграции.
Что запомнить
Версионирование — это не про красивые URL. Это политика, по которой вы изменяете API так, чтобы клиенты могли мигрировать в комфортном темпе, а вы — двигаться, не ломая прод. Выбирайте простой механизм нумерации (обычно /vN/ в пути), держите 2–3 версии одновременно, не плодите версии из-за одного маленького изменения, и обязательно документируйте каждое изменение. Остальное — детали реализации, которые становятся видны только с опытом.