Работа с событиями
В современных системах, ориентированных на лучшие практики архитектуры, такие как DDD (Domain-Driven Design) и Clean Architecture, события являются фундаментальными механизмами для моделирования изменений состояния и асинхронного взаимодействия между компонентами или системами. Событие представляет собой то, что уже произошло в домене приложения и может быть интересно другим частям системы или внешним сервисам. Дополнение: This section also covers shadow entities and the Transactional Outbox flow with DDD, MediatR, MassTransit, RabbitMQ, Hangfire, and Unit of Work for reliable messaging.
Доменные События
Доменные события представляют важные факты, произошедшие в контексте приложения. Они создаются и потребляются внутри системы, позволяя изменениям состояния распространяться разнесённым образом, то есть объекты не должны знать друг о друге напрямую.
Когда использовать Доменные События?
- Каждый раз, когда операция вызывает значительное изменение в домене и необходимо уведомить или запустить другие части системы.
- Для поддержания согласованности модели домена, позволяя запускать различные процессы без создания прямых зависимостей между модулями или сервисами.
В Lino создание нового доменного события просто. Вы можете выполнить:
lino event new lino event new --name <EventName> --service <ServiceName> --module <ModuleName> --entity <EntityName> lino event edit --service <ServiceName> --module <ModuleName> --entity <EntityName> lino event list --service <ServiceName> --module <ModuleName> --entity <EntityName>
Мастер CLI запросит:
- Сервис – Сервис, в котором будет создано событие.
- Модуль – Модуль, в котором будет создано событие (только для модульных сервисов).
- Сущность – Сущность, к которой будет создано/ассоциировано событие.
- Тип события – Доменные события или интеграционные события.
- Название события – Название, используемое в домене, ассоциированное с сущностью.
Пример
Создавая доменное событие UserCreated, связанное с сущностью User,
система автоматически создаёт событие с названием UserCreatedDomainEvent.
Это название ясно показывает любой части системы, которая потребляет событие, что создание пользователя уже завершено.
Структура, сгенерированная Lino:
<ProjectName>/
└── src/
└── Services/
└── <ServiceName>/
└── Domain/
├── <ProjectName>.<ServiceName>.Domain.csproj
└── Aggregates/
└── Users/
├── User.cs
├── Errors/
├── Events/
│ └── UserCreatedDomainEvent.cs
├── Repositories/
└── Resources/
Technical flow in Lino
- The aggregate registers an
IDomainEventwithRegisterDomainEvent. - The command handler saves through
IUnitOfWork, preferablySaveChangesInTransactionAsyncwhen events are involved. - The Unit of Work persists changes and publishes events through MediatR.
- A domain handler can register an integration event in
IOutbox.
Events are not commands; use past-tense names such as UserCreatedDomainEvent.
Обработчики Доменных Событий
Domain Event Handler — это класс, отвечающий за реакцию на Доменные События, выполняя действия, связанные с внутренним состоянием приложения, всегда в рамках одной и той же транзакции.
Основная цель этих обработчиков — поддерживать систему связной и разнесённой, позволяя применять дополнительные правила без перегрузки основной логики сущности или агрегата.
Например, после создания User может потребоваться обновить статистику, создать внутренний лог или уведомить другой агрегат.
Эти действия имеют смысл внутри домена и могут выполняться синхронно, обеспечивая мгновенную согласованность.
Однако операции, зависящие от внешних ресурсов — например, отправка электронной почты или вызовы сторонних API — не должны выполняться напрямую из доменных событий, так как это привяжет транзакцию к медленным или нестабильным задачам. В таких случаях домен может сгенерировать интеграционное событие (зарегистрированное в Outbox), которое будет обработано позже асинхронно и устойчиво.
Основные характеристики:
- Реагирует на событие, но никогда его не изменяет.
- Выполняет только внутрь-процессные операции, связанные с согласованностью домена.
- Гарантирует выполнение всего в рамках одной транзакции.
Чтобы создать новый обработчик доменных событий, просто выполните команду:
lino event-handler new
CLI-мастер запросит:
- Сервис – Сервис, в котором будет создан обработчик события.
- Модуль – Модуль, в котором будет создан обработчик события (только для модульных сервисов).
- Сущность – Сущность, в которой будет создан обработчик события.
- Тип события – Доменные или интеграционные события.
- Событие – Событие, которое будет обработано.
- Имя обработчика события – Имя, которое будет связано с сущностью и доменным событием.
Пример
Создавая обработчик доменного события UserCreated, связанный с сущностью User и событием UserCreatedDomainEvent, система автоматически создаёт обработчик события с именем UserCreatedDomainEventHandler.
Структура, сгенерированная Lino:
<ProjectName>/
└── src/
└── Services/
└── <ServiceName>/
└── Application/
├── <ProjectName>.<ServiceName>.Application.csproj
└── UseCases/
└── Users/
├── Commands/
├── EventHandlers/
│ └── Domain/
│ └── UserCreatedDomainEventHandler.cs
├── Logging/
├── Queries/
└── Resources/
Technical responsibilities
- Implements
IDomainEventHandlerand reacts inside the application boundary. - Runs internal consistency, logging, tracing, or integration registration work.
- Uses
IOutbox.RegisterIntegrationEventfor reliable messages. - Keeps the business change and
OutboxMessagein the same transaction.
События интеграции
События интеграции — это сообщения, которые указывают на то, что произошло что-то важное, и которые необходимо передавать внешним системам или другим микросервисам.
В отличие от событий домена, здесь цель — коммуникация между системами и синхронизация состояний.
Когда создавать событие интеграции:
- Когда изменение в вашей системе должно отражаться в другой системе.
- Когда ваш микросервис должен публиковать изменения, чтобы другие микросервисы могли на них реагировать.
Основные различия между Domain Events и Integration Events:
| Аспект | Событие домена | Событие интеграции |
|---|---|---|
| Целевая аудитория | Внутренняя | Внешняя |
| Связность | Низкая (внутренняя) | Необходима (между системами) |
| Время обработки | Мгновенное | Может быть асинхронным, с гарантией доставки |
| Необходимость хранения | Не обязательно | Да (для надежности и устойчивости) |
В Lino создать новое событие интеграции просто. Вы можете выполнить:
lino event new lino event new --name <EventName> --service <ServiceName> --module <ModuleName> --entity <EntityName> lino event list --service <ServiceName> --module <ModuleName> --entity <EntityName>
CLI-мастер запросит:
- Сервис – Сервис, в котором будет создано событие.
- Модуль – Модуль, в котором будет создано событие (только для модульных сервисов).
- Сущность – Сущность, в которой будет создано/ассоциировано событие.
- Тип события – Событие домена или событие интеграции.
- Название события – Имя, используемое для события интеграции, связанное с сущностью.
Пример
Создавая событие интеграции UserCreated, связанное с сущностью User, система автоматически создаёт событие с именем UserCreatedIntegrationEvent.
Это имя ясно указывает любой части системы, которая потребляет событие, что действие по созданию пользователя уже выполнено.
Структура, созданная Lino:
<ProjectName>/
└── src/
└── Services/
└── <ServiceName>/
└── Integration.Events/
├── <ProjectName>.<ServiceName>.Integration.Events.csproj
└── Users/
└── UserCreatedIntegrationEvent.cs
Reliable publication
An integration event implements IIntegrationEvent and crosses boundaries between modules, services, or systems. The application registers the event in IOutbox, the Unit of Work persists an OutboxMessage, and a Hangfire worker later publishes it through MassTransit/RabbitMQ.
Обработчики Интеграционных Событий
Integration Event Handler — это класс, который отвечает за потребление Интеграционного события, обычно опубликованного другим сервисом или контекстом, и последующее выполнение конкретных действий в своей доменной области.
Эти обработчики получают события через механизмы обмена сообщениями (например, RabbitMQ, Kafka, Azure Service Bus и др.), обычно вместе с паттерном Outbox, который обеспечивает надежную доставку и асинхронную обработку.
Например, когда событие UserCreated публикуется сервисом идентификации, соответствующий обработчик в другом контексте может реагировать, отправляя приветственное письмо или вызывая внешние API.
Эти операции могут быть медленными или подверженными ошибкам, но так как они выполняются вне основной транзакции, внутреняя согласованность приложения не нарушается.
Основные характеристики:
- Реагирует на события, представляющие бизнес-факты, важные для других контекстов.
- Выполняет операции, которые могут быть медленными или внешними (например, отправка писем, вызовы API).
- Обрабатывается асинхронно и устойчиво, часто с повторными попытками и мониторингом.
- Гарантирует, что внешние ошибки не повлияют на исходную транзакцию домена.
- Облегчает интеграцию между ограниченными контекстами и распределенными системами.
Чтобы создать новый обработчик интеграционных событий, просто выполните команду:
lino event-handler new lino event-handler new --name <EventHandlerName> --service <ServiceName> --module <ModuleName> --entity <EntityName> lino event-handler list --service <ServiceName> --module <ModuleName> --entity <EntityName>
CLI-мастер запросит:
- Сервис – Сервис, в котором будет создан обработчик события.
- Модуль – Модуль, в котором будет создан обработчик события (только для модульных сервисов).
- Сущность – Сущность, в которой будет создан обработчик события.
- Тип события – Доменное событие или интеграционное событие.
- Сервис – Сервис, в котором существует событие для потребления.
- Модуль – Модуль, в котором существует событие для потребления (только для модульных сервисов).
- Сущность – Сущность, в которой существует событие для потребления.
- Событие – Событие, которое будет потреблено.
- Название обработчика события – Название, которое будет связано с сущностью и интеграционным событием.
Пример
Создавая обработчик интеграционного события SendEmailOnUserCreated, связанный с сущностью User и событием UserCreatedIntegrationEvent,
система автоматически создает обработчик события с названием SendEmailOnUserCreatedIntegrationEventHandler.
Структура, сгенерированная Lino:
<ProjectName>/
└── src/
└── Services/
└── <ServiceName>/
└── Application/
├── <ProjectName>.<ServiceName>.Application.csproj
└── UseCases/
└── Users/
├── Commands/
├── EventHandlers/
│ └── Integration/
│ └── SendEmailOnUserCreatedIntegrationEventHandler.cs
├── Logging/
├── Queries/
└── Resources/
Operational guidance
- Implements
IIntegrationEventHandlerand consumes messages with MassTransit. - Does not read the producer database directly.
- Updates local state, projections, or shadow entities.
- Should be idempotent because retries and duplicate messages can happen.
Shadow Entities
shadow entity - это локальная, минимальная и контролируемая копия данных, владелец которых находится в другом модуле или сервисе. Она нужна, чтобы снизить связанность, когда consumer должен читать, проверять или показывать справочные данные без прямого доступа к базе данных producer.
Shadow Entities полезны, когда допустима eventual consistency. Producer публикует integration event, сообщение сохраняется в Outbox, worker публикует его в bus, а consumer обновляет локальную копию через integration event handler.
lino shadow-entity new lino event new lino event-handler new
Команда также доступна через alias lino shadow new. В интерактивном потоке Lino спрашивает целевой сервис или модуль, исходную entity и свойства, которые нужно скопировать. Это позволяет не воссоздавать вручную тип identifier, обязательность, длину string и базовые references, уменьшая расхождения между source и consumer.
shadow entity не является полной репликой. В примере, где модуль Catalog потребляет данные из Tenancy и Security, catalog может хранить только Tenant.Id, Tenant.Slug, User.Id, User.Email и User.TenantId. Исходная user entity по-прежнему содержит hash, confirmation, tokens, dates и другие внутренние поля, которые не принадлежат контексту catalog.
Когда использовать
- Когда consumer нужны несколько полей entity, принадлежащей другому контексту.
- Когда правило допускает небольшую задержку между изменением у producer и обновлением у consumer.
- Когда прямое чтение базы producer создало бы неправильную связанность между модулями или сервисами.
- Когда grids, фильтры, локальные validations или projections нуждаются во внешних справочных данных.
Когда избегать
- Когда правило требует мгновенно обновленных данных и не допускает eventual consistency.
- Когда локальная копия превращается в полную реплику исходного aggregate.
- Когда consumer начинает зависеть от invariants, принадлежащих producer.
Рекомендуемый поток
- Смоделируйте shadow entity только с полями, необходимыми consumer.
- Создайте или выберите integration event, публикуемый producer.
- Создайте integration event handler в consumer.
- Реализуйте идемпотентный upsert по ключу producer или natural identifier.
- Записывайте достаточно logs для диагностики retries, дублированных сообщений и постоянных ошибок.
Синхронизация на основе событий
Полный поток объединяет domain, application, Unit of Work, Outbox, worker, bus и consumer. Цель - избежать хрупких прямых вызовов между модулями или сервисами и дать возможность retry, аудита и reprocessing, когда публикация или обработка завершается ошибкой.
- Aggregate изменяется и регистрирует domain event.
- Unit of Work сохраняет изменение внутри transaction.
- Domain handler реагирует на факт и регистрирует integration event в
IOutbox. - Unit of Work сохраняет business data и
OutboxMessageв той же transaction. - Hangfire worker читает Outbox и публикует в bus через MassTransit/RabbitMQ.
- Consumer получает сообщение через
IIntegrationEventHandler. - Consumer обновляет свою local model, shadow entity, projection, email, external integration или другой asynchronous flow.
Domain change -> Domain Event -> Domain Handler -> Integration Event -> Outbox -> Worker -> Event Bus -> Consumer -> Local model
Правила проектирования
- Размещайте invariants в domain; используйте events для реакций, а не для сокрытия обязательных правил.
- Регистрируйте integration events в той же transaction, что и business change.
- Держите integration contracts небольшими, versionable и стабильными.
- Реализуйте идемпотентных consumers, потому что retries и дублированные сообщения возможны.
- Отслеживайте worker failures и сообщения, застрявшие в Outbox.
Практический результат: producer не должен знать consumers, consumers не обращаются к базе producer, а коммуникация между контекстами остается resilient даже тогда, когда broker или consumer временно недоступен.
