Event-Driven Modeling: как проектировать события и асинхронные взаимодействия
В синхронной архитектуре взаимодействие обычно выглядит просто:
Service A -> HTTP request -> Service B -> response -> Service A
Первый компонент вызывает второй и ждёт результат.
Например:
Django API -> Payment API -> payment result
Но не каждое взаимодействие должно происходить таким образом.
Представим согласование заявки. После изменения статуса системе нужно:
сохранить Application -> создать участника -> отправить email -> сформировать документы -> обновить аналитику -> передать данные во внешнюю систему
Если выполнять всё внутри одного HTTP-запроса, основной процесс начинает зависеть от множества внешних операций.
Email provider временно недоступен - запрос зависает.
Генерация PDF занимает 15 секунд - пользователь ждёт.
Внешняя образовательная система не отвечает - согласование заявки завершается ошибкой, хотя сама заявка уже могла быть успешно согласована.
Другой подход:
Application approved -> событие ApplicationApproved -> заинтересованные consumers реагируют независимо
Например:
ApplicationApproved -> Participant Service
ApplicationApproved -> Notification Service
ApplicationApproved -> Document Service
ApplicationApproved -> Analytics
Producer сообщает факт произошедшего события, а consumers самостоятельно определяют необходимую реакцию.
На этой идее строится event-driven architecture, а Event-Driven Modeling помогает формализовать события, команды, маршруты сообщений, гарантии доставки и состояние системы при асинхронном взаимодействии.
Команда и событие
Одна из самых важных границ в event-driven системах проходит между Command и Event.
Кратко:
Command - сделай что-то
Event - что-то уже произошло
Например:
GenerateDocument - Command
DocumentGenerated - Event
Команда выражает намерение.
GenerateDocument
ApproveApplication
SendNotification
CreateParticipant
Событие фиксирует факт.
DocumentGenerated
ApplicationApproved
NotificationSent
ParticipantCreated
Разница не только в названии.
У команды обычно есть конкретный получатель, от которого ожидается выполнение операции.
Application Service -> GenerateDocument -> Document Service
У события может не быть одного конкретного получателя.
Application Service -> ApplicationApproved
Producer сообщает:
Заявка согласована.
А дальше событие могут обрабатывать несколько независимых consumers.
Participant Service -> создать участника
Notification Service -> отправить письмо
Analytics Service -> обновить показатели
Integration Service -> передать данные партнёру
Producer может вообще не знать обо всех этих реакциях.
Это одна из основных причин использования событий.
Почему название имеет значение
Плохое событие:
SendEmail
Это команда.
Она говорит:
Отправь email.
Событие:
ApplicationApproved
говорит:
Заявка была согласована.
Notification Service уже сам решает:
ApplicationApproved -> нужно отправить email
Если позже появится Telegram Notification Service, producer менять не потребуется.
Новый consumer просто начинает реагировать на то же событие.
То есть архитектура становится менее связанной.
Событие описывает прошлое
Это полезное правило именования.
Команды естественно звучат как действия:
ApproveApplication
GenerateReport
CreatePayment
CancelOrder
События обычно формулируются как уже произошедший факт:
ApplicationApproved
ReportGenerated
PaymentCreated
OrderCancelled
Это помогает сразу отличить:
просьбу изменить систему
от:
утверждения о том, что состояние системы уже изменилось
Событие связано с состоянием системы
Событие не должно быть просто сообщением в RabbitMQ.
Оно отражает изменение предметной области.
Например:
Application.status: UnderReview -> Approved
После успешной фиксации этого изменения возникает:
ApplicationApproved
Логическая связь:
State transition -> Domain Event
При этом само событие не обязательно содержит всё новое состояние объекта.
Оно фиксирует значимый факт.
Producer, Broker и Consumer
Базовую event-driven систему можно представить так:
Producer -> Broker -> Consumer
Producer создаёт сообщение.
Например:
Application Service
после согласования заявки публикует:
ApplicationApproved
Broker принимает сообщение и организует его доставку.
Например:
RabbitMQ
Kafka
Consumer получает сообщение и выполняет свою реакцию.
Например:
Notification Worker
Document Service
Analytics Consumer
Полный сценарий:
Application Service -> ApplicationApproved -> RabbitMQ -> Notification Consumer -> send email
Другой consumer может одновременно выполнять:
RabbitMQ -> Participant Consumer -> create participant
Producer при этом не вызывает каждый сервис напрямую.
Зачем нужен Broker
Без брокера producer мог бы делать:
Application Service -> Notification Service
Application Service -> Document Service
Application Service -> Analytics Service
Application Service -> Education Platform
Теперь Application Service знает обо всех зависимых системах.
Если добавляется новый consumer:
CRM Export Service
нужно изменять producer.
При использовании брокера:
Application Service -> ApplicationApproved -> Broker
а подписчики добавляются независимо.
Broker также может предоставлять инфраструктурные возможности:
буферизацию
повторную доставку
routing
acknowledgement
dead-letter processing
разделение producer и consumer по скорости
Это не означает, что event-driven архитектура автоматически лучше синхронной.
Она решает другой класс задач.
Event-driven архитектура не равна Celery
В Python-проектах легко свести всю тему к:
Django -> Celery -> RabbitMQ
Но Celery прежде всего является системой распределённых задач.
Например:
generate_document.delay(application_id)
концептуально ближе к команде:
GenerateDocument
Мы явно просим worker выполнить действие.
Event-driven архитектура шире.
Например:
ApplicationApproved
может иметь несколько независимых consumers, которые реагируют на доменный факт.
Celery может использоваться внутри event-driven системы, но:
Celery task != автоматически domain event
и:
message queue != автоматически event-driven architecture
Важно моделировать семантику взаимодействия, а не только транспорт.
Queue
Queue хранит сообщения, ожидающие обработки.
Упрощённо:
Producer -> Queue -> Consumer
Например:
Django -> document_generation queue -> Celery Worker
Producer кладёт сообщение.
Consumer получает его и выполняет обработку.
Если consumer временно работает медленнее producer, сообщения могут накапливаться в очереди.
Это позволяет разделить скорость компонентов.
Например:
100 HTTP requests/sec -> очередь -> workers обрабатывают 60 tasks/sec
Если такая ситуация сохраняется слишком долго, queue будет расти, поэтому сама по себе очередь не отменяет необходимость capacity planning.
Конкурирующие consumers
Одну queue могут обрабатывать несколько worker instances.
Например:
documents queue -> worker-1
-> worker-2
-> worker-3
Каждое сообщение получает один из workers.
Так можно масштабировать обработку.
Например:
1 worker -> 10 документов в минуту
5 workers -> потенциально значительно больше
Реальный рост производительности зависит от базы, CPU, внешних API и других bottlenecks.
Exchange в RabbitMQ
В RabbitMQ producer обычно публикует сообщение не непосредственно в queue, а в exchange.
Схема:
Producer -> Exchange -> Queue -> Consumer
Exchange принимает сообщение и определяет, в какие queues его направить.
Routing зависит от типа exchange и bindings.
Это позволяет producer не знать конкретные queues consumers.
Например:
Application Service -> events exchange
а дальше:
events exchange -> notification queue
events exchange -> analytics queue
events exchange -> integration queue
Одно событие может попасть сразу нескольким независимым consumers.
Routing Key
При публикации сообщения можно использовать routing key.
Например:
application.approved
application.rejected
document.generated
payment.completed
Queue связывается с exchange через binding.
Концептуально:
event: application.approved
|
v
Exchange
/ | \
v v v
Notify Analytics Integration
Каждая queue получает только нужные сообщения согласно правилам routing.
Direct Exchange
Direct exchange сопоставляет routing key с binding key.
Например:
routing key = application.approved
может быть направлен в:
approved-applications queue
если binding настроен на то же значение.
Topic Exchange
Topic exchange позволяет использовать шаблоны.
Например:
application.*
может принимать:
application.created
application.approved
application.rejected
А:
*.approved
может соответствовать:
application.approved
payment.approved
document.approved
Это позволяет строить более гибкую маршрутизацию.
Fanout Exchange
Fanout отправляет сообщение всем связанным queues без анализа routing key.
Например:
ApplicationApproved -> notification queue
-> analytics queue
-> audit queue
Это удобно, когда один факт должен получить каждый подписчик.
Queue и Topic не одно и то же
В разных брокерах терминология отличается.
В RabbitMQ основными понятиями являются:
exchange
queue
binding
routing key
В Kafka основным каналом является:
topic
Поэтому не стоит механически переносить терминологию одного брокера на другой.
Общий архитектурный смысл остаётся похожим:
Producer -> канал доставки -> Consumer
но конкретные гарантии и модель хранения могут существенно различаться.
Acknowledgement
После получения сообщения broker должен понимать, успешно ли оно обработано.
Для этого существует acknowledgement.
Упрощённо:
Broker -> Message -> Consumer
Consumer обработал -> ACK -> Broker
После ACK сообщение можно удалить из соответствующей очереди.
Если consumer завершился до ACK, broker может доставить сообщение повторно.
Получаем важное следствие:
Consumer должен быть готов к повторной доставке.
At-most-once, at-least-once и exactly-once
При обсуждении доставки полезно различать несколько моделей.
At-most-once:
сообщение будет обработано максимум один раз
Но при сбое оно может потеряться.
At-least-once:
сообщение не должно потеряться, но может быть доставлено несколько раз
Эта модель часто встречается в очередях.
Exactly-once звучит как:
сообщение всегда вызывает business effect ровно один раз
Но в распределённой системе обеспечить это end-to-end значительно сложнее, чем просто включить настройку брокера.
Поэтому практический подход часто выглядит так:
at-least-once delivery + idempotent consumer
Idempotency
Представим событие:
PaymentCompleted
Consumer получает его и создаёт заказ на отгрузку.
Первый раз:
PaymentCompleted -> create shipment
После создания shipment consumer упал до ACK.
Broker решил, что сообщение не обработано, и доставил его повторно.
Если consumer просто снова выполнит:
create shipment
получится:
shipment_1
shipment_2
для одного платежа.
Поэтому повторная доставка одного события не должна автоматически приводить к повторному business effect.
Это и есть задача идемпотентности.
Event ID
Полезно давать каждому событию стабильный идентификатор.
Например:
{
"event_id": "evt_01J...",
"event_type": "payment.completed",
"occurred_at": "2026-09-29T10:00:00Z",
"data": {
"payment_id": "pay_42"
}
}
Consumer может хранить обработанные event_id.
Логика:
event_id уже обработан? -> не выполнять business effect повторно
event_id новый? -> обработать -> сохранить event_id
Но здесь тоже важна атомарность.
Если consumer сначала выполнит business operation, а запись event_id сохранит отдельной транзакцией, между ними снова возможен сбой.
Поэтому deduplication нужно проектировать вместе с транзакционными границами.
Идемпотентность по бизнес-ключу
Иногда лучше защищаться не только через event_id.
Например:
PaymentCompleted(payment_id=42)
должен создать только одну запись:
Shipment.payment_id = 42
Можно добавить:
UNIQUE(payment_id)
Тогда сама база помогает предотвратить повторный business effect.
Такой вариант часто надёжнее исключительно прикладной проверки:
if not shipment_exists:
create_shipment()
потому что два workers могут одновременно пройти эту проверку.
Что должно находиться в событии
Есть две крайности.
Первая:
{
"event_type": "application.approved",
"application_id": 42
}
Consumer затем должен запросить producer через API.
Это называется условно event notification.
Событие сообщает:
Что-то произошло, подробности забери отдельно.
Вторая крайность:
положить в payload всю Application,
всего User,
Program,
Documents,
History,
Permissions
Теперь событие превращается в копию половины базы данных.
Это создаёт сильную связанность.
Обычно payload содержит данные, необходимые для понимания события и типичных downstream reactions.
Например:
{
"event_id": "evt_123",
"event_type": "application.approved",
"occurred_at": "2026-09-29T10:15:00Z",
"data": {
"application_id": "app_42",
"program_id": "program_7",
"applicant_id": "user_91",
"version": 8
}
}
Но точная структура зависит от архитектуры.
Event Notification и Event-Carried State Transfer
Два подхода полезно различать явно.
Event Notification
Минимальное событие:
{
"event_type": "application.approved",
"application_id": "app_42"
}
Consumer после этого вызывает API:
GET /applications/app_42
Преимущество:
меньше дублирования данных
Недостаток:
consumer снова синхронно зависит от producer
Event-Carried State Transfer
Событие переносит необходимые данные:
{
"event_type": "application.approved",
"application": {
"id": "app_42",
"program_id": "program_7",
"applicant_id": "user_91",
"approved_at": "..."
}
}
Consumer может обновить своё локальное представление без запроса к producer.
Преимущество:
меньше runtime coupling
Цена:
дублирование данных + управление версиями event schema + eventual consistency
Нет универсально правильного варианта.
Это архитектурный выбор.
Event Schema является контрактом
Event-driven система тоже имеет API contracts.
Просто вместо HTTP request/response появляется:
event type + headers/metadata + payload + semantics
Например:
{
"event_id": "evt_123",
"event_type": "application.approved",
"event_version": 1,
"occurred_at": "2026-09-29T10:15:00Z",
"producer": "application-service",
"data": {
"application_id": "app_42"
}
}
Consumer зависит от этой структуры.
Значит, изменение:
application_id -> id
может быть таким же breaking change, как изменение HTTP API.
События нужно версионировать и развивать с учётом consumers.
Domain Event и Integration Event
Полезно различать внутреннее доменное событие и событие интеграционного контракта.
Внутри Application domain может существовать:
ApplicationApproved
с rich domain object или внутренними полями.
Но публиковать внутреннюю модель напрямую наружу необязательно.
После commit можно сформировать integration event:
{
"event_type": "application.approved",
"event_version": 1,
"data": {
"application_id": "app_42",
"program_id": "program_7"
}
}
Это создаёт границу:
Domain Model -> Integration Contract
и позволяет менять внутреннюю реализацию независимо от внешних consumers.
Ordering
Асинхронная доставка создаёт ещё одну проблему:
События не всегда приходят в том порядке, в котором бизнес их породил.
Представим:
ApplicationSubmitted
ApplicationApproved
Consumer ожидает:
Submitted -> Approved
Но по разным причинам может получить:
ApplicationApproved
ApplicationSubmitted
Если он слепо применяет события, локальное состояние окажется неправильным.
Почему события могут менять порядок
Причины могут быть разными:
несколько queues
несколько partitions
несколько consumers
retry первого события
разная скорость обработки
network delays
параллельная публикация
Поэтому нельзя автоматически предполагать глобальный ordering во всей распределённой системе.
Sequence Number
Для одной сущности можно использовать порядковый номер.
Например:
Application version 5 -> Submitted
Application version 6 -> UnderReview
Application version 7 -> Approved
События:
{
"application_id": "app_42",
"version": 6,
"event_type": "application.review_started"
}
и:
{
"application_id": "app_42",
"version": 7,
"event_type": "application.approved"
}
Consumer может понять:
последняя известная версия = 7
пришло событие version = 6 -> событие устарело
Entity Version
Версия особенно полезна, когда события относятся к конкретному aggregate или entity.
Логика:
version 10 -> version 11 -> version 12
Consumer может:
- игнорировать старые события;
- обнаруживать пропущенные версии;
- временно откладывать событие;
- запрашивать актуальное состояние.
Какое поведение выбрать, зависит от требований.
Partitioning
В системах с partitioned log можно обеспечить ordering внутри одного partition.
Например, ключом partition выбирается:
application_id
Все события одной Application попадают в один ordered stream.
Тогда:
ApplicationSubmitted
ApplicationReviewStarted
ApplicationApproved
для одного application_id сохраняют порядок внутри partition.
Но глобальный порядок между:
Application A
Application B
обычно не требуется и может отсутствовать.
Latest State Check
Иногда consumer вообще не обязан воспроизводить каждое промежуточное состояние.
Например, задача consumer:
поддерживать поисковый индекс Application
При событии он может проверить актуальную версию записи и обновить индекс только последним состоянием.
Если старое событие пришло позже нового:
event.version < indexed.version
его можно проигнорировать.
Это проще, чем пытаться восстановить абсолютный порядок всех сообщений.
Retry
Обработка события может завершиться временной ошибкой.
Например:
Email Provider -> timeout
Удалять сообщение нельзя, если операция должна быть выполнена.
Поэтому используются retries.
Концептуально:
consume -> temporary failure -> retry later
Но retry требует стратегии.
Нужно определить:
какие ошибки временные
сколько попыток выполнять
какая задержка между попытками
увеличивается ли задержка
что делать после исчерпания попыток
Exponential Backoff
Если внешний сервис лежит, повторять запрос каждую миллисекунду бессмысленно.
Можно использовать backoff:
1 минута -> 2 минуты -> 4 минуты -> 8 минут
или другую стратегию.
Это снижает нагрузку на проблемный компонент и даёт ему время восстановиться.
Retry не должен применяться ко всему
Ошибка:
email provider timeout
может быть временной.
Ошибка:
email address has invalid format
retry не исправит.
Поэтому ошибки полезно классифицировать:
transient -> retry
permanent -> fail / DLQ
Иначе система будет бессмысленно повторять невалидную операцию сотни раз.
Dead Letter Queue
После исчерпания retries сообщение часто направляется в Dead Letter Queue.
Схема:
Main Queue -> Consumer -> retries exhausted -> DLQ
DLQ позволяет не блокировать основную очередь проблемным сообщением.
Там можно анализировать:
event_id
event_type
payload
error
attempt count
failed_at
После исправления причины событие можно:
replay
или обработать вручную.
Но наличие DLQ само по себе проблему не решает.
Если никто её не мониторит, это просто другое место, где незаметно накапливаются ошибки.
Poison Message
Иногда сообщение постоянно приводит consumer к сбою.
Например, из-за:
невалидного payload
неподдерживаемой версии schema
неожиданного значения enum
ошибки конкретных данных
Такое сообщение называют poison message.
Если его бесконечно возвращать в основную queue:
consume -> fail -> requeue -> consume -> fail -> requeue
consumer может зациклиться.
Поэтому нужны:
retry limit -> DLQ
и observability вокруг ошибок.
Transactional Outbox
Одна из главных проблем event-driven систем появляется на границе базы данных и брокера.
Представим:
Application.status = Approved
Backend делает:
1. UPDATE application
2. COMMIT PostgreSQL
3. publish ApplicationApproved в RabbitMQ
Что произойдёт, если:
PostgreSQL COMMIT successful
RabbitMQ publish failed
В базе заявка:
Approved
но событие:
ApplicationApproved
никогда не было опубликовано.
Downstream systems ничего не узнают.
А если публиковать сначала
Попробуем наоборот:
1. publish ApplicationApproved
2. UPDATE application
3. COMMIT
Теперь другой failure:
RabbitMQ publish successful
PostgreSQL transaction rolled back
Consumers получили:
ApplicationApproved
хотя в source of truth заявка осталась:
UnderReview
Обе последовательности имеют окно рассинхронизации.
Почему нельзя просто сделать одну транзакцию
PostgreSQL и RabbitMQ являются двумя независимыми системами.
Обычная SQL transaction не может просто охватить:
UPDATE PostgreSQL + RabbitMQ publish
как одну локальную атомарную операцию.
Существуют распределённые транзакционные механизмы, но они значительно усложняют систему и используются далеко не всегда.
Практический вариант для многих систем - Transactional Outbox Pattern.
Как работает Transactional Outbox
В той же PostgreSQL transaction, где меняются бизнес-данные, создаётся запись в outbox.
BEGIN
Application: UnderReview -> Approved
INSERT OutboxEvent(ApplicationApproved)
COMMIT
То есть:
business change + outbox event -> одна DB transaction
Атомарность обеспечивает PostgreSQL.
Если transaction rollback:
нет изменения Application
нет OutboxEvent
Если commit:
есть изменение Application
есть OutboxEvent
После этого отдельный publisher читает outbox и отправляет события в broker.
OutboxEvent -> Publisher -> RabbitMQ
Пример таблицы Outbox
Упрощённо:
CREATE TABLE outbox_event (
id UUID PRIMARY KEY,
event_type TEXT NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
published_at TIMESTAMPTZ
);
В одной transaction:
UPDATE application
SET status = 'approved'
WHERE id = 42;
INSERT INTO outbox_event (
id,
event_type,
payload,
created_at
)
VALUES (
gen_random_uuid(),
'application.approved',
'{"application_id": 42}',
now()
);
После COMMIT publisher находит неопубликованные записи.
Outbox Publisher
Publisher работает примерно так:
найти unpublished events -> publish broker -> отметить published
Но здесь снова возможен сбой.
Например:
RabbitMQ publish successful
publisher упал до UPDATE published_at
После рестарта событие будет опубликовано повторно.
Именно поэтому Transactional Outbox не отменяет idempotency consumer.
Типичная модель:
Outbox -> at-least-once publication
+
Consumer -> idempotent processing
Outbox не является Broker
Таблица Outbox не заменяет RabbitMQ.
У неё другая роль.
Outbox -> надёжно связать DB commit с будущей публикацией
Broker -> доставлять сообщения consumers
Для небольших систем иногда outbox могут читать сами consumers, но это уже другая архитектура и другие trade-offs.
Inbox Pattern
Симметричный подход можно использовать на стороне consumer.
Consumer получает событие и сохраняет его ID в Inbox в той же transaction, где выполняется business effect.
Упрощённо:
BEGIN
проверить event_id
выполнить business change
INSERT inbox(event_id)
COMMIT
При повторной доставке:
event_id уже существует -> повторный effect не выполняется
Комбинация:
Transactional Outbox + Idempotent Consumer / Inbox
является распространённым способом строить надёжные асинхронные интеграции.
Eventual Consistency
В синхронной монолитной transaction можно ожидать:
операция завершилась -> все изменённые данные уже согласованы
В распределённой event-driven системе это часто невозможно или слишком дорого.
Представим:
Application Service -> Approved
после чего публикуется:
ApplicationApproved
Consumers выполняются независимо:
Participant Service -> через 100 ms
Search Index -> через 300 ms
Email -> через 2 sec
Analytics -> через 5 sec
Некоторое время части системы видят разные состояния.
Например:
CRM уже показывает Approved
поиск ещё показывает UnderReview
Через короткий промежуток consumer обработает событие и состояния снова сойдутся.
Это eventual consistency.
Eventual Consistency не означает отсутствие правил
Ошибка состоит в понимании:
У нас eventual consistency, значит данные могут быть какими угодно.
Нет.
Есть ожидаемая модель:
после события dependent state должен в конечном итоге сойтись к корректному значению
Нужно определить:
какая задержка допустима
что увидит пользователь в промежуточном состоянии
как обнаруживается застрявший consumer
как выполняется reconciliation
Например, задержка:
100 ms
может быть нормальной для поискового индекса.
Задержка:
12 часов
может означать поломку pipeline.
Read Model
Eventual consistency часто используется, когда отдельный consumer строит собственное представление данных.
Например:
Application events -> Reporting Database
или:
Product events -> Search Index
Основная система остаётся source of truth.
Read model оптимизируется под конкретный query pattern.
Например:
PostgreSQL transactional model -> events -> Elasticsearch search model
После изменения PostgreSQL поисковый индекс обновляется асинхронно.
Это объясняет, почему разные представления системы могут кратковременно различаться.
Source of Truth
В распределённой системе важно понимать:
Какой компонент является владельцем конкретных данных?
Например:
Application Service -> owner Application state
Payment Service -> owner Payment state
Identity Service -> owner identity
Другие сервисы могут хранить копии или projections.
Например:
Reporting Service
может хранить:
application_status
program_name
applicant_region
но не должен автоматически становиться источником истины для Application.
Без ownership event-driven архитектура легко превращается в несколько несогласованных копий одной базы.
Event-driven и State Machine
События естественно связаны с переходами состояний.
Например:
PaymentCompleted -> Order: WaitingPayment -> Paid
Но важно различать:
событие произошло
и:
transition разрешён
Consumer получает:
PaymentCompleted
и проверяет текущее состояние Order.
Если:
Order.status = WaitingPayment
переход:
WaitingPayment -> Paid
может быть разрешён.
Но если:
Order.status = Cancelled
нужно отдельное бизнес-правило.
Возможно:
PaymentCompleted для Cancelled Order -> initiate refund
а не:
Cancelled -> Paid
То есть событие является входом State Machine, но не отменяет её правила.
State Transition может породить новое событие
Цепочка может продолжаться:
PaymentCompleted -> Order WaitingPayment -> Paid -> OrderPaid
Теперь OrderPaid является событием Order domain.
Его могут получить:
Warehouse
Notification Service
Analytics
Получается последовательность реакций:
PaymentCompleted -> Order transition -> OrderPaid -> downstream reactions
Так строятся длинные event-driven workflows.
Event-driven и BPMN
BPMN и event-driven architecture тоже описывают разные уровни.
BPMN может показать:
Получить оплату -> Сформировать документ -> Отправить уведомление
или использовать Message Event:
ожидать PaymentCompleted
Но BPMN описывает бизнес-процесс.
Event-driven architecture отвечает на технические вопросы:
кто публикует сообщение
какой broker используется
как выполняется routing
какая queue получает событие
какие delivery guarantees действуют
как работает retry
как обеспечивается idempotency
Поэтому:
BPMN Message Event - бизнес-смысл сообщения внутри процесса
Event-driven architecture - техническая модель доставки и обработки сообщений
Они хорошо дополняют друг друга.
Choreography
В event-driven системе процесс может строиться через choreography.
Например:
ApplicationApproved
-> ParticipantCreated
-> DocumentsGenerated
-> WelcomeNotificationSent
Каждый сервис реагирует на событие предыдущего.
Нет одного центрального компонента, который вызывает всех участников.
Например:
Application Service -> ApplicationApproved
Participant Service -> ParticipantCreated
Document Service -> DocumentsGenerated
Notification Service -> WelcomeNotificationSent
Каждый сервис знает только о событиях, которые ему нужны.
Проблема длинной choreography
Если цепочка становится большой:
A event -> B -> event -> C -> event -> D -> event -> E
становится сложно понять весь бизнес-процесс.
Возникают вопросы:
где сейчас процесс?
кто отвечает за завершение?
что делать при ошибке C?
как отменить уже выполненные A и B?
В таких случаях может потребоваться orchestration.
Orchestration
При orchestration существует компонент, который управляет workflow.
Например:
Enrollment Workflow
может отправлять команды:
CreateParticipant
GenerateDocuments
SendWelcomeNotification
и ждать события:
ParticipantCreated
DocumentsGenerated
WelcomeNotificationSent
Получается:
Orchestrator -> Command -> Service
Service -> Event -> Orchestrator
Это увеличивает центральную координацию, но делает длинный процесс более явным.
Выбор:
choreography vs orchestration
зависит от сложности workflow.
Saga
Если один бизнес-процесс затрагивает несколько сервисов, обычной ACID transaction между ними чаще всего нет.
Например:
Order Service
Payment Service
Inventory Service
Delivery Service
Процесс:
CreateOrder -> ReserveInventory -> ChargePayment -> CreateDelivery
Если доставка не создаётся после успешного payment, откатить распределённую transaction простым ROLLBACK невозможно.
Используются compensating actions.
Например:
CreateDelivery failed -> RefundPayment -> ReleaseInventory
Такой многошаговый distributed workflow часто моделируется как Saga.
Saga может использовать:
choreography
или:
orchestration
Это уже следующий уровень event-driven design.
Синхронный API всё ещё нужен
Одна из ошибок event-driven архитектуры - пытаться заменить событиями любое взаимодействие.
Представим frontend:
пользователь открывает Application
Ему нужен текущий response.
Логичный вариант:
Frontend -> GET /applications/42 -> Application Service
Нет смысла делать:
Frontend -> GetApplicationRequested event -> broker -> service -> ApplicationReturned event
для обычного query, если нет специальных причин.
События особенно полезны, когда:
отправителю не нужен немедленный результат
несколько consumers должны реагировать на один факт
операция долгая
компоненты должны быть слабо связаны
нужно сгладить разницу в скорости обработки
Синхронный API полезен, когда:
ответ нужен прямо сейчас
операция проста
caller действительно зависит от результата
Архитектура обычно использует оба способа.
Command через Broker или HTTP
Команда тоже не обязана быть HTTP request.
Можно отправить:
GenerateReport command -> queue -> Report Worker
Это удобно для долгой операции.
Другой вариант:
POST /reports -> Report Service
который внутри создаёт command.
То есть:
Command/Event - семантика сообщения
HTTP/RabbitMQ/Kafka - транспорт
Не стоит смешивать эти понятия.
Событие может передаваться HTTP webhook.
Команда может передаваться через RabbitMQ.
Event Envelope
Для событий удобно иметь единый внешний формат.
Например:
{
"event_id": "evt_01J...",
"event_type": "application.approved",
"event_version": 1,
"occurred_at": "2026-09-29T10:15:00Z",
"producer": "application-service",
"correlation_id": "req_123",
"data": {
"application_id": "app_42",
"program_id": "program_7"
}
}
Здесь metadata отделена от business payload.
event_id нужен для идентификации конкретного события.
event_type определяет семантику.
event_version помогает управлять schema evolution.
occurred_at показывает момент возникновения события.
producer указывает источник.
correlation_id помогает связать несколько сообщений одного бизнес-сценария.
data содержит предметные данные события.
Correlation ID
Представим цепочку:
HTTP request -> ApplicationApproved -> ParticipantCreated -> DocumentsGenerated -> EmailSent
Без общего идентификатора сложно понять в логах, какие сообщения относятся к одному пользовательскому действию.
Можно передавать:
correlation_id = flow_123
через все сообщения.
Тогда observability получает цепочку:
flow_123:
request
ApplicationApproved
ParticipantCreated
DocumentsGenerated
EmailSent
Это особенно важно в распределённых системах, где нет одного stack trace через все сервисы.
Causation ID
Иногда полезно дополнительно хранить:
causation_id
Он отвечает на вопрос:
Какое конкретное сообщение вызвало это сообщение?
Например:
ApplicationApproved
event_id = evt_1
породило:
ParticipantCreated
event_id = evt_2
causation_id = evt_1
а оно:
DocumentsGenerated
event_id = evt_3
causation_id = evt_2
Correlation ID связывает весь workflow.
Causation ID показывает непосредственную причинную связь.
Observability
В синхронной системе ошибку иногда можно проследить по одному HTTP request.
В event-driven системе бизнес-операция может продолжаться минутами и проходить через несколько сервисов.
Нужно наблюдать:
queue depth
consumer lag
processing time
retry count
failed messages
DLQ size
event publication failures
outbox backlog
Например, если:
notification queue depth постоянно растёт
producer создаёт работу быстрее, чем consumers успевают её обрабатывать.
Если:
outbox unpublished events = 50 000
проблема уже не в бизнес-логике, а в publisher pipeline.
Backpressure
Очередь позволяет временно принять нагрузку быстрее, чем consumer её обрабатывает.
Но очередь не бесконечна.
Представим:
Producer = 1000 events/sec
Consumers = 500 events/sec
Каждую секунду backlog растёт на:
500 events
Через час:
1 800 000 events
Поэтому event-driven система должна учитывать backpressure.
Возможные меры зависят от архитектуры:
scale consumers
ограничить producer
batch processing
приоритизация
отдельные queues
отбрасывание допустимых low-value events
Broker не создаёт вычислительную мощность, он только помогает управлять потоком работы.
Event Schema Evolution
Consumer может работать со старой версией кода, пока producer уже обновился.
Поэтому изменение event schema требует тех же вопросов, что изменение API.
Обычно безопаснее:
добавить optional field
чем:
удалить поле
переименовать поле
изменить тип
изменить значение поля без смены семантики
Например:
{
"event_version": 1,
"data": {
"application_id": "app_42"
}
}
Новая версия может добавить:
{
"event_version": 1,
"data": {
"application_id": "app_42",
"program_id": "program_7"
}
}
если старые consumers спокойно игнорируют неизвестное поле.
Breaking change требует отдельной стратегии миграции.
Не меняйте смысл существующего события
Особенно опасно оставить название:
ApplicationApproved
но изменить его смысл.
Например, раньше событие означало:
все проверки завершены, Application окончательно approved
а после обновления:
предварительное решение принято, но ещё возможна отмена
Формат payload может не измениться вообще, но контракт уже сломан семантически.
Event contract включает не только JSON schema, но и значение события.
Event Catalog
Когда событий становится много, полезно поддерживать каталог.
Например:
application.created
application.submitted
application.approved
application.rejected
payment.created
payment.completed
payment.failed
document.generated
document.signed
Для каждого события стоит зафиксировать:
owner
meaning
producer
consumers
schema
version
delivery expectations
PII classification
Иначе broker со временем превращается в поток сообщений, значение которых известно только авторам конкретного сервиса.
Безопасность событий
Broker не должен считаться доверенной зоной автоматически.
Нужно учитывать:
authentication producers и consumers
authorization на publish/consume
TLS
secret management
изоляцию queues/topics
чувствительные данные в payload
retention
audit
Особенно важно не использовать событие как способ бесконтрольно распространять данные.
Например:
UserUpdated
не должно автоматически содержать:
password hash
access token
полный паспорт
все персональные данные
только потому, что producer имеет к ним доступ.
Принцип data minimization действует и для message contracts.
Как спроектировать event-driven взаимодействие
Начинать стоит не с RabbitMQ.
Сначала нужно определить бизнес-факт.
Например:
Application была согласована
Затем сформулировать событие:
ApplicationApproved
Определить owner:
Application Service
Определить consumers:
Participant Service
Notification Service
Analytics
Определить payload:
event_id
application_id
program_id
occurred_at
version
Определить delivery requirements:
допустима ли потеря
допустима ли повторная доставка
важен ли ordering
Определить idempotency strategy.
Затем определить retries и DLQ.
После этого проверить атомарность публикации:
нужен ли Transactional Outbox
И только затем выбирать broker topology:
exchange
queues
routing keys
bindings
consumer groups
Общий путь:
business fact -> event contract -> ownership -> consumers -> delivery guarantees -> idempotency -> ordering -> retry/DLQ -> outbox -> broker topology -> observability
Пример: согласование заявки
Представим исходное состояние:
Application.status = UnderReview
Manager выполняет:
ApproveApplication
Application Service проверяет:
state transition
permissions
guard conditions
После чего в одной DB transaction:
Application: UnderReview -> Approved
OutboxEvent: application.approved
После commit:
Outbox Publisher -> RabbitMQ
RabbitMQ маршрутизирует:
application.approved -> participant queue
application.approved -> notification queue
application.approved -> analytics queue
Participant Consumer:
проверить event_id -> создать Participant -> сохранить обработанное событие
Notification Consumer:
проверить event_id -> отправить уведомление -> ACK
Analytics Consumer:
обновить projection -> ACK
Если Notification Provider недоступен:
temporary failure -> retry -> retry -> DLQ при исчерпании попыток
Основная Application при этом остаётся:
Approved
Ошибка отправки email не откатывает бизнес-факт согласования заявки.
Это хороший пример того, зачем разделять основной state transition и downstream side effects.
Event-driven Modeling и остальные модели системы
Event-driven модель не заменяет BPMN, State Machine, API Contracts или C4.
Она отвечает на другой набор вопросов.
Связь можно представить так:
BPMN -> какой бизнес-процесс происходит
State Machine -> как меняется состояние сущности
Command -> какое изменение хотят выполнить
Event -> какой бизнес-факт уже произошёл
C4 -> какие приложения участвуют
Event-Driven Model -> как события передаются между ними
API Contract -> как выглядит синхронное взаимодействие
ERD -> где хранится состояние и Outbox/Inbox
Например:
BPMN: Согласовать заявку -> Создать участника -> Уведомить
State Machine: UnderReview -> Approved
Command: ApproveApplication
Event: ApplicationApproved
C4: Application Service -> RabbitMQ -> Notification Worker
ERD: Application + OutboxEvent
Каждая модель раскрывает свой уровень.
Типичные ошибки
Первая ошибка - событие называется командой.
Плохо:
SendEmail
GenerateDocument
UpdateAnalytics
если сообщение должно означать уже произошедший факт.
Лучше:
ApplicationApproved
DocumentGenerated
PaymentCompleted
Вторая ошибка - producer знает бизнес-логику всех consumers.
Например:
Application Service публикует SendWelcomeEmail
хотя причина отправки email:
ApplicationApproved
Так Notification Service теряет самостоятельность.
Третья ошибка - payload превращается в копию всей базы.
Большой payload повышает связанность, усложняет versioning и увеличивает риск распространения лишних данных.
Четвёртая ошибка - отсутствие idempotency.
При at-least-once delivery повторные сообщения являются ожидаемой ситуацией, а не редкой аномалией.
Пятая ошибка - полагаться на глобальный ordering.
Порядок нужно гарантировать только там, где он действительно нужен, и явно определять его scope.
Шестая ошибка - бесконечный retry.
Permanent failure не исправится от тысячной попытки.
Нужны retry policy и DLQ.
Седьмая ошибка - Transactional Outbox считают механизмом exactly-once.
Outbox устраняет окно между DB commit и фиксацией намерения опубликовать событие, но повторная публикация всё равно возможна.
Восьмая ошибка - использовать события там, где нужен обычный синхронный ответ.
Event-driven architecture не должна превращать простой GET в распределённый workflow.
Девятая ошибка - никто не владеет event schema.
Если любое поле может измениться без анализа consumers, event bus превращается в неконтролируемый integration layer.
Десятая ошибка - отсутствует monitoring.
Если consumer перестал работать, но producer продолжает публиковать события, пользователь может долго не видеть явной ошибки.
Очередь просто будет расти.
Как проверить event-driven модель
Перед реализацией полезно проверить:
- Это Command или Event?
- Какой business fact выражает событие?
- Кто является owner этого факта?
- Кто producer?
- Какие consumers существуют?
- Нужен ли consumer вообще знать весь payload?
- Является ли event schema стабильным контрактом?
- Может ли событие быть доставлено повторно?
- Как consumer обеспечивает idempotency?
- Нужен ли ordering?
- Как определяется версия entity?
- Какие ошибки нужно retry?
- Когда сообщение попадает в DLQ?
- Кто мониторит DLQ?
- Может ли DB commit пройти без публикации события?
- Нужен ли Transactional Outbox?
- Что является source of truth?
- Какая задержка eventual consistency допустима?
- Нужен ли correlation ID?
- Можно ли решить задачу проще через обычный HTTP API?
Если на эти вопросы есть конкретные ответы, event-driven архитектура становится управляемой системой, а не набором queues и workers.
Что важно запомнить
Главное различие:
Command - просьба выполнить действие
Event - факт уже произошедшего изменения
Базовая транспортная модель:
Producer -> Broker -> Consumer
Для RabbitMQ она раскрывается как:
Producer -> Exchange -> Queue -> Consumer -> ACK
При надёжной доставке нужно ожидать duplicates:
at-least-once delivery -> idempotent consumer
Если важен порядок:
entity_id + version / sequence number -> контроль ordering
Если изменение базы и публикация события должны быть связаны:
Business Change + Outbox Event -> одна DB transaction -> Outbox Publisher -> Broker
При асинхронном взаимодействии компоненты могут обновляться не одновременно:
source of truth изменился -> событие опубликовано -> consumers постепенно обновили своё состояние
Это eventual consistency.
Главная архитектурная цепочка выглядит так:
Business operation -> Command -> State Transition -> Domain Event -> Integration Event -> Broker -> Consumers -> Side Effects
При этом брокер является только транспортной частью.
Настоящая сложность Event-Driven Modeling находится в другом:
семантика событий -> ownership -> контракты -> гарантии доставки -> idempotency -> ordering -> consistency -> обработка ошибок
Если эти вещи не спроектированы, RabbitMQ сам по себе не сделает систему event-driven и тем более не сделает её надёжной.