Интеграции и API

Как не сломать старых клиентов при изменении API

Есть 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

и понятия не имеет о:

state

Backend-команда не добавила новую функцию.

Она всего лишь «переименовала поле».

Но с точки зрения 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=20

Response:

{
  "items": [],
  "page": 3,
  "totalPages": 14
}

Backend переходит на cursor pagination:

GET /projects?cursor=abc

Response:

{
  "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/:id

Domain возвращает:

{
  "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
status

Response содержит:

{
  "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_name

Dual-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.5

Client 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
→ BLOCK
optional → required
→ BLOCK
integer → 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 fieldBreaking
Переименовано полеBreaking
Изменён типBreaking
Optional → requiredBreaking
null → отсутствующее полеПотенциально breaking
Добавлен enum valueТребует forward-compatible clients
Удалён enum valueBreaking
Ужесточена validationBreaking
Изменён format датыBreaking
Изменена единица измеренияBreaking
Изменён default sortПотенциально breaking
Offset → cursor paginationBreaking
Изменён HTTP statusПотенциально breaking
Изменён error codeBreaking
Изменена auth policyBreaking / 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

Например:

BackendWebAndroidiOSРезультат
NNNNPASS
NN-1N-1N-1PASS
N—minimum supportedminimum supportedPASS
N-1NNNпри необходимости 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-архитектуры:

новую систему можно развивать, не заставляя всех существующих клиентов обновляться в ту же минуту.

Есть похожая задача?

Опишите продукт, интеграции и ограничения. До разработки зафиксируем объём, риски и критерии приёмки.

Обсудить проект →