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

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

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

Проще всего это видно на трёх изменениях подряд в одном ресурсе — и на том, что с каждым из них делает старый клиент.

изменение в /api/v1/ordersчто делает старый клиентверсия+ поле discountнеобязательное, в ответполя нет в его моделимолча пропустилостаётся v1 + статус PARTIALновое значение enumстатус незнакомый —ушёл в ветку «прочее»остаётся v1 customerId → buyerIdполе переименовалиcustomerId пустойзаказ без покупателянужен v2 итог: два изменения — в v1, третье подняло версию/api/v1/ordersживёт по Sunset 6-12 мес/api/v2/ordersсоздан из-за buyerId

Клиент, который игнорирует незнакомые поля и незнакомые значения 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, получат одинаковый ответ, только у вас появится бремя поддержки двух версий без причины.

Как узнать, кто ещё сидит на старой версии

Шаг «после того как клиенты перешли» невыполним, пока нет ответа на вопрос «а перешли?». Ответ даёт измерение, и оно устроено просто.

Метрика по версии. Каждый запрос учитывается с меткой версии из пути: 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 хранят в репозитории, и в сборке сравнивают текущую версию с той, что в основной ветке: инструменты сравнения (openapi-diff, oasdiff) различают совместимые изменения (добавили поле, добавили ручку) и ломающие (убрали поле, сузили тип, сделали обязательным то, что было необязательным). Сборка падает на ломающем изменении в существующей версии — и это единственный способ, который работает без внимательности человека.

Линт контракта. spectral проверяет описание на правила: единый стиль имён, обязательные описания полей, наличие примеров, коды ответов. Без него описание постепенно расползается.

Контрактные тесты. Проверяют, что сервер отвечает так, как описано: ответ валидируется по схеме из описания. Иначе описание и реальность разъезжаются, и клиент, написанный по описанию, падает на настоящем ответе.

Подробно про сборку — в статье про API-first; здесь важно, что без этих трёх проверок правило «внутри версии нельзя ломать» — это пожелание, а не гарантия.

Где болит на самом деле

Пример с двумя контроллерами выглядит безобидно: два пути, одни сценарии внутри. Болит в четырёх местах, и их стоит знать заранее.

Два представления на один домен. У версий разные объекты передачи данных, и это правильно — иначе изменение в общем классе поедет в обе версии. Значит, появляется два набора классов и два преобразования из доменной модели. Это осознанная плата за совместимость, и она растёт линейно числу версий — поэтому версий держат две, а не пять.

Преобразование, а не наследование. Соблазн сделать OrderV2Response extends OrderV1Response велик и ошибочен: добавление поля в родителя поедет в старую версию, удаление сломает компиляцию. Версии не наследуют друг друга; между доменной моделью и каждым представлением стоит своё отображение (руками или через генератор отображений).

Валидация не дублируется. Проверки предметной области живут в домене и в сценарии, а не в объектах передачи данных обеих версий. В представлениях остаются только проверки формата (обязательность, длина, диапазон) — и это как раз то, что различается между версиями и потому дублируется законно.

Ошибки, которых в старой версии не было. Вторая версия ввела новое правило — и у неё появился код ошибки, которого клиент первой версии не знает. Обратное тоже бывает: в старой версии отдавали 400, в новой это 409. Правило простое: набор ошибок — часть контракта версии, и в старой версии он не меняется. Если новая проверка нужна и в старой версии (например, по требованиям безопасности), это ломающее изменение внутри версии, и его согласуют с клиентами.

Ломающее изменение внутри версии: договориться и выкатить вместе

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

Рабочий порядок: согласовать изменение с владельцами клиентов, договориться о дате, выкатить синхронно. Технически это делают через совместимость на время выката, а не через версию: сначала сервер начинает принимать оба варианта (новое поле необязательное, старое ещё работает), потом клиенты переходят, потом сервер убирает старое. Те же три шага, что в миграции схемы базы, — расширение, переход, сжатие.

Условия, при которых так можно: клиентов мало, они ваши, и есть возможность выкатить их согласованно. Как только клиент внешний или их десятки — так нельзя, нужна версия.

Версионируется не только URL

API — не единственный контракт сервиса, и остальные ломаются так же.

События в очереди. Схема сообщения — такой же контракт, и у него та же арифметика совместимости: добавление необязательного поля безопасно, изменение смысла — нет. Версию обычно кладут в тип события (OrderCreated.v2) или в заголовок сообщения, а на время перехода публикуют оба варианта. Подробно — в статье про эволюцию схемы.

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

Что делать, когда новая версия пишет то, чего старая не умеет читать. Ситуация: вторая версия разрешила заказ без адреса доставки (самовывоз), а первая версия отдаёт адрес обязательным полем. Варианта два: отдавать в первой версии осмысленную заглушку (пустая строка, «самовывоз») или отвечать первой версии ошибкой для таких заказов. Первое почти всегда лучше: клиент старой версии продолжает работать, а не падает на части данных. Решение принимают в момент проектирования второй версии, а не когда в базе уже есть такие заказы.

Когда версия не нужна вовсе

Версия — инструмент совместимости с теми, кого вы не можете выкатить вместе с собой. Если таких нет, она только мешает.

Признаки, что версия избыточна: сервис внутренний, потребитель один (или несколько, но все ваши), релиз общий или согласованный, контракт меняется вместе с обеими сторонами. В этом случае /api/v1/ в пути — просто три лишних сегмента, которые однажды придётся объяснять.

Практический компромисс, который встречается чаще всего: путь без версии для внутренних сервисов и с версией — для того, что выходит наружу (публичный API, мобильные приложения, партнёры). И правило на будущее: добавить версию в путь позже дешевле, чем от неё избавиться, — поэтому если есть шанс, что API станет публичным, версию закладывают сразу.

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

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

  1. Создаёте v2 с новым контрактом.
  2. v1 продолжает работать без изменений.
  3. Сообщаете клиентам о том, что v1 будет выведен из работы — через заголовок Sunset в ответах, через документацию.
  4. После того, как клиенты мигрировали (обычно 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 OrderV1Response контроллер v2 OrderV2Response домен заказа одна модель

Что дублируется при двух живых версиях, а что общее; при отключении 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".

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