Контракты и версионирование 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) в три такта:

  1. Expand. Добавляем full_name, но name продолжаем отдавать и заполнять тем же значением. Оба поля в ответе, все довольны.
  2. Migrate. Помечаем name как deprecated, объявляем дату отключения, находим потребителей (по логам и метрике обращений!) и ждём, пока они переедут.
  3. 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) переворачивают ответственность: не поставщик решает, что он обещает, а потребитель записывает, чем он реально пользуется. Работает это так:

  1. Потребитель (сервис уведомлений) пишет тест против мока поставщика: «я шлю GET /users/42, я жду 200 и поля id и email». Из теста генерируется пакт — JSON-файл ожиданий.
  2. Пакт публикуется в общий брокер контрактов.
  3. В CI поставщика (сервис профилей) запускается верификация: настоящий сервис поднимается и проверяется всеми пактами всех потребителей.
  4. Если разработчик удалил 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 автора, а не ночью в проде у потребителя.
Проверьте себя
1. Какое из изменений REST API безопасно для уже работающих потребителей?
AПереименовать поле name в full_name в ответе
BДобавить в ответ новое необязательное поле phone
CСделать ранее необязательный параметр запроса обязательным
DИзменить тип поля amount со строки на число
2. В чём главная идея consumer-driven contract testing (например, Pact)?
AПоставщик пишет полную документацию API, а потребители обязаны ей следовать
BПеред каждым релизом поднимаются все сервисы разом и прогоняются сквозные тесты
CПотребитель фиксирует, чем он реально пользуется, а CI поставщика проверяет его сервис против этих ожиданий
DВсе сервисы переводятся на gRPC, и контракт проверяет компилятор