Контракты и версионирование API
Каждое поле, которое вы отдаёте наружу, — обещание. Урок о том, как менять API, не превращая релиз соседней команды в аварию.
Контракт — зафиксированное обещание сервиса: какие запросы он принимает, что возвращает, какие поля обязательны и какие ошибки возможны. Всё, на что потребитель имеет право опираться, и ничего сверх того.
История, которая случается в каждой компании. Разработчик сервиса профилей переименовывает поле name в full_name — так логичнее, поле и правда хранит имя и фамилию. Проверяет свои тесты, они зелёные. Выкатывает. Через сорок минут падает мобильное приложение, ломается сервис уведомлений (в письмах теперь «Здравствуйте, undefined»), и разваливается ночной отчёт, о существовании которого разработчик не знал вовсе. Ни одной строчки его кода не было сломано. Сломались чужие ожидания.
Почему «просто договоримся» не работает
Устная договорённость масштабируется до трёх человек в одной комнате. Дальше начинается реальность.
- Вы не знаете всех своих потребителей. Про два сервиса вы помните, про третий забыли, четвёртый написала команда из другого офиса полгода назад, пятый — это скрипт аналитика, который тихо ходит в ваш API каждую ночь.
- Люди уходят, знание испаряется. «Мы же договорились в чате в марте» не является технической гарантией.
- Потребители опираются на то, чего вы им не обещали. Это закон Хайрама: при достаточном числе пользователей API любое наблюдаемое поведение системы кто-нибудь считает частью контракта — включая порядок элементов в массиве и точную формулировку текста ошибки.
- Тесты сервиса не ловят такие поломки. Ваши тесты проверяют, что сервис делает то, что вы задумали. Они ничего не знают о том, что от вас ждут другие.
Вывод: контракт должен быть артефактом в репозитории и проверяться машиной в CI, а не жить в головах.
Что ломает потребителя, а что нет
Основа совместимости — простое ассиметричное правило: расширять можно, сужать нельзя. Добавили — старый клиент просто не заметит. Убрали или ужесточили — старый клиент упадёт.
| Изменение | Безопасно? | Почему |
| Добавить необязательное поле в ответ | Да | Клиент, который его не ждёт, просто проигнорирует |
| Добавить необязательное поле в запрос | Да | Старые клиенты его не шлют, сервер подставит дефолт |
| Добавить новый эндпоинт | Да | Никто про него ещё не знает |
| Удалить или переименовать поле ответа | Нет | Клиент читает поле и получает пустоту |
Сменить тип поля ("5300" на 5300) | Нет | Парсер клиента упадёт или тихо соврёт |
| Сделать необязательное поле обязательным | Нет | Старые запросы начнут отваливаться с 400 |
| Добавить значение в enum | Осторожно | Клиент со switch без default может упасть |
| Поменять смысл существующего поля | Нет, худшее из всего | Никто не заметит, все посчитают неверно |
| Поменять код ошибки или её структуру | Нет | Обработка ошибок у клиента сломается тихо |
Проверять это можно автоматически. Вот наивная, но рабочая иллюстрация того, что делает настоящий breaking-checker:
old = {"id": "int", "email": "string", "name": "string"}
new = {"id": "int", "email": "string", "full_name": "string", "phone": "string"}
removed = [f for f in old if f not in new]
retyped = [f for f in old if f in new and old[f] != new[f]]
added = [f for f in new if f not in old]
print("Добавлено (безопасно):", added)
print("Удалено (ломает потребителей):", removed)
print("Сменило тип (ломает потребителей):", retyped)
print("Вердикт:", "СОВМЕСТИМО" if not removed and not retyped else "BREAKING CHANGE")
Результат:
Добавлено (безопасно): ['full_name', 'phone']
Удалено (ломает потребителей): ['name']
Сменило тип (ломает потребителей): []
Вердикт: BREAKING CHANGE
Именно такой шаг — только на настоящих схемах — и стоит поставить в pipeline. Для protobuf это buf breaking, для OpenAPI — openapi-diff или oasdiff. Полминуты в CI против ночной аварии.
Как переименовать поле правильно
Правильный ответ — не переименовывать одним движением, а сделать расширяй-и-сжимай (expand and contract) в три такта:
- Expand. Добавляем
full_name, ноnameпродолжаем отдавать и заполнять тем же значением. Оба поля в ответе, все довольны. - Migrate. Помечаем
nameкакdeprecated, объявляем дату отключения, находим потребителей (по логам и метрике обращений!) и ждём, пока они переедут. - Contract. Когда метрика использования
nameуверенно лежит на нуле — удаляем.
components:
schemas:
User:
type: object
required: [id, email]
properties:
id:
type: integer
email:
type: string
name:
type: string
deprecated: true
description: "Устарело, будет удалено после 2026-10-01. Используйте full_name."
full_name:
type: string
В protobuf есть отдельная защита от самой коварной ошибки — переиспользования номера удалённого поля. Номер, а не имя, определяет байты на проводе: если поле 3 было строкой, а вы отдали тройку под int64, старый клиент прочитает мусор и даже не заметит. Поэтому номера удалённых полей резервируют:
message User {
int64 id = 1;
string email = 2;
reserved 3; // здесь когда-то был name
reserved "name"; // и имя тоже больше нельзя использовать
string full_name = 4;
}
Стратегии версионирования
| Подход | Пример | Плюсы и минусы |
| Версия в пути | /api/v2/users/42 | Очевидно, видно в логах и метриках; но версионируется весь API целиком |
| Версия в заголовке | Accept: application/vnd.shop.v2+json | Чистые URL; но неудобно отлаживать и легко забыть заголовок |
| Версия в пакете (gRPC) | package users.v2; | Стандарт в gRPC, старая и новая версии живут рядом на одном сервере |
| Без версий, только расширение | — | Идеал: если вы никогда не ломаете совместимость, версия не нужна вовсе |
Практика зрелых команд: новая мажорная версия — это признание поражения, крайняя мера, когда обратно совместимо задачу решить нельзя. Держать одновременно больше двух версий почти всегда невыносимо: любой баг чинится дважды. И у каждой версии обязана быть дата смерти, объявленная заранее (для HTTP есть даже стандартный заголовок Sunset). Версия, которую «выключим когда-нибудь», не выключат никогда.
Как это работает: contract testing
Обычные интеграционные тесты «поднимем все двадцать сервисов и погоняем» медленные, хрупкие и не масштабируются. Contract testing решает ту же задачу иначе.
Consumer-driven contracts (самый известный инструмент — Pact) переворачивают ответственность: не поставщик решает, что он обещает, а потребитель записывает, чем он реально пользуется. Работает это так:
- Потребитель (сервис уведомлений) пишет тест против мока поставщика: «я шлю GET /users/42, я жду 200 и поля
idиemail». Из теста генерируется пакт — JSON-файл ожиданий. - Пакт публикуется в общий брокер контрактов.
- В CI поставщика (сервис профилей) запускается верификация: настоящий сервис поднимается и проверяется всеми пактами всех потребителей.
- Если разработчик удалил
email, сборка сервиса профилей краснеет у него в PR — с точным указанием, чей именно пакт он нарушил.
{
"consumer": { "name": "mail-service" },
"provider": { "name": "profile-service" },
"interactions": [
{
"description": "получить профиль для письма",
"request": { "method": "GET", "path": "/api/v1/users/42" },
"response": {
"status": 200,
"body": { "id": 42, "email": "anna@example.com" }
}
}
]
}
Обратите внимание: в пакте нет полей status и tier — сервису писем они не нужны, и он их не проверяет. Поставщик волен менять их как угодно. Контракт покрывает ровно то, что реально используется, — в этом вся идея.
Для событий работает та же логика, но через schema registry: схема события лежит в реестре, и при публикации новой версии реестр сам проверяет режим совместимости — BACKWARD (новые консюмеры читают старые сообщения), FORWARD (старые консюмеры читают новые) или FULL (обе стороны). Несовместимую схему реестр просто не примет.
Частые ошибки
- «Это поле никто не использует». Проверяется не интуицией, а метрикой обращений к полю и логами. Пока цифра не ноль — не удаляем.
- Тихая смена типа. Было
"amount": "5300", стало"amount": 5300. Слабо типизированный клиент не упадёт — он посчитает неправильно. Это хуже падения. - Смена смысла поля. Поле
amountбыло в рублях, стало в копейках. Схема не изменилась, тесты зелёные, чеки выросли в сто раз. - Переиспользование номера поля в protobuf. Прямой путь к молчаливому повреждению данных. Всегда
reserved. - Версия ради версии. Выкатывать
/v2из-за одного нового поля — значит плодить копии кода на пустом месте. Новое поле — это/v1и обратная совместимость. - Вечная
/v1. Обратная ошибка: выкатили/v2и не выключили/v1, теперь поддерживаете обе версии третий год. - Схема, написанная руками после кода. OpenAPI-файл, который никто не проверяет против реального сервиса, врёт уже через месяц. Схема должна быть либо источником кода, либо генерироваться из него и проверяться тестами.
- Внутренние модели наружу. Отдаёте наружу сериализованную ORM-сущность — и теперь любое переименование колонки в БД является breaking change для чужих сервисов. Между БД и API должен быть слой DTO.
Итоги
- Контракт — это то, на что потребитель имеет право опираться. Он обязан быть артефактом в репозитории (OpenAPI,
.proto, схема события), а не устной договорённостью. - Правило совместимости: расширять можно (новое необязательное поле, новый эндпоинт), сужать нельзя (удаление, переименование, смена типа, ужесточение обязательности).
- Переименование делается в три такта — expand, migrate, contract: сначала оба поля, потом deprecated с датой отключения, потом удаление по нулевой метрике использования.
- Мажорная версия — крайняя мера, а не рутина; у каждой версии должна быть объявленная дата отключения, иначе она бессмертна.
- Contract testing (Pact) и проверка breaking changes в CI (
buf breaking,openapi-diff, schema registry) ловят поломку в PR автора, а не ночью в проде у потребителя.