Контракты событий: schema evolution без боли

Событие как публичный контракт: naming, обратная и прямая совместимость, безопасные и ломающие изменения схемы, JSON против Avro/Protobuf, schema registry, consumer-driven contracts и upcasting

В event-driven системе событие, которое сервис публикует наружу, — это публичный API, только не синхронный. У него есть потребители, которых вы часто не контролируете и не всегда даже знаете. И как любой API, контракт события придётся менять: добавлять поля, переименовывать, дробить. Сделать это, не сломав потребителей, — отдельный навык.

Боль приходит не в момент проектирования первой версии события, а через полгода, когда нужно изменить формат, а на той стороне — пять сервисов и год накопленных событий в хранилище. Эта статья — про то, как проектировать контракты событий так, чтобы их эволюция не превращалась в скоординированный релиз всей компании.

В статье

Событие как контракт

У события, в отличие от REST-эндпоинта, нет «версии в URL», которую видно сразу. Это делает его коварным: формат меняют, не задумываясь, что где-то есть потребитель, который распарсит старое поле и упадёт.

Поэтому первый шаг — признать: integration event — это контракт, к которому применимы те же дисциплины, что к публичному API. Domain event (внутренний, см. карту серии) можно менять свободнее — у него один владелец. Но всё, что пересекает границу сервиса, требует уважения к потребителям.

Naming: как называть события

Соглашения, которые экономят годы:

  • Прошедшее время — событие это факт: OrderPlaced, OxygenConsumed, не PlaceOrder (это команда) и не OrderPlacing.
  • Доменный язык — имя в терминах бизнеса, а не таблиц БД: ReserveAllocated, не RowInserted.
  • Стабильность имени — переименование события = ломающее изменение. Имя выбирается так, чтобы его не хотелось менять.
  • Явная версия там, где она нужнаOrderPlaced.v2, если стратегия версионирования живёт в типе (см. ниже).

Backward и forward compatibility

Два направления совместимости, которые важно не путать:

  • Backward compatibility — новый потребитель умеет читать старые события. Нужна, когда обновили потребителя, а в хранилище/очереди ещё лежат события старого формата.
  • Forward compatibility — старый потребитель умеет читать новые события (игнорируя незнакомые поля). Нужна, когда издатель обновился раньше потребителей.

В распределённой системе издатель и потребители деплоятся независимо и в произвольном порядке. Поэтому цель — full compatibility в обе стороны: тогда порядок выката перестаёт иметь значение, и это снимает целый класс координационных проблем.

Безопасные и ломающие изменения

Практическое правило для большинства форматов:

Безопасные (совместимые) изменения:

  • добавить необязательное поле со значением по умолчанию;
  • добавить новый тип события;
  • расширить enum (с осторожностью — старый потребитель не знает нового значения).

Ломающие изменения:

  • удалить или переименовать поле;
  • сделать необязательное поле обязательным;
  • изменить тип поля (stringint);
  • изменить семантику поля при том же имени (самое коварное — формально совместимо, фактически ломает).

Принцип: добавлять — можно, убирать и менять смысл — нельзя. Если ломающее изменение неизбежно — это новая версия события, а не правка старой.

Версионирование: в типе, в поле, в теме

Где «живёт» номер версии — три подхода:

  • В типе событияOrderPlaced.v2 как отдельный тип. Издатель какое-то время публикует и v1, и v2 (параллельный выпуск), потребители мигрируют, затем v1 выводится. Явно и понятно, но плодит типы.
  • В поле schema_version внутри payload — потребитель смотрит версию и выбирает парсер. Гибко, но требует дисциплины в коде обработки.
  • В теме/топикеorders.v1, orders.v2 как разные каналы. Удобно для крупных разрывов, но дублирует инфраструктуру.

Чаще всего хватает аддитивной эволюции (без смены версии вообще) плюс редкий параллельный выпуск новой версии типа для настоящих разрывов.

JSON vs Avro/Protobuf

Выбор формата напрямую влияет на то, как легко эволюционировать контракт:

JSON (Schema) Avro / Protobuf
Читаемость человекочитаемый бинарный
Размер больше компактный
Контроль совместимости внешний (валидация) встроенный в формат
Эволюция схемы по соглашению по правилам формата
Порог входа низкий выше (нужен registry/кодогенерация)

JSON хорош для старта, небольших систем и отладки. Avro/Protobuf — для высоконагруженных систем, где компактность и формальные правила совместимости окупают инфраструктуру. Protobuf силён в forward/backward совместимости за счёт номеров полей; Avro — за счёт сопоставления writer/reader схем через registry.

Schema registry

Schema registry — централизованное хранилище схем событий. Издатель регистрирует схему, потребитель по идентификатору в сообщении получает нужную версию. Что это даёт:

  • проверку совместимости при публикации — registry отклонит несовместимое изменение схемы ещё на этапе деплоя, а не в проде у потребителя;
  • компактность — в сообщении едет ID схемы, а не вся схема;
  • единый источник правды о форматах событий.

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

Consumer-driven contracts

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

Плюс: издатель видит реальное воздействие изменения до деплоя, а не узнаёт об инциденте от потребителей. Минус: нужна инфраструктура контрактного тестирования (Pact и аналоги) и дисциплина всех команд. Окупается в системах с многими внутренними потребителями.

Upcasting: эволюция в event sourcing

В event sourcing проблема острее: события неизменяемы и хранятся вечно. Через год в хранилище лежат события пяти разных версий, и все нужно уметь читать.

Решение — upcasting: при загрузке старое событие на лету преобразуется в актуальную версию специальной функцией-апкастером. Код домена всегда работает с последней версией, а апкастеры инкапсулируют всю историю миграций формата. Альтернатива — версионированные обработчики, умеющие применять события любой версии напрямую.

Что бы вы ни выбрали — стратегию чтения старых событий нужно заложить до того, как накопится история, а не после. Это прямое продолжение пункта про версионирование событий из статьи об event sourcing.

Checklist: здоров ли ваш контракт

  1. Различаете ли вы domain event (внутренний) и integration event (публичный контракт)?
  2. Названы ли события в прошедшем времени и в доменных терминах?
  3. Какие изменения для вашего формата безопасны, а какие ломающие — зафиксировано ли это?
  4. Достигается ли совместимость в обе стороны (независимый деплой издателя и потребителей)?
  5. Выбрана ли стратегия версионирования (тип / поле / тема)?
  6. Есть ли проверка совместимости схем в CI или через registry?
  7. Если используете event sourcing — есть ли стратегия чтения старых версий (upcasting)?

Если контракт меняют «по месту» без проверки совместимости — каждое изменение это потенциальный инцидент у потребителя, о котором вы узнаете последним.

Документация и первоисточники

Обсуждение в Telegram

Присоединиться →

Комментарии