Когда 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 всё же нужен:
- Создаёте
v2с новым контрактом. v1продолжает работать без изменений.- Сообщаете клиентам о том, что
v1будет выведен из работы — через заголовокSunsetв ответах, через документацию. - После того, как клиенты мигрировали (обычно 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-заголовком после миграции клиентов.
Что почитать дальше
- URL и ресурсы REST — как строить пути
/api/v1/.... - Ошибки RFC 9457 — добавление нового error code это non-breaking.
- JSON и формат ответов — добавление optional полей в ответ.