Руководство по обработке событий предметной области и событий интеграции при использовании DDD и .NET.

События домена являются фундаментальным строительным блоком в DDD, но они, безусловно, могут быть чем-то вроде минного поля для навигации.

Событие предметной области запоминает что-то интересное, что влияет на предметную область — Мартин Фаулер

События предметной области используются, чтобы позволить агрегатам реагировать на изменение в другом агрегате, не связывая их. Пионерские книги по DDD от таких авторов, как Эрик Эванс и Вон Вернон, выступают за использование асинхронных механизмов для обработки событий предметной области. Однако это значительно увеличивает сложность ваших приложений и количество компонентов, которые вы должны размещать, из-за большого количества обработчиков событий.

Чтобы справиться с этой сложностью, я обнаружил, что в большинстве случаев допустимо и намного проще обрабатывать события предметной области в рамках того же процесса и области транзакции, из которой они были опубликованы. Для обработки процессов, которые требуют асинхронного поведения и агрегатов в разных поддоменах или микросервисах, события интеграции можно публиковать с помощью брокера сообщений/шины событий.

Важно, чтобы события интеграции публиковались после успешного завершения транзакции, и чтобы существовали механизмы, гарантирующие, что события интеграции всегда публикуются успешно — об этом позже!

В этой статье я буду использовать пример приложения, чтобы продемонстрировать, как можно обрабатывать события предметной области и события интеграции.

Образец заявления

Я написал пример приложения .NET для поддельной компании электронной коммерции под названием DDDMart.

Приложение ориентировано на уровень основного домена, поэтому не имеет API. Существует множество различных способов размещения моделей предметной области через API, и я специально хотел сосредоточиться на моделировании предметной области и на том, как обрабатываются события предметной области и интеграции.

Консольное приложение DDDMart содержит ряд фоновых служб, которые используются для имитации создания и оплаты Заказа с использованием различных ограниченных контекстов.

Поддомены

Домен состоит из трех поддоменов:

  • Каталог (Основной) — Каталог продукции
  • Заказ (основной) — используется для создания корзины и создания заказа различных продуктов.
  • Платежи (вспомогательные) — используется для создания и отслеживания счетов-фактур и хранения способов оплаты клиентов.

Каждый поддомен состоит из одного или нескольких ограниченных контекстов. Приложение было многоуровневым, что позволяет размещать каждый поддомен как отдельный микросервис.

Архитектура

Чистая архитектура использовалась для слоев приложения, а предметно-ориентированная архитектура использовалась в слоях предметной области для организации кода по ограниченному контексту.

  • База данных — SQL Lite использовался через Entity Framework. В EF можно легко заменить другого поставщика базы данных.
  • Шина событий — шина событий в памяти была создана для асинхронной обработки событий интеграции. В реальном приложении будет использоваться сторонний инструмент, такой как Rabbit MQ.
  • Медиатор — MediatR использовался для публикации и обработки доменных событий.
  • IoC — Autofac использовался для внедрения зависимостей, чтобы можно было создавать повторно используемые модули.

Сценарий

Образец моделирует следующий сценарий:

  • Корзина создана
  • Товары добавлены в корзину
  • При оформлении корзины создается заказ.
  • Адрес доставки добавляется в заказ
  • Способ оплаты выбран для заказа
  • При оформлении Заказа формируется Счет
  • После оплаты счета заказ отправляется

Я большой поклонник использования Event Storming в своих командах, особенно при использовании DDD для моделирования предметной области. Вот пример того, как Event Storm использовался для разбивки проблемной области и выделения поддоменов, агрегатов, ограниченных контекстов и событий:

Реализация доменных событий

События предметной области должны вызываться из агрегата. Статические методы можно использовать для обработки событий предметной области, как только они возникают, однако есть и лучшие способы сделать это. Гораздо лучше, если обработка может быть отложена до завершения команды/операции на Агрегате в случае возникновения ошибки или любого другого последующего поведения. События предметной области моделируют то, что произошло в прошлом, поэтому в идеале наш код должен отражать и это.

Инициирование доменного события

Поскольку события предметной области должны создаваться из агрегата, имеет смысл хранить их внутри базового класса AggregateRoot.

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

Отправка доменных событий

Если вы используете Entity Framework, вы можете использовать средство отслеживания изменений для извлечения любых событий предметной области, которые были созданы непосредственно перед сохранением изменений в базе данных.

В примере проекта MediatR используется для публикации событий домена, чтобы их могли использовать любые зарегистрированные обработчики событий домена. MediatR также позволяет зарегистрировать несколько обработчиков для каждого события, если это необходимо. Я считаю, что шаблон посредника — это идеальный способ отделить обработчики событий предметной области от вызываемых и публикуемых событий предметной области.

Использование доменных событий

В следующем обработчике событий предметной области показано, как создается заказ при извлечении корзины. Обработчик выполняется, когда mediator.Publish(domainEvent) вызывается для BasketCheckedOutDomainEvent.

Предыдущие примеры могут работать через библиотеку MediatR, поскольку базовый класс DomainEventнаследуется от INotification, а базовый класс DomainEventHandler наследуется от INotificationHandler<T> where T : DomainEvent.

Рекомендации по событию домена

При разработке событий предметной области и связанных с ними обработчиков необходимо учитывать несколько важных соображений:

1. Обработчики обрабатываются в той же единице работы, из которой они были отправлены.

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

2. Родительская единица работы может завершиться ошибкой при фиксации.

Несмотря на то, что ваш предыдущий код, который опубликовал событие, выполнился успешно, транзакция все еще может завершиться ошибкой при фиксации в базе данных. например Конфликт внешнего ключа, база данных недоступна. Очевидно, что в DDD ваш предыдущий код должен проверять большинство этих сценариев, но в конце концов все мы люди!

Это означает, что вы не должны делать в своих обработчиках ничего, что нельзя зафиксировать или отменить отдельно. Как правило, это означает вызов внешних служб, которые находятся вне нашего контроля. Вот несколько примеров, на которые стоит обратить внимание:

  • Отправка писем пользователям
  • Запись данных в другой API
  • Публикация сообщения или события интеграции

К счастью, этот тип сценария можно обрабатывать асинхронно с помощью событий интеграции!

Реализация интеграционных событий

События интеграции должны публиковаться в брокере сообщений и обрабатываться асинхронно. Это позволяет использовать логику повторных попыток, если есть какие-либо проблемы в нижестоящих системах, и это приведет к согласованности в конечном счете.

Обоюдоострый меч

Итак, если мы не должны публиковать события интеграции из наших обработчиков событий предметной области, то где мы должны их публиковать? Неужто сразу после того, как изменения БД успешно сохранены?! К сожалению, если мы это сделаем, то есть шанс, что публикация события не удастся и ваше мероприятие будет потеряно навсегда :’(.

Мы все знаем, что создание программного обеспечения — это всегда компромисс, поэтому, если ваше приложение может смириться с отсутствием нескольких событий интеграции или отправкой событий интеграции, даже если транзакция базы данных не удалась, тогда вперед и сделайте это! Ваш код будет намного проще.

Если ни один из этих крайних случаев неприемлем, то, к счастью, у нас есть другой вариант — шаблон «Исходящие».

Исходящие шаблон

Используя шаблон исходящих сообщений, мы обрабатываем события интеграции как сущности в нашей базе данных и сохраняем их, когда родительская транзакция, которая их создала, фиксируется (1). Таким образом мы гарантируем, что события интеграции всегда сохраняются, если транзакция завершается успешно, и не сохраняются в случае сбоя.

Создается отдельная служба для чтения событий интеграции, находящихся в очереди, из базы данных (2) и их публикации через посредника сообщений (3). Когда событие интеграции успешно опубликовано, оно удаляется из базы данных. Если брокер сообщений не работает, служба будет продолжать попытки опубликовать события, пока не вернется в оперативный режим.

Сохранение событий интеграции в папку «Исходящие»

В примере приложения события интеграции сериализуются в JSON и сохраняются в базе данных вместе с именем события.

Таблица OutboxIntegrationEvents создается в EF DbContext для каждого поддомена для хранения событий интеграции. Сопоставитель событий интеграции вводится для сопоставления событий предметной области с событиями интеграции.

Каждый поддомен должен реализовать свой собственный класс IntegrationEventMapper, который знает, как отображать свои события. Не все события домена приведут к событию интеграции, сопоставление по умолчанию вернет значение null. Ниже показано, как сопоставление было настроено для поддомена Ordering.

События интеграции сопоставляются и сохраняются после обработки событий предметной области.

Публикация событий интеграции из папки «Исходящие»

Для каждого поддомена требуется отдельная служба публикации событий интеграции. Служба периодически считывает события из таблицы «Исходящие», сериализует их и публикует в шине событий. Как правило, в реальном приложении каждый издатель размещается в отдельном процессе.

Последние мысли

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

Я надеюсь, что вы найдете эту статью полезной в своем путешествии с Domain-Driven Design! Весь код примера приложения можно найти здесь.