В первой версии веб-продукта API кажется чем-то внутренним.
Есть frontend.
Есть backend.
Они разрабатываются одной командой и обновляются почти одновременно.
Сегодня backend возвращает:
{
"id": 1842,
"status": "active"
}Завтра разработчику становится удобнее назвать поле иначе:
{
"id": 1842,
"state": "active"
}Frontend поправили в том же commit.
Всё работает.
Проходит два года.
Теперь этим же API пользуются:
Web application
Android 5.8
Android 5.4
iOS 5.7
партнёрская CRM
внутренний worker
интеграция клиентаИ внезапно простое:
status → stateперестаёт быть рефакторингом.
Оно становится breaking change.
Потому что API уже не принадлежит только backend-разработчику.
У него появились потребители со своими release cycle, своей скоростью обновления и своим кодом.
Именно в этот момент становится понятно, зачем API нужно проектировать не только так, чтобы им было удобно пользоваться сегодня, но и так, чтобы его можно было безопасно изменять завтра.
API — это контракт, а не форма JSON
Представим endpoint:
GET /projects/1842Он возвращает:
{
"id": 1842,
"title": "CRM",
"status": "in_progress"
}Контракт включает значительно больше, чем названия трёх полей.
Клиент может зависеть от:
HTTP method;
URL;
status code;
типов полей;
nullable/non-nullable;
enum values;
формата дат;
pagination;
authorization;
ошибок;
порядка бизнес-переходов.Например сервер раньше возвращал:
{
"price": 50000
}а после обновления:
{
"price": "50000"
}Визуально почти ничего не изменилось.
Для строго типизированного клиента это два разных контракта.
Что на самом деле является breaking change
Очевидный пример:
удалить endpoint.Или:
status → state.Но опасных изменений намного больше.
GitHub, например, в своей текущей политике версионирования относит к breaking changes удаление или переименование parameter/response field, изменение типа, добавление нового обязательного параметра, превращение optional в required, удаление enum value, ужесточение validation и изменения требований authentication/authorization.
То есть даже изменение:
password:
минимум 8 символов
↓
минимум 16 символовможет нарушить существующий API-контракт.
Хотя endpoint, JSON и HTTP method вообще не изменились.
Поэтому версия API — это версия поведения
Не обязательно только:
/v1/или:
/v2/Версия определяет набор гарантий:
Если клиент работает с этим контрактом, какие предположения он имеет право делать?
Например:
status всегда string;
createdAt всегда ISO 8601;
DELETE повторно безопасен;
поле projectId не исчезнет;
enum не потеряет существующее значение без новой версии.Это значительно важнее самой формы version number.
Самая дешёвая версия API — та, которую не пришлось создавать
Первый инстинкт при любом изменении:
Делаем /v2.Потом:
/v1/projects
/v2/projects
/v3/projectsЧерез несколько лет команда поддерживает три почти одинаковых API.
Это тоже технический долг.
Поэтому сначала стоит задать другой вопрос:
можно ли сделать изменение обратно совместимым?
Например нужно добавить поле
Было:
{
"id": 1842,
"title": "CRM"
}Стало:
{
"id": 1842,
"title": "CRM",
"archived": false
}Для нормально спроектированного клиента дополнительное поле обычно не должно создавать проблему.
GitHub также классифицирует добавление response field, нового optional parameter или нового endpoint как additive changes, которые не требуют breaking-version transition в его модели API.
Поэтому новый:
archivedсовсем не обязательно означает:
/v2.Но additive change тоже требует дисциплины клиента
Представим мобильное приложение десериализует JSON очень строго:
разрешены только:
id
titleЛюбое неизвестное поле:
archivedвызывает exception.
Теперь формально additive server change ломает клиент.
Поэтому долговечность API зависит и от того, как проектируются consumers.
Хороший клиент обычно должен уметь игнорировать неизвестные поля, если контракт явно не требует обратного.
Особенно осторожно нужно относиться к enum
Было:
status:
NEW
ACTIVE
COMPLETEDЧерез год backend добавил:
PAUSEDКажется:
Мы ничего не удалили.
Но старый mobile client содержит:
switch (status) {
case 'NEW':
case 'ACTIVE':
case 'COMPLETED':
}И не знает:
PAUSED.Получается, даже добавление enum value может повредить плохо подготовленному клиенту.
Поэтому enum должен иметь стратегию неизвестного значения
Например UI способен показать:
UNKNOWNили:
Другой статус.А критичная бизнес-логика не должна автоматически интерпретировать неизвестное значение как:
COMPLETED.Иными словами, backward compatibility — это не только политика сервера.
Это культура всей системы.
Ещё один важный принцип: не менять смысл существующего поля
Это один из самых опасных видов скрытого breaking change.
Было:
{
"amount": 50000
}и документация говорит:
amount — рубли.Через год разработчики решают:
Деньги лучше хранить в копейках.
И начинают возвращать:
{
"amount": 5000000
}Тип:
numberостался прежним.
Поле:
amountтоже.
Но контракт полностью сломан.
Если семантика изменилась — лучше новое поле
Например:
{
"amount": 50000,
"amountMinor": 5000000,
"currency": "RUB"
}Старый клиент продолжает работать.
Новый постепенно переходит на:
amountMinor.Позже старое поле можно deprecate.
Так гораздо безопаснее, чем тихо менять смысл уже опубликованного значения.
Аналогично с датами
Было:
2026-10-04и это означало:
локальный календарный день.Нельзя без новой семантики внезапно начать возвращать:
2026-10-04T00:00:00Zи считать, что это то же самое.
Дата без времени и timestamp — разные доменные значения.
Хороший API старается быть расширяемым
Например вместо:
{
"success": true
}для долгоживущего сложного процесса полезнее иногда иметь:
{
"status": "completed"
}Потому что через год может появиться:
pending
processing
failed
cancelled.Но это не означает, что нужно заранее проектировать сотни гипотетических состояний.
Нужно лишь избегать контрактов, которые искусственно закрывают очевидные направления роста.
Versioning нужен, когда совместимость сохранить уже нельзя
Например старый API:
POST /v1/paymentsпринимает:
{
"orderId": 1842,
"amount": 50000
}Но новая архитектура принципиально меняет модель.
Теперь:
цена вычисляется только сервером;
появились PaymentIntent;
несколько попыток оплаты;
новый state machine.Продолжать притворяться, что старый контракт тот же, опасно.
Здесь новая версия может быть правильным решением.
Но версия должна отделять именно несовместимое поведение
Не нужно создавать /v2 только потому, что:
добавили поле;
оптимизировали SQL;
сменили PostgreSQL;
переписали service layer.Внутренняя реализация не является частью public contract.
Потребителю должно быть всё равно:
монолит у вас
или микросервисы.Если наружное поведение осталось совместимым, новая версия API ему не нужна.
Как обозначать версию
Есть несколько распространённых вариантов.
Самый очевидный:
GET /api/v1/projects/1842Плюсы — версия заметна, её легко маршрутизировать, понимать в логах и документации.
Минус — URL начинает содержать технический lifecycle API.
Другой вариант — header:
X-Api-Version: 2026-10-01Тогда URL остаётся:
GET /api/projects/1842а контракт выбирается отдельно.
GitHub использует именно date-based header:
X-GitHub-Api-Version: 2026-03-10и выпускает breaking changes через новые API versions. Предыдущая версия после выхода новой поддерживается не менее 24 месяцев.
Дата вместо v2 тоже имеет смысл
Например:
2026-03-10сразу сообщает:
Это контракт состояния API на определённый момент.
В отличие от:
v17который без changelog ничего человеку не говорит.
Stripe использует свою модель версий и разделяет backwards-compatible monthly releases и major releases, содержащие несовместимые изменения; документация также рекомендует тестировать новую версию до окончательного переключения integration.
Нет универсально лучшего способа versioning.
Важно другое:
правило должно быть единым, документированным и предсказуемым.
Что хуже всего — скрытая версия
Например API выглядит:
GET /projectsв январе возвращает одно.
В июне — другое.
В октябре — третье.
Клиент не указывает версию.
Сервер просто предполагает:
Все уже обновились.
Это фактически versioning по календарю, только без контракта.
Для собственного Web + backend иногда версия в URL действительно не нужна
Если:
frontend всегда разворачивается вместе с backend;
старых клиентов нет;
внешних интеграций нет;можно долго поддерживать единственный эволюционирующий API.
Но как только появляется:
mobile;
external integrations;
public API;
third-party clients;предположение:
Все consumers обновятся сегодня.
перестаёт быть безопасным.
Мобильное приложение особенно хорошо показывает проблему
Web-клиент можно обновить:
сейчас.Android пользователь может обновить приложение:
через месяц.Кто-то вообще отключил automatic updates.
iOS-клиент старой версии продолжает ходить к новому backend.
Получаем:
Backend 8.0
Web 8.0
Android 7.9
Android 7.2
iOS 7.8Поэтому backend должен переживать version skew.
Это касается не только response
Старый клиент может отправить:
{
"priority": "high"
}Новый backend уже ожидает:
{
"priority": {
"level": "high"
}
}Если сервер сразу удалит поддержку старой формы, mobile перестанет работать.
Хороший migration path может выглядеть так
Сначала backend принимает обе формы:
old input
+
new input.Но внутри нормализует их в одну domain model.
Например:
API v1 adapter
↓
Normalized command
↑
API v2 adapterBusiness layer не должен содержать:
if v1
if v2
if v3по всему приложению.
Версии лучше локализовать на границе
Например:
HTTP
│
├── v1 adapter
│ ↓
├── v2 adapter
│ ↓
└── Application layer
↓
DomainV1 преобразует:
старый requestв современную внутреннюю команду.
V2 делает то же для нового контракта.
Внутри:
Project.create(...)ничего не знает о:
API v1.То же самое в обратную сторону
Domain возвращает внутреннюю модель.
Дальше:
v1 presenterформирует старый response.
v2 presenter— новый.
Это значительно лучше двух полностью независимых backend’ов:
api-v1/
api-v2/в которых бизнес-логика постепенно начинает расходиться.
Иначе баги приходится исправлять несколько раз
Есть:
/v1/payments
/v2/payments
/v3/paymentsКаждая версия имеет свой:
payment service.Нашли критический баг в refund.
Нужно исправить его:
в v1;
в v2;
в v3.Один забыли.
Production снова сломан.
Лучше делить контракт, а не domain logic
Например:
Payment domain
/ \
/ \
v1 adapter v2 adapterТогда security fix или invariant исправляется один раз.
Это один из важнейших принципов долгоживущего versioning.
Совместимость нужно проверять автоматически
Документация:
Не удаляйте поля из v1.
полезна.
Но автоматический test полезнее.
Например OpenAPI v1 хранится в репозитории.
После изменения CI сравнивает новый contract с предыдущим.
Если обнаружено:
response field removedили:
parameter optional → requiredpipeline сообщает:
BREAKING API CHANGE.OpenAPI здесь становится не только документацией
GitHub, например, публикует машиночитаемое OpenAPI-описание своего REST API и прямо отмечает, что такой контракт можно использовать для генерации клиентов, валидации и тестирования integration.
В собственном продукте API schema тоже можно превратить в release artifact.
Тогда изменение endpoint проходит несколько проверок
Например:
Code
↓
OpenAPI generated
↓
Contract diff
↓
breaking?Если:
нетобычный release.
Если:
данужно сознательно выбрать:
новая версияили:
migration strategy.Contract test должен проверять и реальное поведение
OpenAPI говорит:
status:
stringно реальный endpoint после бага возвращает:
{
"status": null
}Schema была правильная.
Runtime — нет.
Поэтому полезны:
schema tests
+
integration tests
+
consumer contract tests.Consumer contract особенно полезен для внутренних клиентов
Например mobile-команда зависит от:
GET /projects/:idи утверждает:
status присутствует;
project.id number;
owner может быть null.Backend CI проверяет этот контракт.
Теперь refactoring не может случайно нарушить mobile, не показав красный test.
Самый хороший breaking change — обнаруженный до production
А не:
release backend
↓
через 20 минут
Android reviews:
«приложение перестало открываться».Совместимость ошибок тоже важна
Допустим API раньше возвращал:
{
"error": "NOT_FOUND"
}Клиент делает:
if (error === 'NOT_FOUND') {
showDeleted();
}После refactoring backend начинает:
{
"code": "PROJECT_NOT_FOUND"
}HTTP status по-прежнему:
404.Для разработчика:
Всё правильно.
Для клиента:
Breaking change.
Error codes тоже являются API contract
Поэтому лучше иметь стабильные machine-readable codes:
PROJECT_NOT_FOUND
PAYMENT_ALREADY_COMPLETED
IDEMPOTENCY_CONFLICTи отдельно:
messageдля человека.
Не заставлять клиента сравнивать:
"Проект не найден"с текстовой строкой, которая завтра может измениться.
Локализация тоже не должна менять machine contract
Например:
{
"code": "PROJECT_NOT_FOUND",
"message": "Project not found"
}или:
{
"code": "PROJECT_NOT_FOUND",
"message": "Проект не найден"
}Клиент ориентируется на:
code.Не на перевод.
Pagination — ещё одно место скрытых breaking changes
Было:
page=1
limit=20Стало:
cursor=...Это не просто backend optimization.
Client navigation меняется.
Такой переход лучше делать постепенно.
Например некоторое время:
v1 → offset pagination
v2 → cursor pagination.Но можно сначала добавить новый механизм как optional
Например:
GET /projects?cursor=...при отсутствии cursor старый режим продолжает работать.
Это может позволить провести migration без полной новой версии.
Опять же вопрос:
можно ли сохранить старое поведение?
Authorization changes требуют особой осторожности
Допустим endpoint раньше был доступен:
manager.После security review решили:
только owner.С точки зрения безопасности изменение необходимо.
С точки зрения API это может ломать существующие integrations.
Совместимость не должна побеждать безопасность
Versioning — не обещание:
Мы никогда ничего не изменим.
Критическая:
уязвимость;
утечка данных;
небезопасное authorization ruleможет потребовать breaking change быстрее обычного lifecycle.
GitHub в своей текущей политике прямо оставляет исключения для критичных security, availability и reliability issues, когда изменения могут потребоваться вне стандартного versioning cadence.
Это разумный баланс.
API compatibility — это управляемое обещание, а не абсолют
Полезно заранее документировать:
что считается breaking;
сколько поддерживается версия;
какие исключения возможны;
как сообщается deprecation.Тогда consumers понимают правила игры.
Что такое deprecation
Представим:
GET /v1/customersчерез шесть месяцев будет заменён на:
GET /v2/customersОчень плохая стратегия:
1 апреля:
v1 работает.
2 апреля:
404.Партнёры узнают об изменении из production outage.
Deprecation — это не отключение
Это период:
Endpoint пока работает, но его больше не следует использовать для новых интеграций, а существующим клиентам пора мигрировать.
В марте 2025 года IETF стандартизировал специальный HTTP response header Deprecation в RFC 9745. Стандарт подчёркивает, что само объявление deprecation не меняет поведение ресурса: оно сообщает клиенту о lifecycle и позволяет заранее начать migration.
То есть старый endpoint продолжает работать
Но response может сообщить:
Deprecation: @1798761600и дать ссылку на migration documentation.
А если уже известно, когда endpoint перестанет отвечать, можно дополнительно использовать:
Sunset: Tue, 01 Jun 2027 00:00:00 GMTSunset стандартизирован отдельным RFC 8594 именно для объявления момента, после которого resource ожидается недоступным.
Deprecation и Sunset — разные даты
Концептуально:
2026-11-01
Deprecationозначает:
Не начинайте новые integration на v1. Планируйте migration.
А:
2027-06-01
Sunsetозначает:
После этой даты v1 будет отключён.
Между ними существует migration window.
Эта разница очень полезна
Без неё клиент видит только:
работаетили:
не работает.С lifecycle metadata появляется промежуточное состояние:
работает,
но устаревает.Одного HTTP header недостаточно
Партнёр может вообще не анализировать:
Deprecation.Поэтому серьёзный deprecation process использует несколько каналов:
changelog;
documentation;
developer dashboard;
email владельцу integration;
response header;
метрики использования.Но главным должен оставаться один понятный migration plan.
Нельзя deprecate без замены, если клиенту всё ещё нужна функция
Плохая документация:
/v1/reports будет удалён 1 июня.И всё.
Хорошая:
/v1/reportsустаревает. Используйте/v2/reports. В v2 поля X и Y заменены на Z. Вот mapping и пример migration.
Deprecation без пути перехода — это просто предупреждение об аварии в будущем.
Особенно полезна migration table
Например:
| v1 | v2 |
|---|---|
status | state |
amount в рублях | amountMinor + currency |
| offset pagination | cursor pagination |
error | code + message |
Клиенту не нужно самому сравнивать два OpenAPI-файла.
Но лучше ещё предоставить реальные примеры
Было:
{
"amount": 50000,
"status": "paid"
}Стало:
{
"amountMinor": 5000000,
"currency": "RUB",
"state": "paid"
}Это намного понятнее абстрактной фразы:
Модель платежей улучшена.
Нужно знать, кто всё ещё использует старую версию
Иначе команда объявляет:
Sunset через месяц.но не представляет:
20 клиентов или 20 000ещё сидят на v1.
Version telemetry должна быть частью API
Например сервер знает:
API version;
client ID;
app version;
endpoint.Можно увидеть:
v1:
3.2% requests
v2:
96.8%И отдельно:
v1 active integrations:
7.Теперь решение об отключении основано на данных.
Для мобильных приложений полезна версия клиента
Например:
platform = android
app_version = 5.4.2
api_version = 2026-03-10Когда старый contract всё ещё получает traffic, можно понять:
Это забытый партнёр или пользователи старого Android?
Migration strategy будет разной.
Но telemetry не должна превращаться в fingerprinting без необходимости
Нужно хранить только данные, действительно необходимые для эксплуатации и migration.
Например:
client identifier;
API version;
last seen;
request count.часто достаточно.
Когда deprecation можно закончить
Не:
Прошло ровно шесть месяцев.
А когда выполнены оба условия:
обещанное support window закончилось;и:
migration process завершён по принятой политике.Если крупных клиентов осталось несколько, команда может осознанно продлить срок.
Но срок не должен становиться вечным.
Вечная совместимость тоже вредна
Представим:
v1
v2
v3
v4
v5все поддерживаются навсегда.
Любая новая функция должна быть проверена против пяти контрактов.
Security fix — против пяти.
Документация — для пяти.
Monitoring — пяти.
В итоге страх удалить старый API сам становится причиной того, что API невозможно развивать.
Поэтому API lifecycle должен иметь конец
Например политика:
новая breaking version;
предыдущая поддерживается минимум N месяцев;
затем deprecation;
затем sunset.Конкретное N зависит от продукта.
GitHub сейчас, например, гарантирует предыдущей REST API version как минимум 24 месяца поддержки после выхода новой. Это не универсальное число, но хороший пример того, что support window лучше объявлять заранее, а не придумывать для каждого endpoint отдельно.
Внутреннему API иногда достаточно значительно меньшего окна
Если consumers принадлежат одной команде:
Web;
worker;
admin.можно мигрировать быстрее.
Публичному API клиента, встроенному в десятки внешних систем, может понадобиться гораздо более длинный lifecycle.
Значит versioning policy зависит от ownership
Чем меньше контроля над consumer, тем важнее:
стабильный контракт;
version pinning;
долгий migration window;
хорошая deprecation communication.Webhooks тоже являются API
Это часто забывают.
Ваш backend отправляет:
{
"event": "project.completed",
"projectId": 1842
}клиенту.
Через год решили:
{
"type": "project.finished",
"data": {
"id": 1842
}
}Это такой же breaking change, только направление запроса обратное.
Webhook event schema тоже нужно versioning
Например endpoint клиента регистрируется с:
event version = 2026-10-01.Или версия зафиксирована при создании subscription.
Stripe, например, связывает webhook event format с API version endpoint/account, что на практике позволяет не заставлять все существующие webhook consumers мгновенно принимать новый контракт.
Старые события особенно важны при redelivery
Представим webhook был создан:
год назади лежит в durable queue.
Сегодня вышел новый API contract.
При retry старого события нельзя внезапно сериализовать его по новой схеме, если consumer ожидает старую.
Иначе retry перестаёт быть повтором того же события.
Event payload полезно считать immutable
Создали:
PROJECT_COMPLETED v1Сохранили payload.
Retry отправляет тот же смысл.
Не:
возьмём текущий project
и соберём новый JSON.Потому что данные могли измениться.
Версия SDK и версия API — не всегда одно и то же
У клиента может быть:
SDK 8но API contract:
2026-03.Важно не смешивать понятия.
SDK — библиотека.
API version — договор между клиентом и сервером.
Иногда они движутся вместе, иногда нет.
SDK должен помогать migration
Например старая функция:
client.projects.list()помечается deprecated в самой библиотеке.
IDE показывает warning.
Новая:
client.projects.search()уже доступна.
Так consumer узнаёт о migration ещё на этапе разработки.
Documentation должна быть version-aware
Очень неприятный сценарий:
Client работает с:
v1.Открывает документацию.
А там показан только:
v3.Он не понимает, почему его response отличается от примера.
Хорошая документация явно показывает выбранную версию
Например:
API version:
2026-03-10И позволяет переключиться на:
2027-01-15.Примеры и schema соответствуют именно выбранному contract.
Changelog должен описывать влияние
Плохо:
Refactored Project API.
Хорошо:
В версии 2027-01-15 полеstatusудалено и заменено наstate. Клиенты версии 2026-03-10 продолжают получатьstatusдо завершения support window.
Первое описание интересно разработчику API.
Второе полезно потребителю.
Не все изменения должны ждать большой версии
Допустим нашли:
ошибку расчёта.API возвращал неправильную сумму.
Исправление изменит response.
Формально клиент увидит другое число.
Является ли это breaking change?
Нужно различать контракт и баг
Если документация говорит:
total = сумма всех позицийа сервер ошибочно пропускал последнюю позицию, клиент не имеет права считать баг гарантированным контрактом.
Исправление должно вернуть API к задокументированной семантике.
Иначе получается абсурд:
Любой bug после production становится вечным API feature.
Но practically опасные bugfix всё равно требуют коммуникации
Если много клиентов случайно построили логику вокруг неправильного поведения, резкое исправление может вызвать outage.
Тогда иногда разумны:
feature flag;
переходный период;
warning;
compatibility mode.Даже если формально это bugfix.
Хороший API design уменьшает число будущих breaking changes
Например вместо конкретной структуры:
{
"error": "Card declined"
}лучше:
{
"code": "PAYMENT_DECLINED",
"message": "Card declined"
}Вместо:
{
"hasAccess": true
}если право может стать сложнее, возможно полезна модель:
{
"permissions": [
"project.read",
"project.comment"
]
}Но опять же не нужно overengineering.
Смысл в том, чтобы моделировать реальный domain, а не сегодняшнюю случайную UI-кнопку.
API не должен копировать структуру базы
Плохой endpoint:
{
"user_id": 52,
"project_status_id": 7,
"deleted_at": null
}только потому, что такие колонки лежат в PostgreSQL.
Через год схема базы меняется.
И команда обнаруживает, что database migration стала public API breaking change.
Между database schema и API должен существовать слой модели
Например БД хранит:
project_status_id = 7.API отдаёт:
{
"status": "in_progress"
}Теперь внутреннее устройство таблиц можно менять независимо.
Это одна из главных инвестиций в долгоживущий API
Public contract должен выражать:
бизнес-смысл,а не:
текущую реализацию хранения.То же относится к микросервисам
Сегодня данные Projects приходят из:
одного PostgreSQL.Завтра:
Project Service.API consumer не должен замечать архитектурную миграцию, если бизнес-контракт не изменился.
Не следует версионировать каждый endpoint независимо без необходимости
Например:
/projects v2
/users v4
/payments v7
/files v3может быть оправдано в огромной платформе.
Но для обычного продукта быстро создаёт combinatorial complexity.
Client должен знать:
какую версию каждого ресурса сочетать с какой.Часто проще версионировать согласованный API contract целиком.
Но и новый /v2 для одного поля бывает слишком тяжёлым
Отсюда главный принцип:
versioning — последнее средство для несовместимого изменения, а не замена хорошей эволюции контракта.
Как может выглядеть безопасная эволюция поля
Нам нужно:
status
↓
state.Вместо мгновенного breaking change:
Этап A
Response:
{
"status": "active",
"state": "active"
}Новые clients переходят на:
state.Этап B
status объявляется deprecated.
Документация и runtime headers предупреждают consumers.
Этап C
Telemetry показывает:
старых consumers почти нет.Этап D
В новой breaking API version:
statusудаляется.
Старую версию при этом можно обслуживать adapter’ом
Внутренне domain давно использует:
state.Но v1 presenter всё ещё формирует:
{
"status": "active"
}Это controlled compatibility.
Feature rollout и API versioning — тоже разные вещи
Например API v2 поддерживает:
new billing model.Но вы хотите включить её сначала для 5% клиентов.
Это:
feature flag.Не новая версия API.
Версия отвечает:
Какой contract понимает клиент?
Feature flag:
Какое поведение включено конкретному клиенту?
Смешивание двух механизмов быстро создаёт хаос.
API version не должна определять бизнес-тариф
Плохо:
v2 → premium
v1 → free.Versioning и entitlement — разные оси.
Rate limits тоже нужно менять аккуратно
Представим клиенту год обещали:
100 requests/sec.Без предупреждения снизили:
10 requests/sec.Формат response не изменился.
Но интеграция перестала работать.
Это тоже изменение operational contract.
Долгоживущий API документирует не только JSON
Полезно фиксировать:
rate limits;
timeout expectations;
pagination;
idempotency;
retry semantics;
support window;
error model.Потому что consumers зависят и от них.
Особенно важно документировать retry
Например:
GET
→ retry allowed
POST /payments
→ retry only with Idempotency-KeyЕсли клиент вынужден сам угадывать, какие operations безопасно повторять, контракт неполный.
Версия API должна быть видна в логах
При ошибке:
POST /payments
500этого недостаточно.
Полезно иметь:
api_version=2026-03-10
client=partner-17
request_id=req_...Теперь support сразу понимает:
Проблема только у старой версии или у всех?
Метрики тоже полезно делить по версиям
Например:
error rate v1:
7%
error rate v2:
0.2%Возможно v1 compatibility adapter содержит дефект.
Или:
latency v1:
900 ms
v2:
120 ms.Это помогает планировать migration.
Deprecated endpoint должен оставаться тестируемым
Очень распространённая ошибка:
v1 всё равно скоро удаляем, тесты можно больше не поддерживать.
Через два месяца:
v1 accidentally brokenа 12% клиентов всё ещё на нём.
Пока version официально поддерживается, её contract tests должны оставаться частью CI.
Но тесты старых версий можно отделить
Например:
Current contract suiteи:
Compatibility suite.Так команда видит стоимость legacy явно.
Когда версия удаляется, удаляется и соответствующий compatibility layer.
Это хороший способ бороться с вечным legacy
У v1 есть конкретные:
routes;
adapter;
tests;
metrics;
sunset date.А не неизвестный набор:
if oldClientпо всему codebase.
Как выглядит здоровый lifecycle API
Не:
release
↓
breaking change
↓
клиенты чинятся.А:
design contract
↓
publish
↓
additive evolution
↓
breaking need appears
↓
new version
↓
migration documentation
↓
deprecation
↓
usage telemetry
↓
sunset
↓
remove compatibility codeЭто полноценный lifecycle продукта.
Практический checklist API, который должен прожить несколько лет
Перед публикацией публичного или mobile-facing API мы бы проверили:
- Контракт отделён от структуры базы и внутренней архитектуры.
- Понятно, что проект считает breaking change.
- Additive changes не требуют без причины новой версии.
- Клиенты умеют переживать неизвестные response fields и предусмотренные расширения.
- Enum и error codes имеют понятную evolution strategy.
- Семантика существующих полей не меняется тихо.
- Breaking contracts локализованы в adapters, а domain logic не копируется между версиями.
- Есть машиночитаемая schema, например OpenAPI, и contract diff в CI.
- Runtime tests проверяют, что сервер действительно соответствует schema.
- Webhooks и asynchronous events тоже имеют versioning policy.
- Версия клиента/API видна в telemetry и логах.
- Для deprecated API определены migration guide, support window и sunset.
- Старый endpoint остаётся полностью тестируемым до официального конца поддержки.
- Security fixes имеют право нарушить обычный compatibility lifecycle, если это необходимо для защиты пользователей.
- После sunset compatibility code действительно удаляется, а не остаётся навсегда.
Если эти правила определены заранее, изменение API через два года становится инженерной процедурой, а не чрезвычайной ситуацией.
Самый полезный тест API-дизайна
Представим, что сегодняшняя мобильная версия перестанет обновляться.
Она проживёт:
ещё 12 месяцев.Сможет ли backend продолжать развиваться?
Если любой новый feature требует:
Сначала заставим всех пользователей обновить приложение,
API слишком тесно связан с текущим клиентом.
Второй тест
Представим внешний клиент написал integration и больше не общается с вашей командой.
Через два года он делает тот же request.
Получит ли он:
предсказуемый contractили:
что сегодня решил вернуть backend?Третий тест
Можно ли удалить поддержку старой версии одним понятным изменением:
remove v1 adapter
remove v1 tests
remove v1 docsили legacy-разветвления разбросаны по сотням файлов?
Если второе — versioning внедрён слишком глубоко в domain.
Четвёртый тест
Может ли команда ответить:
Кто прямо сейчас использует deprecated API?
Если ответа нет, sunset превращается в азартную игру.
Как это влияет на стоимость разработки
На раннем этапе compatibility кажется дополнительной работой.
Нужно:
не переименовывать поле сразу;
поддержать adapter;
написать contract test;
вести changelog.Но через два года альтернатива намного дороже.
Без этих правил каждое изменение начинается с вопроса:
А не сломаем ли мы что-то неизвестное?
И никто точно не знает ответ.
В результате команда либо:
боится менять API,либо:
регулярно ломает consumers.Оба сценария плохие.
Хороший API позволяет backend развиваться
Можно:
сменить БД;разделить монолит;переписать Billing;изменить внутреннюю state machine;при этом старые consumers продолжают видеть обещанный contract.
Вот в этом настоящая ценность API abstraction.
Не в том, что данные передаются через HTTP.
А в том, что между двумя системами появляется стабильная граница изменений.
Вместо вывода
API, который живёт два месяца, можно менять почти как внутренний код.
API, который должен жить два года, становится отдельным продуктом.
У него появляются:
потребители;
версии;
история;
обязательства;
migration;
deprecation;
конец поддержки.Поэтому основная задача versioning — не поставить:
/v1перед каждым URL.
Главная задача — определить, какие обещания сервер даёт клиенту и как эти обещания будут изменяться.
Большинство ежедневных изменений лучше делать обратно совместимыми.
Добавлять новые optional capabilities.
Не менять смысл старых полей.
Не удалять существующее поведение без необходимости.
Разделять внутреннюю модель и внешний contract.
А когда изменение действительно несовместимо — выпускать новую версию сознательно.
После этого старый API не должен исчезать внезапно.
Он проходит понятный lifecycle:
SUPPORTED
↓
DEPRECATED
↓
MIGRATION WINDOW
↓
SUNSET
↓
REMOVEDСовременный HTTP уже даже предоставляет стандартные механизмы для объявления такого lifecycle: Deprecation стандартизирован RFC 9745, а Sunset позволяет сообщить момент будущего отключения ресурса.
Но сами headers ничего не спасут, если у продукта нет migration policy, telemetry и совместимой архитектуры.
Поэтому хороший API через два года отличается от плохого не количеством версий.
Он отличается тем, что команда может ответить на четыре вопроса:
Что мы можем изменить без breaking change?
Что требует новой версии?
Кто всё ещё использует старый контракт?
Как и когда мы безопасно его отключим?
Если ответы известны заранее, API перестаёт быть хрупкой связью между frontend и backend.
Он становится стабильным контрактом, который позволяет обеим сторонам развиваться независимо.