В event-driven системе событие, которое сервис публикует наружу, — это публичный API, только не синхронный. У него есть потребители, которых вы часто не контролируете и не всегда даже знаете. И как любой API, контракт события придётся менять: добавлять поля, переименовывать, дробить. Сделать это, не сломав потребителей, — отдельный навык.
Боль приходит не в момент проектирования первой версии события, а через полгода, когда нужно изменить формат, а на той стороне — пять сервисов и год накопленных событий в хранилище. Эта статья — про то, как проектировать контракты событий так, чтобы их эволюция не превращалась в скоординированный релиз всей компании.
В статье
- Событие как контракт
- Naming: как называть события
- Backward и forward compatibility
- Безопасные и ломающие изменения
- Версионирование: в типе, в поле, в теме
- JSON vs Avro/Protobuf
- Schema registry
- Consumer-driven contracts
- Upcasting: эволюция в event sourcing
- Checklist: здоров ли ваш контракт
Событие как контракт
У события, в отличие от 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 (с осторожностью — старый потребитель не знает нового значения).
Ломающие изменения:
- удалить или переименовать поле;
- сделать необязательное поле обязательным;
- изменить тип поля (
string→int); - изменить семантику поля при том же имени (самое коварное — формально совместимо, фактически ломает).
Принцип: добавлять — можно, убирать и менять смысл — нельзя. Если ломающее изменение неизбежно — это новая версия события, а не правка старой.
Версионирование: в типе, в поле, в теме
Где «живёт» номер версии — три подхода:
- В типе события —
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: здоров ли ваш контракт
- Различаете ли вы domain event (внутренний) и integration event (публичный контракт)?
- Названы ли события в прошедшем времени и в доменных терминах?
- Какие изменения для вашего формата безопасны, а какие ломающие — зафиксировано ли это?
- Достигается ли совместимость в обе стороны (независимый деплой издателя и потребителей)?
- Выбрана ли стратегия версионирования (тип / поле / тема)?
- Есть ли проверка совместимости схем в CI или через registry?
- Если используете event sourcing — есть ли стратегия чтения старых версий (upcasting)?
Если контракт меняют «по месту» без проверки совместимости — каждое изменение это потенциальный инцидент у потребителя, о котором вы узнаете последним.
Документация и первоисточники
- Канон/продукт: Confluent Schema Registry, Avro — schema resolution, Protocol Buffers, Buf (schema governance).
- Смежное на сайте: что кладём в событие (толстое событие = контракт данных), Экосистема Kafka (Schema Registry)готовится, с 12 августа, ES на практике (upcasting)готовится, с 10 августа, контракт-first APIСкоро, data quality и контракты данныхСкоро, событийная архитектура — карта, матрица решенийготовится, с 8 августа.
Комментарии