DDD и Event Driven архитектура

Начинаем моделирование предметной области по заданию для проекта аукциона.

По мотивам нашего большого стрима рассмотрим домен и поддомены для нашего бизнеса с точки зрения DDD. Перейдём на Event Driven архитектуру для проведения сложных бизнес-процессов в системе слабосвязанных модулей. Поговорим про сложности моделирования и познакомимся с практикой Event Storming для построения цепочек команд и доменных событий:

  • 00:00:55 - Подобласти или поддомены
  • 00:02:44 - Единый язык всех сотрудников
  • 00:03:58 - Ограниченные контексты языка
  • 00:04:59 - Важность коммуникаций
  • 00:06:38 - Единый формат диаграмм
  • 00:08:41 - Модули проекта
  • 00:09:38 - Модульный монолит и микросервисы
  • 00:12:52 - Устройство модуля аутентификации
  • 00:18:00 - Минимизация связей
  • 00:19:45 - Выполнение сложных процессов
  • 00:22:34 - Транзакция в монолите
  • 00:25:59 - Множественные вызовы из контроллера
  • 00:28:11 - Неудобные связи
  • 00:29:01 - Уведомление о событиях
  • 00:31:11 - Слушатели событий
  • 00:32:22 - Цепочки команд и событий
  • 00:36:21 - Event Driven архитектура
  • 00:39:24 - Надёжность получения и и повторы
  • 00:42:37 - Удобство диаграммы событий
  • 00:45:42 - Кто должен рисовать
  • 00:47:18 - Сложности процесса проектирования
  • 00:48:48 - Практика Event Storming

И в следующих эпизодах займёмся практикой моделирования нашего проекта.

Скрытый контент (код, слайды, ...) для подписчиков. Открыть →
Дмитрий Елисеев
elisdn.ru
Комментарии (28)
Руслан

Спасибо!

Ответить
Андрей

Огонь.

Дмитрий, подскажите пожалуйста.

Если у нас есть домен отвечающий за город пользователя. Есть команда Command\SetCurrentCity

Далее остальные модули использую текущий город пользователя.

Получение текущего города пользователя будет GetCurrentCity, будет Command или Query ?

Ответить
Максим (@myks92)

Модуль «город пользователя», не имеет никакого контекста, следовательно не может быть модулем. Объясню. Город фактического место нахождения человека может быть один, город для поиска работы может быть другой, город для размещения объявлений третий, город для заказов четвёртый, город для расчёта налогов пятый. Суть, думаю, уловили.

Так же помимо самого города пользователя может требоваться и другие бизнес правила связанные с городом. Например, для договора достаточно указать название города, а вот для расчёта доставки пиццы требуется не только город, но и много чего ещё. Так же и для оценки стоимости авто в разных городах могут отличаться. В общем, в зависимости от контекста город может иметь разные требования. Так же он может где-то быть Value Object, а где-то Entity.

Это было отступление. Теперь о вашем вопросе.

Command - это мутация, команда. Она никогда не возвращает результат. Хотя есть компромисс в виде ID.

Query - это запрос. Она всегда что-то запрашивает, но ничего не мутирует (не изменяет) и получает результат запроса.

Соотвественно GetCurrentCity всегда Query.

Ответить
Александр

"Она никогда не возвращает результат. Хотя есть компромисс в виде ID."

Подскажите, а почему команда не может возвращать результат?

Ответить
Максим (@myks92)

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

Почему допустимо возвращать ID? Потому что такую команду будет проще сделать асинхронной передавая ID в саму команду и убрав возвращаемый ID из Handler. Проще рефакторить. Но лично я никогда не возвращаю никакой результат из команды (void), а ID сразу передаю в контроллере, сгенерировав его специальным сервисом UuidIdentifierGenerator, который подключается через DI везде где нужно создать ID.

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

Вот полезная статья: https://habr.com/ru/post/347908/

Ответить
Александр

Я понимаю случай с асинхронной обработкой. Но если все делается синхронно(и мы точно никогда не будем это менять), не вижу препятствий вернуть необходимые данные после того как данные каким-то образом были изменены.

Я имею ввиду, что все зависит от требований/задачи, которые необходимо решить и фразу "Она никогда не возвращает результат." я бы перефразировал: "Она может не возвращать результат."

Ответить
Максим (@myks92)

Я говорю вам о том, что принято. Вы можете отступать от правил как угодно. Можете хоть в контроллерах напрямую SQL запросами изменять данные в базе. Дело ваше.

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

Если вам нужен результат - после команды можете вызвать Query и отдать результат на фронт.

Ответить
Александр

Начал читать статью и понял, что я ее читал когда-то и полностью согласен со следующим:

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

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

Ответить
Максим (@myks92)

Я же говорю, можете возвращать, если не используете шину. Но если делаете всё хорошо и через шину, то лучше этого не делать. Если нужен результат - можете сделать запрос по Query в контроллере после выполнения команды.

А вот если вам захотелось получить данные из QueryHandler внутри обработчика команды, скорее всего это значит, что CQRS в данной подсистеме не нужен. CQRS решает конкретную проблему - проблему с нагрузками.

Ответить
B

Спасибо!

Ответить
Роман

Вопрос не по теме, но я написал неделю или больше вам на почту и не получил ответ, продублирую сюда.

Планируется ли сделать курс по ларавел новой версии? (возможно обновить старый курс который вы когда то делали или сделать полностью новый).

Мне нравится ваш стиль уроков, было бы интересно по ларавел тоже послушать.

Ответить
Максим (@myks92)

А в чём разница между Laravel и Slim? Отличается лишь инфраструктурный слой и способ настройки конфигурации. Slim более гибок, из-за этого разработчику нужно самому решать как настраивать своё приложение. То есть нет готовых конфигураторов или быстрого внедрения компонентов вроде бандов, как у Symfony. В Symfony и Laravel это всё есть. Не нужно тратить много времени на то, чтобы настроить что-то в данных фреймворках. Достаточно найти подходящий Brige, установить его по документации и готово. А вот в Slime не так. Приходится гибко настраивать.

У Laravel и Symfony все ответы есть в документации или же есть кучу ответов по подобным вопросам. А остальную часть используйте по урокам Дмитрия. Не вижу никаких проблем)

Ответить
Дмитрий Елисеев

В Laravel слишком часто выпускают новые версии и часто меняется мода. Поэтому любые курсы конкретно по нему быстро становятся неактуальны.

Помимо этого там много упрощений и анархии в виде фасадов, трейтов, нетипизированных коллбэков и магических методов. Это удобно для разработки в стиле "фигак, фигак и в продакшен", но IDE и статические анализаторы кода сходят с ума без кучи исправляющих это плагинов. И Eloquent предполагает стиль разработки с интеграционными тестами вместо юнит-тестов и Code First.

А если рассматривать именно код приложения, то, как ответили выше, всё остальное можно внутри программировать как и здесь в Slim.

Ответить
Максим (@myks92)

Подскажите как планируете развивать Event Driven архитектуру, а именно Domain Events?

Для взаимодействия между Агрегатами в рамках одного Контекста в DDD используется два подхода: единичная атомарная транзакция и консистентность в конечном счете.

  1. Транзакционный. Для взаимодействия между Агрегатами Доменные события публикуются и обрабатываются синхронно, используя одну транзакцию в рамках одного Контекста. Для взаимодействия между другими Контекстами – в одном из синхронных подписчиков публикуется Интеграционное событие. То есть Доменное событие != Интеграционное событие.
  2. Консистентный в конечном счёте. Второй способ подразумевается для использования в более нагруженном приложении с меньшей блокировкой таблиц. Для возаимодействия между Агрегатами и Контекстами Доменные события сразу публикуются в шину, или так же используя Интеграционное событие, но достигая согласованность в конечном счёте. В результате появляются ещё и Компенсационные события для обработки сбоев.
  3. Гибридный. Для менее нагруженной части использовать синхронные подписчики, а для более нагруженной части - сразу публиковать интеграционные события, а сбои решать через компенсационные события.

Какой из подходов планируете использовать в аукционе?
Будете ли разделять Domain Event и Integration Event?

Ответить
Дмитрий Елисеев

Будет полностью асинхронный на Domain Event.

Ответить
Максим (@myks92)

Я всё таки ушел от того чтобы доменные события были и интеграционными и вот почему:

  1. Слишком много событий будет происходить что увеличивает таблицы event store (если его использовать) гораздо быстрей.
  2. События должны иметь скалярные типы или поддержку сериализации. Иначе маппинг будет плохо работать, так как в доменных событиях есть VO типа Id. Второй вариант - декодировать весь объект в шину, вместо json, что не очень хорошо. Вдруг объект переместится, а внеси жилится информация с namespace.
  3. События это тоже контракт, как и роуты в HTTP. Если выбрасывать все события, то получиться, что другим сервисам даётся слишком много возможности. В итоге можем получить кучу связанностей по событиям между сервисами. К тому же неконтролируемые. На любое событие может подписаться любой, кому это вздумается. Всегда нужно будет проверять не использует ли кто-то эти события, чтобы изменить этот контракт. В монолите это проще, но в микросервисах определенно сложно.
  4. Некоторые события имеют персональные данные. Чтобы они не попадали в общую шину в открытом виде я их шифрую и подписываю через ключ. Прочитать данные могут только те, у кого есть ключ. Доменные события сложнее шифровать.
  5. Если мы хотим согласовать два агрегата асинхронно, то нам придется делать компенсирующие события, вместо того чтобы делать их только на интеграционные.
  6. Иногда интеграционное событие сильно отличается от доменного. Например, при регистрации 1000-ого пользователя нам нужно выдать бесплатную подписку на 1 год. В случае с доменным событием будет только одно - пользователь зарегистрирован. И сервису подписок нужно будет напрямую спрашивать сколько подписчиков в сервисе на дату публикации события. А в случае с интеграционным мы можем просто добавить в событие «пользователь зарегистрирован» число регистраций на момент его регистрации. Тогда нам не нужно ходить в сервис пользователей и узнавать дополнительную информацию.
  7. Так же есть массовые действия. Например, добавить 100 товаров. В итоге будет 100 доменных событий. Хотя интеграционного события мне бы было достаточно одного.

Хорошая статья на подобную тему тоже говорит о том, что доменные события не должны быть асинхронными и попадать в шину: https://habr.com/ru/companies/ispring/articles/569648

Ответить
Евгений

Что-то уже 2 месяца тишина. Дмитрий..?

Ответить
Владимир Перепеченко

"Тут даже ослик сразу поймёт" (с) Винни the Пух.
Браво!
Сегодня отказался от обеда в таджикской харчевне, чтобы подписаться на месяц. Не пожалел!

Ответить
Дмитрий

Что делать, если нам надо в транзакции асинхронно обработать несколько событий. Например, создался покупатель, выдали счет, зачисили на счет 100 руб. Саги?

Ответить
Максим (@myks92)

Либо Saga, либо компенсирующие события. Транзакций между сервисами нет. Так же если вы используете Symfony Messenger + Doctrine, то там есть Middleware for Doctrine (doctrine_transaction), но это на любителя.

Ответить
Дмитрий

Да используем, за doctrine_transaction большое спасибо, не знал.

Ответить
Сергей

А ещё можно делать программные системы по аналогии с устройством биологических систем.

Ответить
Sergey

Добрый вечер,

Есть вопрос про так называемые политики (слушатели событий) о которых говорится в видео. На каком слое они должны быть реализованы в модуле который слушает внешние события, то есть где будет лежать этот класс этой политики в котором будет хранится связка external_event => internal_command)? На слое application (commands/queries) или на слое infrastructure (controllers/console)?

Ответить
Максим (@myks92)

Если у вас есть деление на слои, то EventHandler лежат в слое Application, там же где CommandHandler, QueryHandler. Если нет, то в корне модуля - Auth/EventHandler.

Ответить
Илья

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

Ответить
Дмитрий Елисеев

Тогда покупателя можно создавать перед превой покупкой.

Как на этом сайте комментатор создаётся не сразу, а когда перед написанием первого комментария вы вписали имя в поле "Как вас называть в комментариях?" и нажали "Далее".

Ответить
gfdgdf

тема супер, когда продолжение?

Ответить
Зарегистрируйтесь или войдите чтобы оставить комментарий

Или войти через:

Google
GitHub
Yandex
MailRu