Event или command
Event сообщает о СВЕРШИВШЕМСЯ факте: OrderPaid, UserRegistered. Адресат — кто угодно, кто хочет реагировать; реакций может быть несколько. Command — намерение выполнить действие: ChargeCard, SendEmail. Адресат — один конкретный исполнитель.
Смешивание этих смыслов даёт неясные правила ретраев и подписки. Повтор события безопасен при идемпотентной обработке; повтор команды может создать второй побочный эффект (второе списание), если нет idempotency key. Имена тоже отражают это: события в прошедшем времени (OrderPaid), команды в повелительном (PayOrder).
Envelope: обязательные поля
Сообщение делят на envelope (метаданные) и payload (тело). В envelope кладут: eventId (UUID, для дедупа), eventType + schemaVersion (для эволюции), occurredAt (бизнес-время), aggregateId (для партиционирования и порядка per-key), correlationId/causationId (трассировка цепочек запрос→событие→событие), source/producer-id.
Эти поля важнее «красивого» формата payload: без них дальше нельзя ни дедуплицировать, ни эволюционировать схему, ни понять, что породило событие в логах. Структуру envelope обычно стандартизируют по всей компании — CloudEvents или собственный аналог.
Idempotency key
At-least-once доставка означает, что consumer обязан переживать дубли (см. урок про DLQ/retries). Технически — кладут стабильный idempotency key (обычно eventId) и держат таблицу processed_events с unique-constraint по нему.
Ключевое: запись бизнес-эффекта и отметки об обработке должны идти в ОДНОЙ транзакции БД (или эквиваленте). Если упасть между ними, при повторе либо обе записи появятся снова, либо обе отсутствуют — третьего не дано, иначе дубли в проде.
-- атомарно: эффект и отметка вместе
begin;
insert into processed_events(event_id) values (?) on conflict do nothing returning 1;
-- если вставка не дала строк — событие уже обрабатывали, пропускаем эффект
insert into payments(...) values (...);
commit;
Schema evolution
События живут дольше одной версии сервиса: продюсер уже выкатил v2, а старые consumer'ы ещё читают v1. Поэтому формат событий проектируют под эволюцию: добавление optional-поля — обратно совместимо (старые читатели его игнорируют), а удаление поля или смена типа требует плана миграции и согласования с потребителями.
Schema Registry с правилами совместимости (BACKWARD/FORWARD/FULL) превращает договорённость в проверяемый контракт: попытка опубликовать несовместимую схему отклоняется на лету. Avro/Protobuf со Schema Registry — стандартный набор; чистый JSON работает, но контракт держится только на дисциплине.
Thick против thin payload
Сколько данных класть в событие — компромисс. Thick (event-carried state transfer): везти все нужные подписчикам поля внутри события, consumer'у не надо звать продюсера. Плюс — автономность; минус — раздутые события и сложнее эволюционировать.
Thin (event notification): только идентификаторы, consumer сам подтягивает детали через API/БД. Плюс — компактность и одна модель данных у продюсера; минус — связность сервисов и нагрузка от вторичных вызовов. Выбор по нагрузке и автономности подписчиков. И отдельно — PII/чувствительные данные в payload требуют осторожности: в логах брокера, ретеншне и архивах они живут долго.
Transactional outbox
Классическая проблема: записали в БД, но не отправили событие в брокер (или наоборот). Двухфазный коммит между БД и брокером дорог и хрупок — его не делают.
Решение — outbox: в той же транзакции БД, что и бизнес-данные, пишут строку в таблицу outbox. Отдельный publisher (или CDC поверх WAL — Debezium) читает outbox и публикует в брокер, помечая отправленные. Если publisher упадёт между транзакцией и отправкой, событие останется в outbox и уедет позже. Гарантия — «либо оба, либо ни одного», без 2PC.
Как отвечать на собеседовании
Разграничьте event (свершившийся факт, много подписчиков, идемпотентный повтор) и command (намерение, один исполнитель, требуется idempotency key). Перечислите обязательные поля envelope (eventId/Type/Version/OccurredAt/aggregateId/correlationId). Объясните идемпотентную обработку с атомарной записью эффекта+отметки. Подчеркните schema evolution через Registry с правилами совместимости. Сравните thick и thin payload и завершите transactional outbox как стандартный паттерн «БД + событие» вместо 2PC.