Когда API выходит в прод, у него появляются клиенты. Мобильные приложения, партнёры, ваш собственный фронтенд — все они рассчитывают на определённый контракт. Стоит изменить формат поля или удалить эндпоинт, и что-то где-то сломается.
Версионирование — это способ развивать API, не ломая тех, кто уже его использует.
Проще всего это видно на трёх изменениях подряд в одном ресурсе — и на том, что с каждым из них делает старый клиент.
Клиент, который игнорирует незнакомые поля и незнакомые значения enum, переживает два изменения из трёх. Версию поднимает только третье — переименование поля: его старый клиент пережить не может. Оговорка про второй такт важна: незнакомые поля Spring прощает сам, а незнакомое значение enum — нет, клиента к этому готовят отдельно (разбор ниже).
Версия в URL: почему именно такспросят на собеседовании
Самый распространённый подход — ставить версию прямо в путь:
/api/v1/orders
/api/v2/orders
Это удобно по нескольким причинам: версию видно в логах, в браузере, в документации. Запрос curl http://localhost/api/v1/orders говорит сам за себя — не нужно добавлять заголовки, чтобы понять, какую версию вы вызываете.
Есть два популярных альтернативных подхода, которые на практике создают проблемы:
Версия в query-параметре (/orders?version=1) рискует кешированием. Большинство прокси и CDN включают строку запроса в ключ кеша, и тогда всё в порядке; но настройка «игнорировать параметры запроса» встречается сплошь и рядом — её включают, чтобы не плодить копии из-за меток рекламных кампаний. Стоит кому-то это сделать, и клиент начнёт получать из кеша ответ не той версии, причём заметят это далеко не сразу.
Версия в заголовке (Accept-Version: v1) скрывает версию. Её не видно в логах, она не отображается в браузерной строке, а маршрутизация на прокси усложняется.
Справедливости ради: у заголовка есть уважаемая разновидность, которую применяют всерьёз. Версию кладут не в свой заголовок, а прямо в тип содержимого, который клиент просит в Accept:
Accept: application/vnd.github+json; version=2022-11-28
Так делает GitHub. Смысл в том, что версия описывает представление ресурса, а не другой ресурс, — и формально это ближе к тому, как задуман HTTP: адрес один, форматов ответа несколько. Приём рабочий, и если вы встретите его в чужом API, это не ошибка проектирования.
Но цена у него та же, что у любого заголовка: версию не видно в логе доступа, curl без заголовка молча получит какую-то версию по умолчанию, а кеш придётся настраивать через Vary: Accept. Для внутренних сервисов и обычных публичных API это лишняя сложность, поэтому дальше — версия в URL-пути.
Итого — версия в URL-пути. Формат: буква v + целое число. Только целое: v1, v2. Без минорных версий и дат:
| Путь | Годится | Почему |
|---|---|---|
/api/v1/orders | да | буква v и целое число |
/api/v2/orders | да | следующая версия рядом с первой |
/api/v1.2/orders | нет | минорная версия не нужна |
/api/2024/orders | нет | дата ничего не говорит о совместимости |
/orders | нет | ни /api, ни версии |
/v1/orders | нет | нет /api |
Зачем /api? Это пространство имён для бизнес-эндпоинтов. Служебные пути — /health, /metrics, /ready — работают вне него.
Когда создавать новую версию
Правило одно: новая версия создаётся только при breaking change. Если изменение обратно совместимо — оно идёт в текущую версию.
На первый взгляд кажется, что любое изменение ломает совместимость. Но это не так — есть большой класс изменений, которые клиенты обязаны переживать без поломок.
Ключевое соглашение: клиент должен игнорировать неизвестные поля и неизвестные значения enum в ответе. Это фундамент forward compatibility.
Если клиент настроен так, что падает при виде незнакомого поля, — он будет ломаться при каждом добавлении поля в API. Это проблема клиента, не API. Здесь есть тонкость: сам по себе Jackson на незнакомое поле падает, а вот в приложении на Spring Boot он настроен наоборот — лишние поля игнорируются. То есть клиент на Spring по умолчанию защищён, а самодельный клиент с голым new ObjectMapper() — нет.
Настройка, которая за это отвечает, в Jackson называется FAIL_ON_UNKNOWN_PROPERTIES, а в Spring Boot выставляется свойством:
spring:
jackson:
deserialization:
fail-on-unknown-properties: false # Boot ставит false и сам
С неизвестными полями спасает, с неизвестным enum — нет
А вот тут начинается то, что ломает людей в проде. «Клиент на Spring по умолчанию защищён» — верно только про поля. С незнакомым значением перечисления история другая, и она неприятная.
Добавили в API статус PARTIAL. Старый клиент, у которого в enum Status такого значения нет, разбирает ответ — и падает:
InvalidFormatException: Cannot deserialize value of type Status
from String "PARTIAL": not one of the values accepted for Enum class
Никакой ветки «прочее» не сработало: до неё дело не дошло, разбор JSON упал раньше. Две настройки Jackson, которые могли бы это спасти, по умолчанию выключены, и Spring Boot их не включает:
READ_UNKNOWN_ENUM_VALUES_AS_NULL— незнакомое значение станетnull;READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE— станет тем значением, которое помечено как запасное.
Получается, что добавление значения в перечисление считается неломающим изменением только при условии, что клиенты к этому готовы. Готовность делается на стороне клиента, одним из двух способов. Либо помечаем в своём перечислении запасное значение и включаем настройку:
enum Status {
CREATED, PAID, SHIPPED,
@JsonEnumDefaultValue UNKNOWN
}
spring:
jackson:
deserialization:
read-unknown-enum-values-using-default-value: true
Либо вообще не объявляем поле перечислением: принимаем его строкой и разбираем сами, а незнакомое значение кладём в ветку «прочее».
Практический вывод для владельца API: объявляя расширение перечисления неломающим, напишите это в документации прямо — «список значений может пополняться, клиент обязан переживать незнакомое». Иначе первое же новое значение уронит половину интеграций, и виноваты будут не клиенты.
Что считается 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поменялось, смысл тот же.
Про коды ошибок нужна оговорка, потому что рядом в списке ломающих стоит «изменить код ответа», и граница между ними не очевидна.
Считайте так: новый код на новый случай — можно, новый код на старый случай — нельзя. Завели проверку «нельзя заказать больше 100 штук» и на неё отвечаете новым 409 ORDER_LIMIT_EXCEEDED — это добавление: раньше такого запроса просто не бывало, никто не сломался. А вот если заказ на 150 штук вчера проходил, а сегодня получает 409, — это ломающее изменение, и не важно, что код новый: у клиента перестал работать сценарий, который работал. То же самое с полем detail: менять текст можно, потому что клиент на него не опирается (он смотрит на code), а вот менять сам code у существующей ошибки — нельзя.
Частая ошибка: создать v2 ради добавления необязательного поля. Это лишнее — оба клиента, v1 и v2, получат одинаковый ответ, только у вас появится бремя поддержки двух версий без причины.
Как не сломать совместимость случайно
Договорённость «не менять» держится на людях, а проверка — на сборке. Три инструмента, которые ставят один раз.
Сравнение спецификаций. Описание API хранят в репозитории, и в сборке сравнивают текущую версию с той, что в основной ветке: инструменты сравнения (openapi-diff, oasdiff) различают совместимые изменения (добавили поле, добавили ручку) и ломающие (убрали поле, сузили тип, сделали обязательным то, что было необязательным). Сборка падает на ломающем изменении в существующей версии — и это единственный способ, который работает без внимательности человека.
Линт контракта. spectral проверяет описание на правила: единый стиль имён, обязательные описания полей, наличие примеров, коды ответов. Без него описание постепенно расползается.
Контрактные тесты. Проверяют, что сервер отвечает так, как описано: ответ валидируется по схеме из описания. Иначе описание и реальность разъезжаются, и клиент, написанный по описанию, падает на настоящем ответе.
Подробно про сборку — в статье про API-first; здесь важно, что без этих трёх проверок правило «внутри версии нельзя ломать» — это пожелание, а не гарантия.
Где болит на самом деле
Пример с двумя контроллерами выглядит безобидно: два пути, одни сценарии внутри. Болит в четырёх местах, и их стоит знать заранее.
Два представления на один домен. У версий разные объекты передачи данных, и это правильно — иначе изменение в общем классе поедет в обе версии. Значит, появляется два набора классов и два преобразования из доменной модели. Это осознанная плата за совместимость, и она растёт линейно числу версий — поэтому версий держат две, а не пять.
Преобразование, а не наследование. Соблазн сделать OrderV2Response extends OrderV1Response велик и ошибочен: добавление поля в родителя поедет в старую версию, удаление сломает компиляцию. Версии не наследуют друг друга; между доменной моделью и каждым представлением стоит своё отображение (руками или через генератор отображений).
Валидация не дублируется. Проверки предметной области живут в домене и в сценарии, а не в объектах передачи данных обеих версий. В представлениях остаются только проверки формата (обязательность, длина, диапазон) — и это как раз то, что различается между версиями и потому дублируется законно.
Ошибки, которых в старой версии не было. Вторая версия ввела новое правило — и у неё появился код ошибки, которого клиент первой версии не знает. Обратное тоже бывает: в старой версии отдавали 400, в новой это 409. Правило простое: набор ошибок — часть контракта версии, и в старой версии он не меняется. Если новая проверка нужна и в старой версии (например, по требованиям безопасности), это ломающее изменение внутри версии, и его согласуют с клиентами.
Когда версия не нужна вовсе
Версия — инструмент совместимости с теми, кого вы не можете выкатить вместе с собой. Если таких нет, она только мешает.
Признаки, что версия избыточна: сервис внутренний, потребитель один (или несколько, но все ваши), релиз общий или согласованный, контракт меняется вместе с обеими сторонами. В этом случае /api/v1/ в пути — просто три лишних сегмента, которые однажды придётся объяснять.
Практический компромисс, который встречается чаще всего: путь без версии для внутренних сервисов и с версией — для того, что выходит наружу (публичный API, мобильные приложения, партнёры). И правило на будущее: добавить версию в путь позже дешевле, чем от неё избавиться, — поэтому если есть шанс, что API станет публичным, версию закладывают сразу.
Глубже: как узнать, кто ещё сидит на старой версиирасширенное
Шаг «после того как клиенты перешли» невыполним, пока нет ответа на вопрос «а перешли?». Ответ даёт измерение, и оно устроено просто.
Метрика по версии. Каждый запрос учитывается с меткой версии из пути: http.server.requests{uri="/api/v1/orders"}. График по этой метке показывает, сколько трафика ещё идёт в старую версию и как он спадает. Это минимум, который стоит настроить в тот же день, когда появилась вторая версия.
Метрика по клиенту. Одного объёма недостаточно: чтобы кого-то попросить перейти, надо знать, кого именно. Поэтому к метке версии добавляют идентификатор клиента — из токена, из ключа API, из заголовка с именем приложения. Тогда отчёт «кто ещё на первой версии» строится одним запросом к системе метрик.
@Component
class ApiVersionMetrics implements HandlerInterceptor {
private final MeterRegistry registry;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
String version = request.getRequestURI().startsWith("/api/v1/") ? "v1" : "v2";
registry.counter("api.requests", "version", version, "client", clientIdOf(request)).increment();
return true;
}
}
Отчёт перед отключением. За месяц до даты отключения смотрят: кто обращался к старой версии за последние две недели, сколько раз, какими ручками. Клиентам из этого списка пишут адресно; тем, кто не ответил, уточняют дату. Отключают, когда счётчик держится на нуле оговорённый срок — обычно две недели.
Что делать, если клиента не опознать (публичный API без ключей): тогда единственный инструмент — заголовки вывода из эксплуатации (Deprecation, Sunset) и объявление в документации, а отключение делают поэтапно: сначала кратковременные «репетиции» (отдавать ошибку на пять минут в известное время), потом полное отключение. Репетиция находит тех, кто читает документацию задним числом.
Глубже: ломающее изменение внутри версии: договориться и выкатить вместерасширенноеспросят на собеседовании
Самый частый случай в жизни — не вторая версия, а разговор с единственным потребителем. Если API внутренний и клиентов один-два, вторая версия приносит больше издержек, чем пользы: два контроллера, два представления, метрики, отключение.
Рабочий порядок: согласовать изменение с владельцами клиентов, договориться о дате, выкатить синхронно. Технически это делают через совместимость на время выката, а не через версию: сначала сервер начинает принимать оба варианта (новое поле необязательное, старое ещё работает), потом клиенты переходят, потом сервер убирает старое. Те же три шага, что в миграции схемы базы, — расширение, переход, сжатие.
Условия, при которых так можно: клиентов мало, они ваши, и есть возможность выкатить их согласованно. Как только клиент внешний или их десятки — так нельзя, нужна версия.
Глубже: версионируется не только URLрасширенноеспросят на собеседовании
API — не единственный контракт сервиса, и остальные ломаются так же.
События в очереди. Схема сообщения — такой же контракт, и у него та же арифметика совместимости: добавление необязательного поля безопасно, изменение смысла — нет. Версию обычно кладут в тип события (OrderCreated.v2) или в заголовок сообщения, а на время перехода публикуют оба варианта. Подробно — в статье про эволюцию схемы.
Схема базы под двумя версиями API. Если первая версия читает колонку address строкой, а вторая — структурой из трёх полей, в базе на время перехода живут оба представления, и пишутся оба. Это обычное расширение и сжатие схемы, только продлённое на срок жизни старой версии API — то есть старая версия API удерживает старую схему базы, и это стоит учитывать, планируя отключение.
Что делать, когда новая версия пишет то, чего старая не умеет читать. Ситуация: вторая версия разрешила заказ без адреса доставки (самовывоз), а первая версия отдаёт адрес обязательным полем. Варианта два: отдавать в первой версии осмысленную заглушку (пустая строка, «самовывоз») или отвечать первой версии ошибкой для таких заказов. Первое почти всегда лучше: клиент старой версии продолжает работать, а не падает на части данных. Решение принимают в момент проектирования второй версии, а не когда в базе уже есть такие заказы.
Глубже: как поддерживать v1 и v2 параллельнорасширенное
Когда breaking change всё же нужен:
- Создаёте
v2с новым контрактом. v1продолжает работать без изменений.- Сообщаете клиентам о том, что
v1будет выведен из работы — через заголовокSunsetв ответах, через документацию. - После того, как клиенты мигрировали (обычно 6-12 месяцев) — удаляете
v1.
Про Sunset стоит сказать конкретнее, потому что одно название тут ничего не даёт. Это заголовок из RFC 8594, и в нём лежит дата и время выключения эндпоинта — в обычном для HTTP формате, том же, что у Date и Expires:
HTTP/1.1 200 OK
Deprecation: @1756684800
Sunset: Tue, 01 Sep 2026 00:00:00 GMT
Link: </api/v2/orders>; rel="successor-version"
Рядом с ним ходят ещё два. Deprecation (RFC 9745) отвечает на вопрос «с какого момента считается устаревшим» — и это момент в прошлом или настоящем, за 6-12 месяцев до Sunset, а не тот же самый. Link с rel="successor-version" показывает, куда переезжать.
Заголовки ставят на все ответы выводимой версии, а не один раз в письме: письмо прочтёт человек, а заголовок увидит каждый вызов, и его можно поймать мониторингом. Подробнее — в статье Rate limiting, файлы и вывод эндпоинтов.
В Spring Boot это выглядит прямолинейно:
@RestController
@RequestMapping("/api/v1/orders")
public class OrderControllerV1 {
// старый контракт
}
@RestController
@RequestMapping("/api/v2/orders")
public class OrderControllerV2 {
// новый контракт
}
Под капотом оба контроллера могут использовать одни и те же use case-объекты — разные только DTO и маппинг. Так v2 не дублирует бизнес-логику, а лишь представляет её в новом формате.
Что дублируется при двух живых версиях, а что общее; при отключении v1 уходит вся верхняя строка, а обработчик и домен остаются.
Коротко
- Версия в URL-пути:
/api/v1/orders. Формат —v+ целое число, без минорных версий и дат. - Префикс
/apiобязателен для бизнес-эндпоинтов;/health,/metrics— вне него. - Новая версия создаётся только при breaking change. Non-breaking изменения — в текущую версию.
- Клиент должен игнорировать неизвестные поля и enum-значения в ответе — это основа forward compatibility. Неизвестные поля Spring Boot прощает сам; неизвестное значение enum — нет, нужен
@JsonEnumDefaultValueи включённая настройкаread-unknown-enum-values-using-default-value. - Breaking: удаление/переименование поля, изменение типа, удаление enum-значения, изменение HTTP-метода, URL, кода ответа, ужесточение валидации.
- Non-breaking: добавление optional поля, нового enum-значения, нового эндпоинта, ослабление валидации.
- Новый код ошибки на новый случай — можно; новый код на запрос, который раньше проходил, — ломающее изменение.
- Версия в query (
?version=1) и в собственном заголовке — не используем. Версия в типе содержимого (Accept: application/vnd.shop+json; version=2) — рабочий приём, но со своей ценой: не видно в логах, нуженVary: Accept. - v1 и v2 живут параллельно; v1 выводится с заголовками
Deprecation(дата объявления, в прошлом),Sunset(дата выключения, HTTP-дата) иLink; rel="successor-version".
Что почитать дальше
- URL и ресурсы REST — как строить пути
/api/v1/.... - Ошибки RFC 9457 — добавление нового error code это non-breaking.
- JSON и формат ответов — добавление optional полей в ответ.