← назад к разделу

Когда API выходит в прод, у него появляются клиенты. Мобильные приложения, партнёры, ваш собственный фронтенд — все они рассчитывают на определённый контракт. Стоит изменить формат поля или удалить эндпоинт, и что-то где-то сломается.

Версионирование — это способ развивать API, не ломая тех, кто уже его использует.

Версия в URL: почему именно так

Самый распространённый подход — ставить версию прямо в путь:

/api/v1/orders
/api/v2/orders

Это удобно по нескольким причинам: версию видно в логах, в браузере, в документации. Запрос curl http://localhost/api/v1/orders говорит сам за себя — не нужно добавлять заголовки, чтобы понять, какую версию вы вызываете.

Есть два популярных альтернативных подхода, которые на практике создают проблемы:

Версия в query-параметре (/orders?version=1) ломает кеширование. Прокси и CDN строят ключ кеша по URL. Если URL одинаковый для разных версий, клиент может получить ответ не той версии из кеша.

Версия в заголовке (Accept-Version: v1) скрывает версию. Её не видно в логах, она не отображается в браузерной строке, а маршрутизация на прокси усложняется.

Поэтому — версия в URL-пути. Формат: буква v + целое число. Только целое: v1, v2. Без минорных версий и дат:

/api/v1/orders         ✓
/api/v2/orders         ✓

/api/v1.2/orders       ✗  — минорная версия не нужна
/api/2024/orders       ✗  — дата-версия не говорит о совместимости
/orders                ✗  — без /api и без версии
/v1/orders             ✗  — без /api

Зачем /api? Это пространство имён для бизнес-эндпоинтов. Служебные пути — /health, /metrics, /ready — работают вне него.

Когда создавать новую версию

Правило одно: новая версия создаётся только при breaking change. Если изменение обратно совместимо — оно идёт в текущую версию.

На первый взгляд кажется, что любое изменение ломает совместимость. Но это не так — есть большой класс изменений, которые клиенты обязаны переживать без поломок.

Ключевое соглашение: клиент должен игнорировать неизвестные поля и неизвестные значения enum в ответе. Это фундамент forward compatibility.

Если клиент настроен так, что падает при виде незнакомого поля (например, Jackson с failOnUnknownProperties: true) — он будет ломаться при каждом добавлении поля в API. Это проблема клиента, не API. Jackson и Spring RestClient по умолчанию неизвестные поля игнорируют — это правильный default.

Что считается breaking change

Breaking change — это изменение, которое ломает существующих клиентов без каких-либо правок с их стороны:

  • Удалить или переименовать поле — клиент читал customerId, теперь его нет.
  • Изменить тип поля — было string, стало number. Клиент распарсит иначе или упадёт.
  • Удалить значение из enum — клиент получает статус, которого больше нет в перечислении.
  • Добавить обязательный параметр — запрос без него начинает падать с ошибкой.
  • Изменить HTTP-метод — был POST /orders, стал PUT /orders.
  • Изменить URL/orders превратился в /sales-orders.
  • Изменить код ответа — был 200, стал 201. Клиенты, проверяющие точный код, сломаются.
  • Изменить семантику поля — поле total раньше включало налоги, теперь нет. Данные те же, смысл другой.
  • Ужесточить валидациюmaxLength уменьшился, запросы, которые раньше проходили, начинают отклоняться.
  • Удалить эндпоинт — клиент вызывает его, получает 404.

Что не ломает совместимость

Эти изменения можно вносить в текущую версию без bump:

  • Добавить необязательное поле в ответ — клиент его проигнорирует, если не знает о нём.
  • Добавить необязательный query-параметр — старые клиенты его не передают, это нормально.
  • Добавить новое значение в enum — клиент, который корректно обрабатывает unknown enum values, не сломается.
  • Добавить новый эндпоинт — никто его не вызывает принудительно.
  • Ослабить валидацию — запросы, которые раньше отклонялись, теперь проходят. Это только к лучшему для клиента.
  • Добавить новый код ошибки — у клиентов, не знающих этого кода, должна быть логика «неизвестная ошибка».
  • Улучшить текст сообщения об ошибке — поле detail поменялось, смысл тот же.

Частая ошибка: создать v2 ради добавления необязательного поля. Это лишнее — оба клиента, v1 и v2, получат одинаковый ответ, только у вас появится бремя поддержки двух версий без причины.

Как поддерживать v1 и v2 параллельно

Когда breaking change всё же нужен:

  1. Создаёте v2 с новым контрактом.
  2. v1 продолжает работать без изменений.
  3. Сообщаете клиентам о том, что v1 будет выведен из работы — через заголовок Sunset в ответах, через документацию.
  4. После того, как клиенты мигрировали (обычно 6-12 месяцев) — удаляете v1.

В Spring Boot это выглядит прямолинейно:

@RestController
@RequestMapping("/api/v1/orders")
public class OrderControllerV1 {
    // старый контракт
}

@RestController
@RequestMapping("/api/v2/orders")
public class OrderControllerV2 {
    // новый контракт
}

Под капотом оба контроллера могут использовать одни и те же use case-объекты — разные только DTO и маппинг. Так v2 не дублирует бизнес-логику, а лишь представляет её в новом формате.

Коротко

  • Версия в URL-пути: /api/v1/orders. Формат — v + целое число, без минорных версий и дат.
  • Префикс /api обязателен для бизнес-эндпоинтов; /health, /metrics — вне него.
  • Новая версия создаётся только при breaking change. Non-breaking изменения — в текущую версию.
  • Клиент должен игнорировать неизвестные поля и enum-значения в ответе — это основа forward compatibility.
  • Breaking: удаление/переименование поля, изменение типа, удаление enum-значения, изменение HTTP-метода, URL, кода ответа, ужесточение валидации.
  • Non-breaking: добавление optional поля, нового enum-значения, нового эндпоинта, ослабление валидации.
  • Версия в query (?version=1) и в заголовке — не используются.
  • v1 и v2 живут параллельно; v1 выводится с Sunset-заголовком после миграции клиентов.

Что почитать дальше