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

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

Как не сломать совместимость случайно

Договорённость «не менять» держится на людях, а проверка — на сборке. Три инструмента, которые ставят один раз.

Сравнение спецификаций. Описание 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 всё же нужен:

  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".

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