Материал учебного циклаОткрытый материал

Event-Driven Modeling: события, очереди и асинхронные процессы

Практическое руководство по Event-Driven Modeling и проектированию асинхронных взаимодействий. Разбираем команды и события, Producer, Broker и Consumer, очереди и routing, идемпотентность, порядок событий, retries, Dead Letter Queue, Transactional Outbox, eventual consistency и связь событий с BPMN и State Machine.

Продукт и интерфейсы #Event-Driven Architecture #RabbitMQ #события
Учебный цикл Моделирование и проектирование информационных систем Материал 8 из 9

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 и тем более не сделает её надёжной.

Продолжить

  1. 01 BPMN: от бизнес-процесса до требований к системе Что такое BPMN, зачем моделировать бизнес-процессы и как читать простую диаграмму от начала до результата.
  2. 02 UML: как описывать программную систему с разных сторон Введение в UML и основные виды диаграмм. Разбираем, зачем одной системе нужны разные представления, что показывают Use Case, Sequence, Class, Activity, State Machine, Component и Deployment Diagram и как выбрать подходящую диаграмму под задачу.
  3. 03 ERD и моделирование данных Подробное введение в ERD и моделирование данных. Разбираем сущности, атрибуты, первичные и внешние ключи, связи 1:1, 1:N и M:N, обязательность связей, нормализацию и переход от ERD к PostgreSQL и Django ORM.
  4. 04 State Machines: жизненный цикл сущностей Практическое введение в State Machine и моделирование жизненного цикла сущностей. Разбираем состояния, переходы, события, guard conditions, побочные эффекты и связь State Machine с BPMN, базой данных и API.
  5. 05 DFD: как данные проходят через систему Практическое введение в DFD и моделирование потоков данных. Разбираем внешние сущности, процессы, хранилища и потоки данных, Context Diagram, уровни декомпозиции, баланс между уровнями, связь DFD с API, безопасностью, BPMN и Sequence Diagram.
  6. 06 C4 Model: архитектура программной системы Практическое введение в C4 Model для описания архитектуры информационных систем. Разбираем уровни System Context, Container и Component, дополнительные Dynamic и Deployment Diagram, границы между ними и применение C4 для монолитов и микросервисов.
  7. 07 API Contracts: как системы договариваются о взаимодействии Практическое руководство по проектированию API-контрактов. Разбираем структуру HTTP API, ресурсы и action endpoints, request и response schemas, ошибки, OpenAPI, версионирование, совместимость, аутентификацию, авторизацию, webhook-контракты и безопасное изменение API для существующих consumers.
  8. 08 Как собрать BPMN, UML, ERD, C4 и API в одну проектную документацию Практическое руководство по сборке проектной документации информационной системы. Показываем, как связать BPMN, UML, ERD, State Machine, DFD, C4, API Contracts и Event-Driven Modeling так, чтобы каждая модель отвечала на свой вопрос и не дублировала остальные.

Обратная связь

Материал был полезен?

Нашли ошибку или неточность? Сообщить →

Начните вводить — найдём материал по всей библиотеке.