Команда заказов решила назвать поле buyer_id вместо customer_id. В модели данных это одна правка.

В публичном API старое имя уже читают мобильное приложение, кабинет партнёра и выгрузка для бухгалтерии.

Они обновляются в разное время; часть установленных мобильных версий вообще нельзя принудительно заменить сегодня.

  • Если сервер начнёт возвращать только buyer_id, старый клиент может потерять покупателя в карточке заказа.

  • Если сервер перестанет принимать customer_id во входящих запросах, прежний клиент не сможет исправить заказ.

Для такого изменения нужен переходный период, в котором обе версии клиента работают с одним сервером.

Ниже — моделируемый пример, а не описание инцидента в действующем сервисе. customer_id и buyer_id в нём обозначают один и тот же идентификатор покупателя.

Для такого изменения нужен переходный период, в котором обе версии клиента работают с одним сервером. Это одна из задач проектирования микросервисной архитектуры: изменения в API приходится согласовывать с жизненным циклом его потребителей.


Я Иннокентий Бодров, Product Leader с опытом более 18 лет в технологических продуктах.

В статье разберу последовательность совместимой миграции: как расширить серверный контракт, проверить старых и новых клиентов, наблюдать переход и решить, когда можно убрать прежнее имя поля.

Что именно ломает переименование

В AIP-180 о совместимости API Google рассматривает переименование как удаление старого компонента и добавление нового.

Там же разделены совместимость исходного кода клиента, обмена данными и наблюдаемого поведения.

Это полезнее одного вопроса «проходит ли JSON валидацию?». Ответ может проходить, но значение поля для клиента уже изменилось.

Рассмотрим два метода одного ресурса:

GET /orders/ord-42
PATCH /orders/ord-42
Content-Type: application/json

{"customer_id":"buyer-7"}

До изменения GET возвращает {"id":"ord-42","customer_id":"buyer-7"}. Клиент может использовать поле в коде, в сгенерированной модели OpenAPI, в локальном кеше или в проверке ответа.

На входе возможны отдельные проблемы: старый клиент продолжит отправлять customer_id; новый пришлёт buyer_id; плохо обновлённый посредник отправит оба с разными значениями.

Поэтому перед разработкой стоит записать контракт изменения. В нашем примере он такой:

Ситуация

Поведение сервера в переходный период

GET старым клиентом

Ответ содержит customer_id с прежним значением и смыслом.

GET новым клиентом

Ответ также содержит buyer_id, равный customer_id.

PATCH со старым именем

Сервер принимает customer_id.

PATCH с новым именем

Сервер принимает buyer_id.

PATCH с обоими одинаковыми значениями

Сервер принимает запрос и сохраняет одно каноническое значение.

PATCH с обоими разными значениями

Сервер отклоняет запрос; приоритет одного имени не угадывает.

PATCH без обоих полей

Покупатель не меняется.

В примере null для идентификатора недопустим. Если ваш API разрешает обнуление поля, нужно отдельно определить его семантику: отсутствие ключа и явный null в частичном обновлении не одно и то же.

Шаг 1. Расширяем серверный контракт

Сначала выкатываем сервер, способный обслужить и старые, и новые клиенты. Хранилище по-прежнему держит одно значение buyer_id; два публичных имени не должны превращаться в две независимые колонки, которые могут разойтись.

Нормализацию входа лучше делать на границе API. Ниже маленькая функция на стандартной библиотеке Python.

Она не подменяет фреймворк, авторизацию или транзакцию записи: задача кода — показать правило двух имён и проверить спорные случаи.

MISSING = object()


def buyer_from_patch(payload):
    old_present = "customer_id" in payload
    new_present = "buyer_id" in payload

    if not old_present and not new_present:
        return MISSING  # PATCH не меняет покупателя

    old_value = payload.get("customer_id")
    new_value = payload.get("buyer_id")

    if old_present and new_present and old_value != new_value:
        raise ValueError("customer_id и buyer_id должны совпадать")

    value = new_value if new_present else old_value
    if not isinstance(value, str) or not value:
        raise ValueError("идентификатор покупателя должен быть непустой строкой")
    return value


def order_response(order):
    buyer_id = order["buyer_id"]
    return {
        "id": order["id"],
        "customer_id": buyer_id,  # прежний контракт ещё действует
        "buyer_id": buyer_id,
    }


assert buyer_from_patch({"customer_id": "buyer-7"}) == "buyer-7"
assert buyer_from_patch({"buyer_id": "buyer-7"}) == "buyer-7"
assert buyer_from_patch({"customer_id": "buyer-7",
                         "buyer_id": "buyer-7"}) == "buyer-7"
assert buyer_from_patch({}) is MISSING
assert order_response({"id": "ord-42", "buyer_id": "buyer-7"}) == {
    "id": "ord-42",
    "customer_id": "buyer-7",
    "buyer_id": "buyer-7",
}

for payload in (
    {"customer_id": "buyer-7", "buyer_id": "buyer-8"},
    {"buyer_id": None},
    {"customer_id": ""},
):
    try:
        buyer_from_patch(payload)
    except ValueError:
        pass
    else:
        raise AssertionError(f"Запрос должен быть отклонён: {payload}")

В HTTP-обработчике конфликт двух имён можно оформить как 422 Unprocessable Content с машинным кодом ошибки, например conflicting_buyer_fields.

Конкретный статус и формат ошибки надо взять из принятого контракта сервиса: важна предсказуемая реакция, а не число 422 само по себе.

Не стоит выбирать одно значение правилом «новое поле всегда побеждает». Такой приоритет скрывает ошибку клиента и может записать покупателя, которого пользователь не выбирал.

Точно так же нельзя использовать payload.get("buyer_id") or payload.get("customer_id"): null, пустая строка и отсутствие ключа сольются в одно состояние.

Шаг 2. Публикуем схему, которую сервер уже исполняет

После серверного релиза обновляем описание API, примеры и сгенерированные клиенты.

В переходной схеме оба поля видны, новое имя рекомендовано, старое помечено deprecated: true. OpenAPI 3.1 использует ключевые слова JSON Schema; аннотация deprecated сообщает о плане миграции, но не удаляет поле и не меняет поведение сервера.

components:
  schemas:
    Order:
      type: object
      required: [id, customer_id]
      properties:
        id:
          type: string
        customer_id:
          type: string
          deprecated: true
          description: Старое имя buyer_id; пока возвращается для совместимости.
        buyer_id:
          type: string
          description: Идентификатор покупателя; равен customer_id.

Это фрагмент схемы ответа, а не полной спецификации PATCH. Старое поле остаётся в required, пока сервер гарантирует его в каждом ответе: иначе старая сгенерированная модель может получить неожиданный пропуск.

Схема входа должна отдельно описывать два допустимых имени, необязательность обоих при PATCH, запрет null и ошибку при конфликте. Одним ключом deprecated это правило не выразить; нужны текст контракта и проверка обработчика.

Даже добавление нового поля не следует считать безопасным без проверки клиентов.

Руководство Microsoft по проектированию API исходит из того, что клиент игнорирует незнакомые поля ответа, но оговаривает необходимость обрабатывать старые запросы без новых полей.

В вашем парке могут быть строгие десериализаторы или валидаторы с запретом неизвестных свойств. Их поведение надо проверить до выката, а не предполагать.

Шаг 3. Переводим потребителей по одному

Новые версии клиентов читают buyer_id и отправляют его при изменении заказа. Старые версии продолжают работать с customer_id.

Для каждого известного потребителя нужен владелец, версия или способ идентификации и доказательство перехода. Внутри компании подойдут проверки контрактов клиента против нового сервера.

Для внешних интеграций потребуются документация, уведомление и доступное окно миграции.

Перед релизом сервера полезна матрица, которую можно запустить в CI:

Клиент / действие

Старый сервер

Переходный сервер

Старый клиент читает GET

Работает

Должен работать

Старый клиент отправляет PATCH

Работает

Должен работать

Новый клиент читает GET

Не поддерживается

Должен работать

Новый клиент отправляет PATCH

Не поддерживается

Должен работать

Запрос содержит два разных ID

Нет такого случая

Предсказуемая ошибка

Строки «должен работать» проверяют исполняемым контрактным тестом или тестом реального клиента, включая код, сгенерированный из OpenAPI. Pact описывает подход, при котором потребители фиксируют свои ожидания, а поставщик проверяет их до релиза.

Инструмент необязателен; важно хранить проверяемое ожидание каждого клиента, а не только серверный unit-тест.

Для наблюдения на входе считаем запросы с customer_id, buyer_id, обоими именами и конфликтом.

Разбиваем счётчики по методу, идентификатору интеграции и версии клиента, если эти признаки доступны без записи персональных данных. Сырые значения ID в метрики и логи для этой цели не нужны.

Доля запросов со старым именем полезна, но показывает только запись. Сервер по обычному GET не узнает, какое поле клиент прочитал из ответа.

Для чтения нужны контракты, данные SDK или приложения, подтверждения владельцев и проверка распространённости старых версий.

Отдельно проверьте места, которые не видны в одном HTTP-обработчике: вебхуки, события, пакетные выгрузки, мобильный кеш, примеры в документации и повторные запросы после сетевой ошибки.

Если имя поля входит в подписанный payload, ключ кеша или сериализованное событие, переход потребует собственного плана для этой границы.

Шаг 4. Убираем старое имя только после доказательства

«За неделю не пришло customer_id» недостаточно для удаления: редкий интеграционный сценарий мог просто не запускаться. Перед сокращением контракта стоит ответить на четыре вопроса:

  1. Какие поддерживаемые клиенты ещё могут читать customer_id в ответе или отправлять его в запросе?

  2. Какие из них проверены на переходном сервере и кто подтвердил обновление?

  3. Каков полный период наблюдения, включая редкие задания, мобильные версии и аварийные процедуры?

  4. Как вернуть совместимость, если после удаления проявится забытый потребитель?

Если старый потребитель всё ещё поддерживается, прежнее поле остаётся. В стабильной публичной версии API удаление поля нарушит контракт; AIP-180 прямо относит его к несовместимым изменениям.

Для удаления нужен новый контракт или версия с управляемым переводом клиентов. Microsoft также рекомендует сохранять старую версию на время перехода при ломающем изменении.

Срок жизни старой версии задают владельцы API и потребителей; универсального безопасного числа дней нет.

Не путайте удаление поля с выводом из эксплуатации всего HTTP-ресурса. Заголовки Deprecation и Sunset описывают состояние ресурса и срок его возможного отключения.

Они не объявляют сами по себе, что из JSON-ответа исчезнет именно customer_id. Для поля нужны документация схемы, заметка об изменении и адресное уведомление потребителей.

Перед сокращением выпускаем сборку без customer_id в тестовом окружении, прогоняем контракты и проверяем, что только переведённые потребители обращаются к новой версии.

Откат должен быть заранее исполнимым: вернуть двойную выдачу и приём старого имени можно без обратной миграции данных, потому что внутреннее значение всё время оставалось одним.

Короткий план релиза

Этап

Изменение

Доказательство перед следующим этапом

Подготовка

Перечень потребителей, смысл поля, тесты старого клиента

Известно, кто владеет каждым контрактом и где есть неизвестные потребители.

Расширение

Сервер принимает оба имени и выдаёт оба

Старые запросы и чтение ответа проходят; конфликты отклоняются.

Перевод

Клиенты переходят на buyer_id; схема помечает старое имя

Контракты новых клиентов проходят, использование старого входа наблюдается.

Сокращение

Старое имя убирается в новом контракте/версии

Владельцы подтвердили переход; есть период наблюдения и проверенный откат.

Самая полезная проверка здесь простая: запустить старый клиент против нового сервера до релиза нового клиента. Если она падает, совместимой миграции ещё нет, как бы аккуратно ни выглядела новая схема. А если тест проходит, это только начало: чтение старого поля, редкие интеграции и смысл значения всё равно требуют отдельных доказательств.

Выводы

  • Переименование публичного поля затрагивает чтение, запись, сгенерированные клиенты и внешние интеграции. Перед изменением нужен явный контракт для каждого случая.

  • Сначала сервер принимает и выдаёт оба имени при одном внутреннем значении. Конфликт двух разных ID в запросе следует отклонять, а не выбирать победителя молча.

  • Старый клиент должен пройти проверку против переходного сервера до выпуска нового клиента. Потребителей переводят по одному и подтверждают это контрактами и наблюдением.

  • Старое поле убирают только после доказанного перехода поддерживаемых клиентов, достаточного периода наблюдения и проверки отката. Отсутствие старого имени в запросах не доказывает, что никто не читает его в ответах.


Когда меняется контракт API, важно заранее убедиться, что обновление не нарушит работу существующих клиентов. Для этого нужно проверить поведение сервера на старых и новых запросах и учесть зависимости между интегрированными системами.

Разобрать эти задачи на практике можно на бесплатных открытых уроках:

  • 26 октября, 20:00. «Тестирование FastAPI: проверяем API с помощью pytest». Записаться

  • 26 октября, 20:00. «Интеграция приложений: пошаговая инструкция для системного аналитика». Записаться

Больше обучающих материалов по бэкенду можно найти в дайджесте

Комментарии (1)


  1. showmelove
    26.10.2026 20:58

    а еще можно пользоваться префиксами с версиями в путях к ручкам и оставлять обратную совместимость на долгое время, пока не выйдет Н-количество релизов