API Versioning Strategies — three side-by-side approaches: URL path versioning (/v1, /v2 in parallel), Stripe-style date-based versioning with per-account pinned date and request/response transformer middleware, and GraphQL @deprecated directive with field usage tracking. Includes ADRs for when to pick each approach plus a deprecation lifecycle scenario covering Sunset/Deprecation HTTP headers, brownouts, and 410 Gone after the grace period.
Любой публичный API через год потребует breaking changes: переименовать поле, ужесточить тип, выкинуть endpoint. Если контракт не зафиксирован — мобильные клиенты в сторах ломаются молча, partner integrations начинают возвращать 500, а вы узнаёте об этом из support-тикетов. Versioning — это обязательный контракт о том, как долго старая форма API будет жить и как клиенты узнают о грядущих изменениях. Без этого контракта вы не имеете права что-либо менять — каждое изменение становится outage для тех, кого вы не контролируете.
Знание стратегий + их trade-offs — must-have для любого backend-инженера, который собирается выкатывать API наружу.
«Versioning не про то, как назвать v1 и v2. Versioning — это контракт о том, как долго каждая форма ответа будет работать и как клиент узнает о sunset. Стратегия выбирается под темп breaking changes и состав аудитории, а не по моде.»
Три осевых вопроса, которые решает любой подход:
Ответы на эти три вопроса однозначно проецируются на одну из стратегий ниже.
Три колонки — три стратегии, упакованные в одну топологию для прямого сравнения:
Колонка 1 (URL path). Mobile v1.0 и Mobile v2.0 стучат в общий API Gateway, который по префиксу пути роутит на /v1 handlers или /v2 handlers. Оба слоя ходят в один Core service — то есть в коде живёт один движок, а v1/v2 — тонкие адаптеры формата. Это самый частый сетап (Stripe URL, GitHub REST, Twilio, Twitter v1.1/v2): видно глазами, кэшируется как два разных ресурса, дебажится через curl.
Колонка 2 (Stripe-Version header). Два партнёра запинены на разные даты (2023-08-16 и 2025-04-10). Запрос приходит на единый API entry, дальше Version transformer поднимает chain преобразований по pinned-дате аккаунта, приводит запрос к latest-формату, зовёт Core service (latest), и на обратном пути применяет reverse-трансформации. Внешне один URL — внутри полтора десятка адаптеров, навешанных historically.
Колонка 3 (GraphQL @deprecated). Web app дёргает один POST /graphql, схема эволюционирует additive. Каждый field-resolver инкрементит счётчик в Field usage tracker — это основа политики удаления: поле удаляется только когда count == 0 за rolling window. Версий как сущности нет вообще — есть deprecation directive и метрика usage.
На каждой стратегии сидит ADR (кнопка [ADR] на gateway-ноде), который объясняет в каких условиях её стоит выбрать и какие у неё обязательные операционные требования.
url-path-parallel. Параллельный хостинг /v1 и /v2. mobile-old зовёт /v1/orders/42, gateway по префиксу маршрутизирует в v1-handler, тот вызывает core и возвращает legacy-shape {total: 1999}. Параллельно mobile-new зовёт /v2/orders/42 и получает обогащённую форму {amount: {value: 1999, currency: "USD"}}. Один и тот же core.getOrder(42) отдаёт одну внутреннюю структуру — расхождение только в адаптере. Финальный шаг — v1-ответ несёт Sunset и Deprecation заголовки: контракт заявлен явно, а не намёком.
stripe-date-based. Партнёр на пин-дате 2023-08-16 шлёт запрос, gateway по аккаунту поднимает длинную chain трансформеров (всё, что было breaking с 2023-08-16 до сегодня), нормализует запрос к latest, ходит в core, и на обратном пути «откатывает» формат: amount.value -> amount, customer_id -> customer. Партнёр на свежей пин-дате 2025-04-10 идёт по более короткой цепочке — почти passthrough. Ключевой инвариант, подсвеченный showError в конце: аккаунт никогда не апгрейдится автоматически — миграция всегда инициатива клиента (он меняет один заголовок).
graphql-deprecated. Старый клиент запрашивает fullName, новый — firstName + lastName. Оба запроса работают одновременно, потому что схема additive, а resolver fullName вычисляется как firstName + ' ' + lastName. Каждый вызов фиксируется в usage tracker. Через introspection клиент видит isDeprecated=true с reason. Перед удалением сервер сам себя спрашивает «сколько callers за 30d?», получает 0, удаляет поле — и уже после этого старый запрос даёт structured error с указанием замены, а не загадочный 500.
deprecation-lifecycle. Полный playbook sunset, который применим к любой стратегии: T+0 — заголовки Sunset + Deprecation + Link rel="successor-version". T+3 месяца — email-нотификация владельцу аккаунта. T+5 месяцев — brownouts: /v1 раз в неделю на час возвращает 503, чтобы заставить ops-команды клиентов заметить интеграцию, про которую все забыли. T+12 — 410 Gone с JSON-телом, указывающим successor. И только после этого код v1 удаляется из репозитория.
В коде диаграммы вшиты три полноценных ADR на gateway-нодах каждой колонки — открываются кнопкой [ADR]. Сжатое сравнение:
| Стратегия | Стоимость на сервере | Стоимость для клиента | Когда выбирать |
|---|---|---|---|
URL path /v1, /v2 | Параллельные хендлеры | Сменить URL — большой commit | Default для публичных API, неизвестные интеграторы |
Header (X-API-Version) | То же, плюс лог-инфраструктура | URL стабилен, но invisible | Когда стабильность URL критична (CDN-shards) |
Media type (Accept: vnd.x.v2+json) | Per-resource роутинг | Verbose, обучение клиентов | HATEOAS-strict, нишевые API |
| Date-based (Stripe) | Transformer chain, навсегда | Сменить один заголовок | Только если вы контролируете SDK + ломаете часто |
| GraphQL @deprecated | Field usage tracking | Просто не запрашивать поле | Любой GraphQL API |
| gRPC service v2 | Service explosion в proto | Перегенерить клиент | Internal microservices |
Сквозные trade-offs независимо от стратегии:
Stripe-Version: 2024-04-10, per-account pinning, transformer middleware. Брандон Лич описал систему в посте APIs as Infrastructure./v3, плавная депрекация в пользу GraphQL.@deprecated(reason: "...") на полях, schema preview за feature-flag.2024-04, 2024-07), unstable preview ежемесячно. Hybrid date + URL./v18.0/me/feed. Каждая major-версия живёт два года по официальной политике./2010-04-01/, чёткие deprecation timelines, сервис-уровневые версии (отдельно для Voice, отдельно для Messaging).compute.v1, compute.beta).Sunset header и grace period. Production outage у партнёров, иногда с юридическими последствиями.Не любой API требует formal versioning. Берите минимальный подход (или вообще без версий) когда:
@deprecated + usage tracking покрывают 90% задач.Главный антитезис: версионирование — обязательство на годы. Не вводите его, если у вас нет операционных мускулов на поддержку двух версий параллельно.