Перейти к основному содержимому

Шина доменных событий

Зачем нужно

Документ описывает контракт шины событий, через которую домены сообщают друг другу о произошедших фактах. Один домен — один владелец события. Все 7 доменов используют одну шину и один формат.

Транспорт

  • Технология: NATS JetStream (старт). Допустима замена на Apache Kafka через ADR при росте.
  • Runtime-реализация шины закреплена в ADR-032.
  • Один кластер на окружение.
  • Persistent storage с replication factor ≥ 2 в prod.
  • Streams партиционируются по домену.

Формат сообщения

{
"messageId": "uuid",
"messageKind": "event",
"messageType": "lms.enrollment.activated",
"messageVersion": 1,
"occurredAt": "2026-04-30T12:00:00Z",
"producer": "lms-api",
"producerInstance": "lms-api-7d9f...",
"traceId": "...",
"actor": {
"userId": "...",
"actorContext": "personal",
"serviceClient": null
},
"data": { ... },
"metadata": { ... }
}
ПолеНазначение
messageIdуникальный идентификатор сообщения (UUID v4)
messageKindevent или command
messageTypeимя события или команды
messageVersionмажорная версия payload, увеличивается при поломочных изменениях
occurredAtвремя факта (ISO 8601 UTC)
producerимя сервиса-источника
producerInstanceID конкретного инстанса (для дедупликации)
traceIdсвязь с распределённой трассировкой
actorкто инициировал
datapayload события (контракт описан в events.md домена-источника)
metadataслужебная информация: idempotency, корреляции

Naming events

<domain>.<entity>.<action>
  • <domain> — один из 7 доменов или platform; составные домены пишутся в kebab-case, поэтому домен банка задач в событиях — task-bank;
  • <entity> — сущность в snake_case;
  • <action> — глагол в past simple (created, updated, activated, revoked, consumed, published).
  • messageType для messageKind = event должен соответствовать regex ^(identity|storefront|crm|lms|task-bank|competitions|management|platform)\.[a-z0-9_]+\.[a-z0-9_]+$.

Примеры:

  • identity.user.registered
  • identity.session.revoked
  • crm.entitlement.activated
  • crm.entitlement.revoked
  • crm.payment.succeeded
  • lms.enrollment.created
  • lms.attendance.recorded
  • lms.entitlement.consumed
  • task-bank.problem.published
  • competitions.result.published
  • management.recommendation.created
  • storefront.lead.submitted

Исключение: lms.entitlement.consumed сохраняет префикс lms, хотя canonical owner crm_entitlement — CRM. Это не событие о lifecycle crm_entitlement, а LMS-originated usage fact: LMS зафиксировал потребление доступа, а CRM потребляет факт и изменяет баланс/лог. CRM остаётся владельцем таблиц crm_entitlement и crm_entitlement_consumption_log; исключение закреплено ADR-030 и не расширяет правило ownership для других сущностей.

Контракты payload

Контракт payload каждого события описан в domains/<owner>/events.md и согласован с потребителями в integrations/<source>--<target>.md.

Каждый domains/<owner>/events.md должен показывать тот же envelope с messageVersion, чтобы domain-каталог не расходился с платформенным контрактом.

При изменении payload:

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

Consumer смешанных версий обязан явно ветвить обработку:

switch (event.messageVersion) {
case 1:
return handleEntitlementConsumedV1(event.data);
case 2:
return handleEntitlementConsumedV2(event.data);
default:
throw new UnsupportedEventVersionError(event.messageType, event.messageVersion);
}

Unsupported version отправляется в DLQ с alert; молча обрабатывать неизвестную major version запрещено.

Доставка

  • At-least-once.
  • Подтверждение обработки (ack) — на стороне потребителя.
  • Retries по экспоненциальной задержке: 1s, 5s, 30s, 2m, 10m, 1h.
  • После N попыток — DLQ (dead letter queue) с алертом.

Идемпотентность

Потребитель обязан быть идемпотентным:

  • хранит обработанные messageId в таблице processed_messages со сроком хранения ≥ 14 дней;
  • проверяет, видел ли он messageId, перед обработкой;
  • если сообщение — команда (например, crm.entitlement.activate.requested), используется доменный idempotency_key в data.idempotencyKey.
  • если событие порождено HTTP-командой с Idempotency-Key, producer копирует этот ключ в metadata.httpIdempotencyKey и, если ключ нужен consumer для бизнес-дедупликации, также в data.idempotencyKey.
  • TTL processed_messages покрывает normal retry window. Replay за пределами TTL запускается только в explicit replay mode: без side effects, через business idempotency key, или в отдельный snapshot/watermark target. Financial consumers хранят business idempotency keys весь срок финансового retention.

Подписки

  • Каждый сервис описывает свои подписки в domains/<name>/events.md.
  • Подписка задаёт фильтр по messageType (поддерживаются wildcards: crm.entitlement.*).
  • Подписка имеет consumer_group для масштабирования.

Retention

  • События по умолчанию хранятся 30 дней.
  • Финансовые и аудит-события — 7 лет.
  • Конкретные значения по домену — в events.md источника.

Сheck-чтение и replay

  • Любой потребитель может перечитать события за окно retention.
  • Перед replay потребитель должен учитывать идемпотентность.

Команды vs события

В шине ходят оба типа:

  • доменные события — факты, прошедшее время, владелец источник;
  • команды — запросы на действие к другому домену; messageKind = command, messageType использует формат <target-domain>.<entity>.<imperative-action>.requested, например crm.entitlement.activate.requested.

Команды используются, когда вместо синхронного API нужен асинхронный заказ; обычно для долгих или throttled операций. Команды запрещено публиковать как messageKind = event и запрещено называть _requested/_pending внутри event naming.

Безопасность

  • Подключение к шине только из приватной сети.
  • TLS обязательно.
  • Каждое сервис-подключение использует service credentials.
  • Никакого raw PII в payload без явного согласования.

Запрещено

  • Передавать события с messageType без префикса домена.
  • Передавать новые события с hyphen в entity/action сегментах.
  • Публиковать команды как события или трактовать command как уже произошедший факт.
  • Делать события неидемпотентными.
  • Использовать события вместо API для синхронного запроса данных.
  • Менять messageType существующего события (вместо этого — новое имя).

Связанные документы