Есть endpoint:
GET /api/projects/1842Два года он возвращает:
{
"id": 1842,
"title": "CRM для отдела продаж",
"status": "in_progress"
}Команда решает немного улучшить naming.
Внутри новой domain model поле уже давно называется:
stateПоэтому разработчик делает:
{
"id": 1842,
"title": "CRM для отдела продаж",
"state": "in_progress"
}Frontend обновили одновременно.
Автотесты прошли.
Новый Web-клиент работает.
Релиз считается успешным.
Через два часа приходит сообщение:
У части пользователей мобильное приложение перестало открывать проекты.
Причина простая.
Android-клиент версии двухлетней давности всё ещё делает:
project.statusи понятия не имеет о:
stateBackend-команда не добавила новую функцию.
Она всего лишь «переименовала поле».
Но с точки зрения API-контракта произошло другое:
существующее поле было удалено.
Именно такие изменения чаще всего ломают старых клиентов.
Не потому, что разработчики плохо знают HTTP.
А потому, что API внутри команды всё ещё воспринимается как обычный внутренний код, который можно рефакторить вместе со всем проектом.
Пока API использует один Web-клиент, который deploy происходит одновременно с backend, это ещё может работать.
Но как только появляются:
мобильные приложения;
desktop-клиенты;
партнёрские интеграции;
вебхуки;
SDK;
старые frontend-сборки;
background workers;API становится долговоживущим контрактом.
И менять его нужно уже совсем иначе.
Главная проблема старого клиента: вы не контролируете момент его обновления
Web-приложение удобно.
Сегодня в 14:00 вышел backend.
В 14:01 можно развернуть совместимый frontend.
Старый JavaScript исчезнет после следующего обновления страницы.
С мобильным приложением всё иначе.
Backend сегодня:
v8.0А реальные пользователи могут продолжать работать на:
Android 8.0
Android 7.9
Android 7.4
Android 6.8
iOS 8.0
iOS 7.6
iOS 6.9Причины обычные:
автообновление отключено;
устройство давно не использовалось;
пользователь не хочет обновляться;
новая версия ОС не поддерживается;
релиз ещё проходит Store Review.То есть production на самом деле выглядит не так:
Backend v8
↓
Client v8а так:
┌── Web v8
├── Android v8
Backend v8 ───────┼── Android v7
├── Android v6
├── iOS v8
└── iOS v7И каждый из этих клиентов имеет собственные предположения о вашем API.
Это называется version skew.
Самый опасный API — тот, который никто не считает публичным
Команда может говорить:
Это не публичный API. Им пользуется только наше приложение.
Но если мобильное приложение уже установлено на:
10 000 устройств,API фактически опубликован.
Пусть документации нет.
Пусть нет developer portal.
Пусть endpoint называется:
/internal/api/projectsЕсли его использует код, который невозможно обновить одновременно с сервером, у него уже есть внешний consumer.
Следовательно, есть и compatibility contract.
Что вообще считается breaking change
Самые очевидные изменения понятны всем:
удалить endpoint;
переименовать endpoint;
удалить поле.Но production чаще ломают гораздо менее заметные изменения.
GitHub в своей актуальной политике REST API относит к breaking changes, например, удаление или переименование параметра/response field, добавление обязательного параметра, превращение optional-параметра в required, изменение типа поля, удаление enum values, ужесточение validation и изменение authentication/authorization requirements.
Рассмотрим их на конкретных примерах.
Breaking change №1. Переименование поля
Было:
{
"status": "active"
}Стало:
{
"state": "active"
}Для нового backend-кода это может быть красивее.
Для старого клиента:
status = missingРешение на этапе миграции:
{
"status": "active",
"state": "active"
}Новый клиент читает:
stateСтарый продолжает читать:
status.После миграции status можно deprecate.
Не удалять в тот же релиз.
Breaking change №2. Изменение типа
Было:
{
"price": 150000
}Стало:
{
"price": "150000"
}Кажется незначительным.
В JavaScript это иногда даже останется незаметным.
Но Kotlin/Swift/Java/C# decoder ожидает:
numberи получает:
string.Result:
deserialization error.Особенно опасны деньги
Допустим раньше:
{
"amount": 150000
}означало:
150 000 рублей.Команда решила перейти на minor units.
То же поле теперь означает:
1500 рублей,
если 150000 — копейки.Тип вообще не изменился.
Но семантика изменилась полностью.
Это ещё хуже.
Безопаснее добавить новое поле
Например:
{
"amount": 150000,
"amountMinor": 15000000,
"currency": "RUB"
}Новый клиент переходит на:
amountMinor.Старый ещё живёт на:
amount.Breaking change №3. Optional стал required
Вчера API принимал:
{
"email": "user@example.com"
}Сегодня backend ожидает:
{
"email": "user@example.com",
"consentVersion": "2026-10"
}и validation теперь говорит:
consentVersion required.Новый frontend поле отправляет.
Старое мобильное приложение — нет.
Все регистрации старой версии начинают получать:
400 Bad Request.Самый безопасный rollout
Сначала backend принимает:
старый request;
новый request.Если поля нет:
использует backward-compatible defaultили старую business policy.
Только после того, как старые clients действительно исчезнут, parameter можно сделать обязательным.
Breaking change №4. Новое правило validation
Раньше:
username:
3–50 символовСтало:
username:
5–30 символов,
только латиница.Endpoint тот же.
JSON тот же.
Тип:
string.Но часть payload, которая два года считалась валидной, теперь отклоняется.
Это тоже изменение контракта.
Особенно опасно менять validation при редактировании старых объектов
Допустим раньше пользователь мог создать:
username = "Михаил"Сегодня новые правила разрешают только ASCII.
Старый объект всё ещё существует.
Пользователь меняет:
avatarи отправляет весь profile обратно.
Backend повторно валидирует username по новым правилам.
Получаем:
400.Хотя пользователь вообще не менял username.
Поэтому validation migration должна учитывать исторические данные
Иногда нужны разные правила:
create;
update unchanged legacy value;
update changed value.Не всегда правильно применять сегодняшние ограничения ко всей истории продукта.
Breaking change №5. null исчез или появился
Было:
{
"avatarUrl": null
}Клиент знает:
nullable string.Команда решает:
Зачем отправлять null?
И начинает:
{}Старый клиент может интерпретировать отсутствие поля иначе.
И наоборот.
Было:
{
"owner": {
"id": 52
}
}Клиент считает owner обязательным.
Новый backend начинает:
{
"owner": null
}для удалённых пользователей.
Строго типизированный старый клиент падает.
Empty, null и missing — это три разных состояния
Особенно если API используется долго.
field missingможет означать:
Эта версия API вообще не знает поле.
nullможет означать:
Поле существует, значения нет.
""может означать:
Значение задано пустой строкой.
Нельзя менять эти состояния произвольно только ради красивого JSON.
Breaking change №6. Enum расширили
На первый взгляд добавить enum value безопасно.
Было:
NEW
ACTIVE
COMPLETEDСтало:
NEW
ACTIVE
PAUSED
COMPLETEDСервер ничего не удалил.
Но старый Swift-клиент может иметь:
switch status {
case .new:
case .active:
case .completed:
}и не иметь fallback.
Новый:
PAUSEDломает decoding или UI.
Интересно, что GitHub относит добавление enum values к additive changes своей REST API.
Но это работает только если consumers действительно разработаны как forward-compatible.
Клиент должен быть готов к неизвестному enum
Например:
NEW
ACTIVE
COMPLETED
UNKNOWNПри неизвестном значении:
UNKNOWNа не:
crash.Но для security-critical enum fallback нужно выбирать осторожно
Допустим:
permission:
OWNER
EDITOR
VIEWERпоявилось:
SUSPENDED.Нельзя неизвестное состояние автоматически интерпретировать как:
OWNERили даже:
VIEWERесли это нарушает security model.
Для permission-подобных систем безопаснее fail closed.
Breaking change №7. Изменился формат даты
Было:
{
"createdAt": "2026-10-06T15:30:00Z"
}Стало:
{
"createdAt": "06.10.2026 17:30"
}Человеку второй вариант может даже нравиться больше.
API-клиенту — нет.
Machine API должен использовать стабильный machine format
Например ISO 8601/RFC3339-подобный формат:
2026-10-06T15:30:00Zи не зависеть от:
языка;
таймзоны frontend;
регионального formatting.Ещё опаснее убрать timezone
Было:
2026-10-06T15:30:00ZСтало:
2026-10-06T15:30:00Теперь клиент вынужден угадывать:
UTC?
Europe/Berlin?
timezone пользователя?
timezone сервера?Семантика снова изменилась без изменения поля.
Breaking change №8. Изменение units
Было:
{
"timeout": 30
}и документация подразумевала:
seconds.Backend переписали.
Теперь:
milliseconds.То же поле.
То же число.
API формально отвечает успешно.
Но 30 теперь означает:
30 msвместо:
30 sec.Это самый неприятный класс breaking changes: response schema выглядит той же.
Лучше единица в самом контракте
Например:
timeoutSecondsили:
{
"timeout": 30,
"timeoutUnit": "seconds"
}Breaking change №9. Изменился смысл boolean
Было:
{
"active": true
}означало:
аккаунт разрешён.Через год появляется:
pending;
suspended;
blocked;
deleted.Boolean перестаёт выражать domain.
Команда пытается сохранить старое поле, но теперь:
active = falseможет означать четыре разных состояния.
Лучше добавить настоящее состояние
{
"active": false,
"status": "suspended"
}Старый клиент ещё понимает хотя бы:
не активен.Новый получает точную семантику.
Breaking change №10. Изменение pagination
Было:
GET /projects?page=3&limit=20Response:
{
"items": [],
"page": 3,
"totalPages": 14
}Backend переходит на cursor pagination:
GET /projects?cursor=abcResponse:
{
"items": [],
"nextCursor": "xyz"
}Это не просто оптимизация SQL.
Это новый клиентский navigation contract.
Не нужно ломать старую pagination ради новой
Можно некоторое время поддерживать:
offset modeи:
cursor mode.Например новый endpoint/version.
Или optional cursor.
Старый mobile продолжит ходить по страницам.
Breaking change №11. Изменение sorting по умолчанию
Раньше:
GET /messagesвозвращал:
oldest → newest.После оптимизации:
newest → oldest.Schema не изменилась вообще.
Но старый client делает:
append(response.items)и чат начинает отображляться неправильно.
Default behavior — тоже API contract
К нему относятся:
sorting;
pagination default;
timezone;
filter defaults;
case sensitivity;
duplicate handling.Breaking change №12. Новый HTTP-код
Endpoint раньше при duplicate operation возвращал:
200с:
{
"alreadyExists": true
}Новый backend делает более «правильно»:
409 Conflict.Новый frontend обновили.
Старый client считает:
любой non-2xx = network error.и показывает:
Повторите операцию.
Получаем retry loop.
HTTP status тоже входит в contract
Нельзя менять его только потому, что новая реализация кажется более RESTful.
Сначала нужно понять:
Как старые consumers интерпретируют этот статус?
Breaking change №13. Изменился error body
Было:
{
"error": "NOT_FOUND"
}Стало:
{
"code": "PROJECT_NOT_FOUND",
"message": "Project not found"
}Новый формат лучше.
Но старый клиент делает:
if (body.error === 'NOT_FOUND') {
...
}Теперь branch никогда не запускается.
Безопасная миграция
На переходе:
{
"error": "NOT_FOUND",
"code": "PROJECT_NOT_FOUND",
"message": "Project not found"
}Позже:
errorможно deprecate.
Breaking change №14. Изменилась authentication policy
Вчера endpoint:
GET /projects/:idпринимал обычный access token.
Сегодня после security review требует:
новый scopeили:
MFA-enabled session.Это может быть правильным security-решением.
Но старый клиент начнёт получать:
401/403.GitHub тоже прямо относит изменения authentication/authorization requirements к breaking changes.
Безопасность важнее совместимости
Это исключение нужно проговорить отдельно.
Иногда нельзя сказать:
Старый клиент поддержим ещё год.
Если контракт позволяет:
утечку;
эскалацию прав;
обход authorization,breaking security fix может быть обязательным немедленно.
Но даже тогда полезно:
зафиксировать изменение;
вернуть понятный error;
дать upgrade path;
не маскировать проблему как случайный 500.Breaking change №15. Убрали поле, которое «никто не использует»
Фраза:
Я поискал по frontend repository — это поле нигде не используется.
не доказывает ничего.
Его может использовать:
старое мобильное приложение;
партнёр;
скрипт клиента;
аналитическая система;
старый worker;
SDK.После появления внешних consumers code search перестаёт быть доказательством отсутствия использования.
Поэтому перед удалением нужна telemetry
Например поле:
legacyStatusнельзя напрямую отследить в response.
Но endpoint/version/client usage — можно.
Например:
api_version=2024
client_version=6.4
endpoint=/projects/:idЕсли endpoint старой версии ещё получает:
18% traffic,удаление явно преждевременно.
API должен знать, кто его вызывает
Не обязательно собирать огромный fingerprint.
Достаточно:
client type;
client version;
API version;
endpoint;Например:
client=android
version=6.8.2или:
client=partner-crm
version=2025-04Это позволяет ответить на главный вопрос
Перед breaking change:
Остались ли consumers, которые его не переживут?
Без telemetry ответ часто:
Наверное, нет.
Это плохая основа для production migration.
Главное правило совместимости: сначала ADD, потом MIGRATE, потом REMOVE
Не:
OLD
↓
NEWза один release.
А:
OLD
↓
OLD + NEW
↓
clients migrate
↓
NEWЭто базовый expansion/contraction pattern.
Пример переименования поля
Release 1
{
"status": "active"
}Release 2
{
"status": "active",
"state": "active"
}Новый клиент начинает читать:
state.Release 3
Telemetry показывает:
старые clients почти исчезли.status объявляется deprecated.
Release 4 / новая API version
{
"state": "active"
}Только теперь старое поле удаляется.
То же для request
Было:
{
"name": "CRM"
}Нужно перейти на:
{
"project": {
"name": "CRM"
}
}Backend некоторое время принимает обе формы:
old request
→ normalize
new request
→ normalizeВнутри application layer получается одна команда:
CreateProject(name)Это очень важный архитектурный принцип
Compatibility должна жить на границе системы.
Не в domain.
Плохо:
if (apiVersion === 1) {
business logic A
} else if (apiVersion === 2) {
business logic B
}во всех сервисах.
Лучше:
v1 request
↓
v1 adapter
↓
normalized command
↓
DOMAIN
v2 request
↓
v2 adapter
↓
normalized commandТогда старый API становится adapter, а не отдельным приложением
Например:
GET /v1/projects/:idDomain возвращает:
{
"id": 1842,
"state": "in_progress",
"priceMinor": 15000000
}V1 presenter преобразует:
{
"id": 1842,
"status": "in_progress",
"price": 150000
}V2:
{
"id": 1842,
"state": "in_progress",
"priceMinor": 15000000,
"currency": "RUB"
}Business logic одна.
Contracts разные.
Это намного дешевле двух API-кодовых баз
Плохо:
api-v1/
ProjectService
api-v2/
ProjectServiceЧерез два года:
security bugнужно исправлять в двух местах.
Потом в трёх.
Потом в четырёх.
Версия должна менять contract, а не копировать domain
Именно поэтому API adapters/presenters настолько полезны.
Нужно ли вообще сразу делать /v1
Нет.
Если API используется:
только web frontend;
frontend и backend всегда deploy вместе;
никаких внешних consumers;можно долго эволюционировать один контракт.
Но после появления мобильного или внешнего consumer следует считать published behavior стабильным.
Версионирование — не единственный инструмент
Очень многие изменения можно провести без:
/v2.Например:
добавить optional response field;
добавить optional request parameter;
добавить новый endpoint.GitHub в текущей модели тоже считает такие изменения additive, не требующими новой breaking API version.
Но additive change тоже может сломать плохого клиента
Например добавили:
{
"newField": true
}Старый decoder настроен:
fail on unknown properties.И integration падает.
Поэтому compatibility — ответственность обеих сторон
Server должен:
не ломать старый contract.Client должен:
не предполагать, что JSON никогда не расширится.Хороший клиент игнорирует неизвестные response fields
Например старый client знает:
id
title
statusResponse содержит:
{
"id": 1,
"title": "CRM",
"status": "active",
"newAnalyticsField": 42
}Client продолжает работу.
Но request нужно валидировать строже
Здесь важна асимметрия.
При чтении:
будь tolerant к новым полям.При записи:
будь строг к неожиданным/опасным данным.Особенно чтобы не получить mass assignment vulnerabilities.
Не нужно делать backend бесконечно терпимым ко всему
Backward compatibility не означает:
Принимать любой старый мусор навечно.
Она означает:
Есть известное окно миграции и управляемый lifecycle.
Поэтому нужен deprecation
Например:
/v1/projectsпока работает.
Но официально:
deprecated.Это означает:
Новые integrations сюда строить не нужно. Существующие должны перейти.
Deprecation должна иметь конкретную дату
Плохо:
v1 deprecated.
Когда её отключат?
через месяц?
через десять лет?
никогда?Лучше:
Deprecation:
2027-01-01
Sunset:
2027-07-01Тогда клиент понимает lifecycle
сейчас
│
├── работает
│
├── deprecated
│
├── migration window
│
└── отключениеGitHub показывает полезный production-пример
Его REST API сейчас использует date-based versions; breaking changes выпускаются новой версией, а предыдущая версия после выпуска новой поддерживается как минимум ещё 24 месяца. В ответах стареющих версий используются lifecycle-сигналы вроде Deprecation и Sunset, после завершения поддержки запрос к закрытой версии получает 410 Gone.
Для собственного продукта срок может быть намного меньше.
Но сам принцип полезен:
клиент должен узнать об отключении раньше, чем endpoint перестанет работать.
Как сообщать deprecation
Одного сообщения в корпоративном чате недостаточно.
Особенно если API внешнее.
Полезны:
documentation;
changelog;
email владельцам integrations;
developer dashboard;
response headers;
SDK warnings.И главное:
migration guide.Плохое объявление
/v1/payments будет отключён.Хорошее
/v1/payments отключается 1 июля.Используйте /v2/payment-intents.amountзаменено наamountMinor + currency.
status=paidзаменено наstate=succeeded.
Пример старого и нового request ниже.
До 1 июля обе версии работают параллельно.
Клиент понимает, что делать.
Dual-read
Один из самых полезных migration patterns.
Новая система должна читать:
new fieldно если его ещё нет:
fallback old field.Например storage migration:
display_nameзаменяет:
first_name + last_name.На transition:
read display_name
if null:
derive from first_name + last_nameDual-write
На период миграции изменение записывается:
и в старую;
и в новуюмодель.
Это полезно при смене schema/storage, но требует осторожности.
Почему dual-write опасен
Если:
new write success
old write fail,системы расходятся.
Поэтому, если обе записи находятся в одной PostgreSQL transaction, хорошо.
Если в разных сервисах — появляются distributed consistency issues.
Для API migration часто лучше adapter, чем dual-write
Если меняется только внешний contract, domain/storage вообще не нужно дублировать.
Например:
statusи:
stateмогут формироваться из одного domain field.
Feature flags помогают отделить deploy от rollout
Например backend уже умеет новый response.
Но включаем его:
internal clients
↓
beta clients
↓
10%
↓
100%Если ошибка обнаружена на 10%, feature выключается без rollback всей версии.
Но API version и feature flag — не одно и то же
Версия:
Какой contract клиент понимает?
Feature flag:
Какую возможность мы сейчас включили этому клиенту?
Не стоит делать:
v2 = new feature enabled
v1 = disabledесли версия существует только ради rollout.
Очень опасный migration pattern: deploy backend первым, который уже требует новый client
Например backend начинает требовать:
{
"newField": "..."
}а mobile update ещё:
на проверке App Store.Теперь production сломан несколько дней.
Compatibility window должна начинаться ДО выпуска клиента
Правильнее:
Backend release A
Поддерживает:
old + new.Mobile release B
Начинает использовать:
new.Наблюдение
Смотрим adoption.
Backend release C
Удаляет old только после завершения support window.
Это называется server-first compatible rollout
Backend сначала должен быть совместим одновременно со старым и новым клиентом.
Не наоборот.
То же самое при удалении функции
Нельзя сначала удалить endpoint, а потом отправлять mobile update в Store.
Последовательность:
новый endpoint готов
↓
новый client опубликован
↓
users migrate
↓
старый endpoint deprecated
↓
traffic old endpoint → ~0
↓
удалениеКак понять, что старый клиент уже можно отключать
Не по дате в Jira.
По данным.
Например:
API v1 requests:
0.03%
active v1 users:
17
last v1 request:
12 days agoТеперь решение об отключении осознанное.
Но 0% traffic за один час ничего не доказывает
Некоторые integrations запускаются:
раз в сутки;
раз в неделю;
раз в месяц.Поэтому telemetry window должна соответствовать реальному cadence клиентов.
Например бухгалтерская интеграция
Она вызывает:
/export-monthтолько первого числа месяца.
Если проверить usage:
15 октября,можно ошибочно решить:
Никто endpoint не использует.
А 1 ноября придёт production incident.
Нужно знать характер consumers
Например:
mobile
→ daily active
backup integration
→ nightly
monthly billing export
→ monthly
annual compliance job
→ yearly.Иногда отключение API требует разговора с владельцем integration, а не только графика последних 24 часов.
Client version должна попадать в observability
Например headers:
X-Client: android
X-Client-Version: 7.4.1
X-Api-Version: 2026-03или эквивалентные служебные metadata.
В логах:
request_id=req_123
client=android
client_version=7.4.1
api_version=2026-03
endpoint=/projects/1842
status=200Теперь инцидент становится диагностируемым
Вместо:
У некоторых пользователей не работает.
Можно увидеть:
errors only on Android < 7.5.Причина ищется намного быстрее.
API contract tests должны быть в CI
Человек не способен каждый раз помнить:
это поле нельзя удалить;
это optional;
этот enum используется старым Android.Машина способна.
Если используется OpenAPI
CI может сравнивать:
previous schemaи:
new schema.И блокировать:
removed response property;
required parameter added;
type changed.Но schema diff не ловит всё
Например:
sort order changed.OpenAPI останется тем же.
Или:
price теперь в копейках.Тип всё ещё:
integer.Поэтому нужны ещё semantic contract tests.
Пример contract test
Старый клиент ожидает:
{
"status": "in_progress"
}Тест не просто проверяет:
status is string.Он проверяет:
поле существует;
nullable policy;
known values;
default behavior.Consumer-driven contracts
Особенно полезны, если backend обслуживает несколько собственных приложений.
Android-команда описывает:
Для экрана проекта нам необходимы:
id;
title;
status;
owner nullable;Backend CI запускает этот contract.
Изменение:
status → stateсразу ломает build.
Не production.
Старые clients тоже должны оставаться в compatibility test matrix
Например:
Current:
Web v8
Android v8
iOS v8
Compatibility:
Android v7
iOS v7
partner API v1Пока версия официально поддерживается — тесты остаются.
Очень сильный тест — replay старых requests
Берём реальные anonymized request fixtures старых версий:
{
"email": "a@example.com"
}без новых полей.
И прогоняем через новый backend.
Ожидаем:
success.То же с responses
Можно хранить compatibility fixtures:
v1 response schema;
v2 response schema.И убеждаться, что новый domain всё ещё способен сформировать обе версии.
Контракт нужно тестировать не только happy path
Например старый client зависит от error:
PROJECT_NOT_FOUND.Если новый backend превращает его в:
RESOURCE_NOT_FOUND,compatibility test должен упасть.
Error contract особенно часто забывают
Команды тщательно сохраняют successful JSON.
Но могут без предупреждения менять:
status codes;
error codes;
error shape;
validation errors.А мобильное приложение использует именно их для UX.
Например
Старый client:
409 → показать «проект уже создан».После refactoring backend возвращает:
422.Пользователь теперь получает:
Что-то пошло не так.
Функция формально существует.
UX сломан.
Не возвращайте human message как единственный contract
Плохо:
{
"error": "Проект уже существует"
}Client сравнивает строку.
После локализации:
{
"error": "Project already exists"
}logic ломается.
Лучше
{
"code": "PROJECT_ALREADY_EXISTS",
"message": "Проект уже существует"
}code стабилен.
message можно:
переводить;
улучшать;
менять формулировку.Что делать с новым обязательным поведением
Например новая legal policy действительно требует:
consentVersion.Старые clients его не умеют передавать.
Есть несколько вариантов.
Вариант 1. Backend определяет legacy default
Если юридически допустимо.
Например:
missing consentVersion
→ legacy flow.Вариант 2. Старому client возвращается специальный upgrade-required
Например:
426 Upgrade Requiredили собственный стабильный application error, если именно такой контракт выбран системой.
Но это должно быть сознательным product decision.
Пользователь должен понимать проблему
Не:
Network error.
А:
Эта версия приложения больше не поддерживается. Обновите приложение.
Forced upgrade — крайний механизм
Он уместен, если:
security issue;
critical protocol change;
legal requirement;
backend больше физически не может поддерживать старый client.Не нужно заставлять пользователя обновляться после каждого backend refactoring.
Minimum supported client version
Для мобильного API можно хранить:
min_android_version;
min_ios_version.Но использовать механизм аккуратно.
Хорошая модель
recommendedVersion = 8.2
minimumVersion = 6.5Client 7.0:
работает;
получает рекомендацию обновиться.Client 6.0:
получает controlled upgrade-required.Не блокируйте client раньше, чем новый release реально доступен
Классический incident:
backend minimum version = 8.0Но iOS 8.0:
ещё находится на Store Review.Все пользователи iOS заблокированы.
Rollout minimum-version должен учитывать публикацию Store.
И rollback
Представим новая версия mobile обнаружила критический crash.
Её откатывают или приостанавливают.
Если backend уже перестал поддерживать старую версию:
rollback client невозможен.Поэтому compatibility window даёт ещё и rollback safety.
Backward compatibility — это страховка deployment
Она нужна не только старым пользователям.
Она позволяет:
выкатывать постепенно;
откатывать;
canary release;
A/B;
store review delay;без жёсткой синхронизации всех компонентов.
Database migration тоже должна учитывать старый API
Например поле:
statusв PostgreSQL заменяется:
state.Плохой migration:
ALTER TABLE projects
RENAME COLUMN status TO state;если old API code всё ещё читает:
status.Expand/contract работает и на БД
Expand
добавить state.Backfill
копировать status → state.Application migration
Новый код читает:
state.Old compatibility adapter всё ещё способен вернуть:
status.Contract
После migration window старая колонка удаляется.
Не пытайтесь сделать всё одним deploy
Особенно если есть:
несколько API instances;
workers;
old deployment during rolling update.В момент rolling deployment одновременно могут работать:
old applicationи:
new application.Database schema должна быть совместима с обоими.
Это называется N/N-1 compatibility
Во время rollout:
App N
+
App N-1должны некоторое время работать с одной БД.
Если migration мгновенно удаляет колонку, которую N-1 использует:
часть requests падает.Поэтому DB migration sequence часто такая
Не:
DROP old_column
↓
deploy new app.А:
ADD new_column
↓
deploy compatible code
↓
backfill
↓
switch reads
↓
observe
↓
drop old later.То же относится к events и queues
Не только HTTP API имеет contracts.
Background worker может получить job:
{
"type": "SEND_EMAIL",
"userId": 52
}Новый producer начинает:
{
"type": "SEND_EMAIL",
"recipient": {
"id": 52
}
}Но в очереди ещё лежат старые jobs.
Новый worker после deploy должен уметь обработать старый payload.
Поэтому durable messages тоже versioned contracts
Можно хранить:
{
"type": "SEND_EMAIL",
"version": 2,
"payload": {}
}и временно поддерживать:
v1 decoder;
v2 decoder.Webhook ещё опаснее
Ваш API может быть под полным контролем команды.
Webhook отправляется чужому серверу.
Вы вообще не знаете, как быстро consumer обновится.
Например было
{
"event": "project.completed",
"projectId": 1842
}Стало:
{
"type": "project.finished",
"data": {
"project": {
"id": 1842
}
}
}Красивый новый формат.
Но все существующие webhook consumers сломаны.
Webhook schema требует такого же lifecycle
add new event version;
allow consumer choose version;
migration;
deprecation;
sunset.Не:
с понедельника JSON другой.Особенно важно для redelivery
Событие было создано вчера по schema v1.
Сегодня вышел v2.
Webhook retry сегодня должен оставаться:
событием v1,если consumer подписан на v1.
Не пересобирать старое событие новым serializer только потому, что deploy обновился.
Immutable event payload сильно упрощает совместимость
Создали:
event v1Сохранили.
Все retries отправляют тот же payload.
Как не плодить версии бесконечно
Есть другая крайность.
Любое добавление:
/v2.Через год:
/v17.Это тоже плохо.
Новая версия нужна именно для несовместимой семантики
Не требуется новая версия, если:
добавлен optional field;
добавлен новый endpoint;
оптимизирован SQL;
сменился PostgreSQL;
backend стал микросервисным.Consumer не должен знать об internal refactoring.
Хороший тест
Спросить:
Может ли корректно написанный старый client продолжить работу без изменения кода?
Если да — изменение вероятно additive.
Если нет — breaking либо требует migration layer.
Но слово «корректно написанный» важно
Добавление неизвестного JSON field обычно считается backward-compatible.
Если клиент падает от любого нового поля, проблема частично находится на стороне client design.
Поэтому SDK и официальные клиенты тоже нужно проектировать forward-compatible.
Какие response changes обычно безопаснее
добавление optional field;
добавление дополнительного metadata;
новый endpoint;
новый optional request parameter.Но даже они должны тестироваться.
Какие изменения почти всегда требуют особой осторожности
удаление;
переименование;
type change;
nullable change;
required change;
semantic change;
sorting change;
validation tightening;
auth changes;
error-code changes.Хорошо иметь внутренний API change checklist
Например pull request меняет API.
Автор должен ответить:
Удаляется поле?
Меняется тип?
Nullable?
Required?
Enum?
Validation?
Status code?
Error code?
Default sorting?
Pagination?
Auth?Если хотя бы:
да,review должен рассмотреть compatibility.
Ещё лучше — автоматизировать часть проверки
OpenAPI diff:
removed property
→ BLOCKoptional → required
→ BLOCKinteger → string
→ BLOCKНо final decision остаётся архитектурным
Автоматический diff не знает, что:
amountраньше был в рублях, а теперь в копейках.
Это должен знать человек или semantic test.
Документация API должна быть контрактом, а не описанием текущего кода
Если docs генерируются только из текущего backend:
старый contract исчезает из документации.Consumer v1 открывает docs и видит v2.
Непонятно:
Почему мой response другой?
Version-aware документация
Например переключатель:
API 2026-03
API 2027-01показывает соответствующие:
request;
response;
error codes;
deprecations.Changelog должен быть ориентирован на consumer
Плохо:
Refactored project serializers.
Хорошо:
Добавлено поле state.status сохранено для совместимости и будет удалено в следующей breaking API version.Новым integrations следует использовать state.Не скрывайте breaking change под словом refactoring
Для backend-команды это может быть refactoring.
Для client:
production outage.Название PR не меняет nature изменения.
Как может выглядеть реальный безопасный rollout
Допустим хотим заменить:
statusна:
state.Неделя 1
Backend возвращает оба:
{
"status": "active",
"state": "active"
}Логи начинают собирать:
client version.Неделя 2
Web переходит на state.
Неделя 3
Новая Android/iOS версия переходит на state.
Следующие недели
Измеряем adoption.
После support window
Старая API version помечается deprecated.
Следующая breaking version
status удаляется.
Никакой emergency migration
Никаких:
Почему Android 6.4 внезапно падает?
Потому что изменение было спроектировано как lifecycle, а не как rename.
Что делать, если клиенты неизвестны
Иногда API уже много лет существует, а telemetry нет.
Нужно удалить старый endpoint.
Но никто не знает, кто его использует.
Самое опасное:
просто удалить.Можно сначала добавить warning telemetry
Например запрос старого endpoint:
логируется отдельно.Дополнительно response может включать deprecation metadata.
Наблюдаем:
30–90 днейв зависимости от cadence.
Если traffic есть
Ищем владельцев:
API key;
client ID;
account;
organization.Связываемся.
Если traffic нет
Это уже намного более сильный аргумент к удалению.
Но всё равно нужно учитывать редкие scheduled integrations.
Shadow validation
Очень полезный pattern при ужесточении rules.
Допустим хотим сделать:
phone required.Сразу отклонять старые requests опасно.
Можно сначала:
принимать request;но отдельно считать:
сколько нынешних clients
не прошло бы новое правило.Например:
would_fail_new_validation = 17%.Мы заранее узнаём масштаб breaking change.
То же с новым parser
Хотим перейти с:
legacy payload parserна:
strict parser.Можно временно запускать новый parser в shadow mode:
не влияет на response;
только записывает отличие.После этого rollout становится data-driven
Не:
Думаю, всё будет нормально.
А:
За неделю новый parser не принял бы 0.04% запросов, и все они идут от client v5.2.
Теперь понятно, что мигрировать.
Compatibility layer тоже имеет стоимость
Важно не превратить статью в призыв:
Никогда ничего не удаляйте.
Это другая крайность.
Каждое legacy поле увеличивает:
код;
тесты;
документацию;
cognitive load.Поэтому compatibility должна иметь:
owner;
deadline;
telemetry;
removal plan.Полезно заводить технический долг явно
Например:
Legacy field:
status
Replacement:
state
Introduced:
2026-10
Deprecated:
2027-01
Sunset:
2027-07Теперь старое поле не превращается в вечный артефакт.
Почему вечная совместимость опасна
Через пять лет response выглядит:
{
"status": "active",
"state": "active",
"legacyStatus": 1,
"stateCode": "A",
"newState": "ACTIVE"
}Никто уже не знает, какое поле настоящее.
Это тоже плохой API design.
Нужен controlled removal
Совместимость — переходный режим.
Не конечная архитектура.
Security exception
Иногда ждать migration window нельзя.
Например обнаружено:
endpoint возвращает чужие данные
при неверной ownership-проверке.Исправление может изменить behavior старого client.
Это нормально.
Security invariant важнее backward compatibility.
Но даже security fix можно сделать качественно
Например раньше:
200 + чужой объект.После:
403 FORBIDDEN
code=PROJECT_ACCESS_DENIED.Клиент может показать controlled error вместо random crash.
Не поддерживайте неправильный security behavior «ради совместимости»
Это важная граница.
Практический compatibility checklist перед изменением API
Перед merge изменения полезно проверить:
| Изменение | Риск |
|---|---|
| Удалено response field | Breaking |
| Переименовано поле | Breaking |
| Изменён тип | Breaking |
| Optional → required | Breaking |
null → отсутствующее поле | Потенциально breaking |
| Добавлен enum value | Требует forward-compatible clients |
| Удалён enum value | Breaking |
| Ужесточена validation | Breaking |
| Изменён format даты | Breaking |
| Изменена единица измерения | Breaking |
| Изменён default sort | Потенциально breaking |
| Offset → cursor pagination | Breaking |
| Изменён HTTP status | Потенциально breaking |
| Изменён error code | Breaking |
| Изменена auth policy | Breaking / security-sensitive |
| Добавлено optional поле | Обычно additive |
| Добавлен optional parameter | Обычно additive |
| Добавлен новый endpoint | Обычно additive |
И deployment checklist
До production:
Новая server version принимает старые requests?Старый client понимает новые responses?Есть ли rollout order?Мобильная версия уже доступна в Store?Можно ли rollback client/backend?Есть ли telemetry old clients?Есть ли API contract tests?Назначена ли дата удаления legacy?Тест, который стоит делать специально
Поднять:
новый backendи подключить:
старый Android client.Не только новый frontend.
Потому что обычный E2E часто проверяет неправильную пару
new frontend
+
new backend.Конечно они совместимы — их разработали одновременно.
А production-вопрос:
old frontend/mobile
+
new backend.Хорошая compatibility matrix
Например:
| Backend | Web | Android | iOS | Результат |
|---|---|---|---|---|
| N | N | N | N | PASS |
| N | N-1 | N-1 | N-1 | PASS |
| N | — | minimum supported | minimum supported | PASS |
| N-1 | N | N | N | при необходимости rollback |
Не каждому проекту нужны все комбинации.
Но принцип очень полезен.
API нужно проектировать так, чтобы rollback был возможен
Представим:
backend Nначал записывать данные в совершенно новом формате.
Потом его откатывают на:
N-1.Старая версия больше не умеет читать новые записи.
Формально application rollback есть.
Практически:
rollback impossible.Backward compatibility нужна и в data model
При rolling/rollback migration новый код некоторое время должен писать данные, которые старый код хотя бы способен безопасно игнорировать или прочитать.
Именно поэтому destructive migration лучше откладывать
DROP COLUMNне должен находиться в том же релизе, где новый code впервые перестал её использовать.
API, database и jobs имеют один и тот же принцип
EXPAND
↓
MIGRATE
↓
OBSERVE
↓
CONTRACTЭто универсальная схема безопасной эволюции production systems.
Что получает бизнес от всей этой сложности
На первый взгляд:
Мы держим старое поле лишние три месяца.
Кажется техническим излишеством.
Но альтернатива:
часть мобильных пользователей
перестаёт работать;партнёрская CRM перестаёт получать заявки;webhook consumer начинает терять события.То есть compatibility — это не эстетика API.
Это доступность продукта.
Чем больше продукт, тем ценнее независимые релизы
Хорошо спроектированный API позволяет:
backend releaseне ждать:
Android review;
iOS review;
partner deploy.И наоборот.
Каждый компонент двигается своим темпом.
В этом одна из главных ценностей API как архитектурной границы
Не просто:
frontend вызывает backend по HTTP.
А:
frontend и backend могут развиваться независимо в пределах стабильного контракта.
Самый полезный вопрос перед API-изменением
Не:
Наш новый frontend работает?
А:
Что произойдёт со старым клиентом, который ничего не знает об этом релизе?
Второй вопрос
Можно ли сначала добавить новый контракт, не удаляя старый?
Очень часто ответ:
да.И это уже решает большую часть проблемы.
Третий
Как мы узнаем, что старым контрактом больше никто не пользуется?
Если ответ:
Никак.
Удалять его рано.
Четвёртый
Как мы удалим compatibility layer через полгода?
Если ответа нет, temporary solution почти наверняка станет permanent legacy.
Вместо вывода
Сломать старого API-клиента удивительно легко.
Для этого вовсе не обязательно удалить весь endpoint.
Достаточно:
status → state;number → string;optional → required;null → missing;добавить неизвестный enum;изменить sort order;200 → 409;ужесточить validation.Для backend-команды это могут быть маленькие изменения.
Для клиента двухлетней давности — совершенно новый протокол.
Поэтому безопасная эволюция API строится не вокруг надежды:
Наверное, все уже обновились.
А вокруг управляемого процесса:
Новое поведение
↓
Additive change
↓
OLD + NEW одновременно
↓
Новые clients мигрируют
↓
Telemetry
↓
Deprecation
↓
Support window
↓
Старый contract удаляетсяИменно здесь важен принцип:
сначала расширить систему, потом перевести consumers и только после этого сжимать старый контракт.
На уровне API это означает:
сначала добавить поле;
потом переключить clients;
потом удалить старое.На уровне базы:
сначала добавить колонку;
потом мигрировать код и данные;
потом удалить старую.На уровне events:
сначала поддержать новую schema;
потом перевести producers/consumers;
потом убрать старый decoder.Такой подход кажется медленнее одного быстрого rename.
Но именно он позволяет backend, Web, Android, iOS, workers и внешним integrations обновляться независимо.
А это уже не просто backward compatibility.
Это одно из основных свойств зрелой production-архитектуры:
новую систему можно развивать, не заставляя всех существующих клиентов обновляться в ту же минуту.