Статья написана по мотивам моего доклада на митапе

Всем привет! В этой статье хочу поделиться опытом построения системы доменных событий (domain events) в нашем модульном монолите и микросервисах, рассказать о том, как мы гарантируем их доставку, следим за консистентностью в рамках транзакций, используя transactional outbox, чем доменные события отличаются от интеграционных и всё это в рамках multi tenant приложения.

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

Данная статья нацелена на мидлов и сеньоров. Для джунов рекомендую сначала посмотреть в сторону этих двух книжек:

Давайте разберёмся, что такое ограниченный контекст (далее просто контекст). Контекст — это часть большого контекста предметной области, самодостаточная, со своими определениями, языком и доменной моделью. В рамках модульного монолита приложение можно разделить на модули в соответствии с контекстами, в микросервисной архитектуре - на микросервисы. Именно изменения доменной модели внутри контекста генерят события. 

О доменных событиях нужно знать следующее:

  • они обозначают события, произошедшие в прошлом;

  • обрабатываются в текущем контексте;

  • нужны для синхронизации нескольких агрегатов в контексте (то есть каждый агрегат предметной модели сам отвечает за свое консистентное состояние, но если у нас в контексте их несколько, то мы их должны синхронизировать и это делается с помощью доменных событий);

  • не идут в брокер сообщений;

  • обрабатываются синхронно.

В контекстe User могут быть следующие доменные события: пользователь добавлен в систему, пользователь добавлен в группу, у пользователя изменился пароль и так далее.

Теперь перейдём к тому, как мы реализовали схему диспатчинга. В контексте User есть агрегат User. Предположим, у него изменилось имя и сгенерировалось доменное событие, которое отправилось в Event Dispatcher.

Чтобы не нарушать чистую архитектуру, интерфейс Event Dispatcher Interface находится на уровне домена, а реализация лежит на уровне приложения. В реализацию попадает объект события, который передаётся всем подписчикам. Event Handler’ы вызывают нужные методы других агрегатов. Это и есть синхронизация двух агрегатов. 

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

Разберём по схеме. Доменное событие через доменный Event Dispatcher попадает в Domain Event Handler, находящийся на уровне инфраструктуры. Этот Handler преобразовывает объект доменного события в сообщение (в нашем случае в JSON строку) и отправляет в брокер. После этого сообщение попадает в Message Handler в другом контексте. Данное сообщение, пришедшее из брокера и преобразованное в объект Integration Event, и есть интеграционное событие. Для обработки такого события используется Integration Event Handler, поскольку событие не из текущего контекста.


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

  1. Синхронная обработка событий в другом контексте. 

    Идея многомодульного монолита такова, что каждый модуль можно легко вынести в отдельный микросервис, потому что это маленькая предметная область взаимодействующая с другими контекстами через своё API. Если другой модуль подписывается на события синхронно, то он делает это в обход API и его уже не так легко вынести в микросервис. События же являются частью API модуля/микросервиса (хороший пример — https://launchany.com/microservice-design-canvas/). Дополнительные сложности могут принести синхронные транзакции в нескольких модулях.

  2. Событие не доменное, а уровня приложения. 

    Разберём на примере — при добавлении пользователя в систему сам способ добавления (через форму или импорт файла) для предметной области может ничего не значить. Событие «завершен импорт файла» или «изменился прогресс выполнения задачи» чаще всего уровня приложения, а не доменного. Подобные ошибки мы отлавливали на Code Review.

  3. События проектируются с учетом бизнес логики потребителей. 

    Со стороны внешнего сервиса бывает нужно только одно конкретное событие, но с точки зрения нашего контекста, генерирующего событие, оно общее. В таком случае правильным решением будет сделать общее событие, чтобы часть логики внешнего сервиса не проникла в наш контекст. Например, имеем список групп, который может редактироваться и пополняться через интерфейс приложения. Пользователь может быть добавлен в любую группу — группу администраторов, группу комментаторов и тд. Казалось бы это все одно и то же - «пользователь добавлен в группу», но с точки зрения внешнего контекста может быть важно лишь одно событие «пользователь добавлен в группу администраторов». Если мы будем отправлять событие добавления в конкретную группу администраторов, то часть бизнес логики внешнего контекста окажется в нашем контексте.

Ещё есть интересные задачи связанные с событиями:

  1. Массовые и единичные события.

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

  2. Количество и набор данных в событии. 

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

  3. Идемпотентность обработки события. 

    Брокер сообщений может гарантировать at least once доставку, то есть одно сообщение будет доставлено как минимум один раз. Это значит, что оно может быть доставлено и больше одного раза. Когда мы только написали свою систему событий, у нас одно событие бывало приходило и десяток раз. И если во время обработки события мы, например, к счетчику прибавляем два, а потом вновь пришло это событие и мы еще раз добавили два, то в итоге получили +4, а должны были получить только +2. За этим нужно следить.

Каждая из этих тем интересная и большая, подробнее их в статье мы рассматривать не будем. В целом стоит помнить, цитата из книги «Fundamentals of Software Architecture: An Engineering Approach by Mark Richards and Neal Ford»: 

Everything in software architecture is a trade-off (First Law of Software Architecture)


Теперь подробнее про диспатчинг.

Мы уже разобрали, что такое доменное событие и понимаем, какие данные в это событие нужно положить. Пришло время его диспатчить. Казалось бы — диспатчер вызвал и диспатчь, как в примере выше :) На самом деле это не так просто сделать. 

Существует несколько подходов (вариантов диспатчинга):

1) Статический диспатчер, который используется напрямую из доменной модели

Плюсы: его легко реализовывать.

Минусы: 

  • сложнее покрыть и проверять тестами;

  • события диспатчатся немедленно. Мы еще не завершили операцию, агрегат ещё что-то не доделал, а событие уже задиспатчилось. За этим нужно следить.

Например, переименование пользователя — в агрегате User в методе Rename сначала выполняется бизнес логика (проверяются инварианты, что имя корректно), а после этого статическим методом диспатчится доменное событие. Всё просто, но я бы посоветовал всё продумать, прежде чем так делать. 

namespace User\Domain;

class User
{
    public function rename(Name $name): void
    {
        $this->name = $name;
        Dispatcher::dispatch(new UserRenamed($this->getId(), $name));
    }
}

2) Агрегат коллекционирует все свои события (более популярный)

Плюсы:

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

Минусы:

  • лишние методы и данные у агрегата. В нашу предметную модель попали методы, которые не совсем относятся к предметной модели.

По этой теме существует компонент Messenger. 

В агрегате user появляется массив с событиями. Когда выполняется операция «rename», в этот массив сохраняются все доменные события и появляется метод «getEvents». В момент сохранения агрегата получается список событий из агрегата и они сохраняются в базу. После успешного сохранения список событий в агрегате очищается.

namespace User\Domain;

class User
{
    private $events = [];

    public function rename(Name $name): void
    {
        $this->name = $name;
        $this->events[] = new UserRenamed($this->getId(), $name);
    }

    public function getEvents(): array
    {
        return $this->events;
    }
}

3) События диспатчит доменный сервис.

Плюсы:

  • нет лишних методов и зависимостей у агрегатов, модель становится чистой;

  • можно использовать анемичную или частично анемичную доменную модель. DDD рекомендует использовать богатую доменную модель (когда агрегат сам следит за своими инвариантами и не имеет сеттеров). Анемичная модель — это модель для хранения данных, за её инварианты отвечают внешние сервисы, а не она сама. Типичная анемичная модель имеет только поля и наборы сеттеров и геттеров. В данном случае, доменный сервис следит, чтобы инварианты доменной анемичной модели соблюдались внутри нашего уровня домена. Анемичная модель легка в реализации, но в рамках нашего решения не является обязательной.

Минусы:

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

Обычно доменный сервис — это редкий кейс, используется в случаях, когда какая-то логика не относится к конкретному агрегату. Хорошим примером доменного сервиса по DDD является спецификация. Это доменная логика и она относится к предметной модели и области, но не имеет своего состояния и выполняется над агрегатом или над коллекцией агрегатов. Фактически, это сервис, который мы положили в домен. 

namespace User\Domain;

class UserService
{
    public function __construct(DomainEventDispatcherInterface $dispatcher)
    {
        $this->dispatcher = $dispatcher;
    }

    public function rename(User $user, Name $name): void
    {
        $user->rename($name);
        $this->dispatcher->dispatch(new UserRenamed($user->getId(), $name));
    }
}

Именно третий вариант мы выбрали у себя. Он позволил разделить доменную логику и обязанность отправки событий. Модель в таком случае отвечает только за своё состояние и правила его изменения.

Стоит уточнить, что когда у нас разрабатывается CRUD сервис, то для него далеко не всегда нужен DDD. Есть сложные контексты, где мы используем только DDD. Но также есть простые контексты, где можно использовать анемичную модель, которая может лежать на уровне приложения и события там будут только уровня приложения. 


Итак, всё классно, мы задиспатчили событие и тут возникает следующий вопрос. У нас же есть транзакции и брокер сообщений и нам нужно в какой-то момент преобразовать доменное событие в сообщение и отправить его в наш брокер. Как это сделать, чтобы ничего не сломалось? Есть несколько вариантов.

Отправка событий и транзакции

Варианты отправки событий в брокер сообщений:

  1. До закрытия транзакции. 

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

  2. После закрытия транзакции.

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

  3. Сохранение события в event store в хранилище событий в той же самой транзакции, в которой сохраняются наши модели, и отправка в брокер после транзакции. Самый хороший вариант, известный как transactional outbox. Именно этот вариант мы реализовали у себя.

UPD: под event store подразумевается таблица в той же базе, где лежат данные агрегата, хранящая все его события. Таким образом появляется возможность в рамках одной транзакции записать в базу сразу и изменения агрегата, и новые доменные события.

Рассмотрим третий вариант на простой схеме.

В данном случае алгоритм обработки доменного события:

  1. После изменения модели User генерится доменное событие (в нашем случае в доменном сервисе)

  2. Это доменное событие из Event Dispatcher’a синхронно попадает в Event Handler’ы

  3. Первый Event Handler синхронно вызывает методы модели Group

  4. Второй Event Handler синхронно передаёт доменное событие в Event Store

  5. Это событие преобразуется в stored event в удобном виде для хранения в БД

  6. Дальше происходит закрытие транзакции и в БД сохраняются изменения моделей User, Group и модели Stored Event

  7. Stored Event Listener на событие коммита транзакции в базу выбирает все новые Stored Event’ы и отправляет в брокер сообщений

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

7ой пункт у нас в таком виде реализован для PHP монолита. В БД есть отдельная таблица, хранящая ID последнего отправленного сообщения. При отправке считываются все неотправленные сообщения, при этом с блокировкой, чтобы не слать одно сообщение много раз. В рамках микросервисов на go мы не подписываемся на событие коммита, а проверяем с заданным интервалом наличие новых сообщений в горутине.

Подробнее про transactional outbox можно почитать в книге Chris Richardson «Microservices Patterns».


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

Один из интересных моментов, с которым мы столкнулись — один tenant мог нагенерить так много событий, что их обработка могла занять минуты, а иногда и часы. Ясно, что нам необходимо было работать над уменьшением времени обработки отдельного события. Но в рамках общей архитектуры такие наплывы всё равно могли произойти и мы решили сначала разобраться с основной проблемой - не дать наплыву событий из одного tenant повлиять время обработки событий из других. В multi tenant модели нам надо обрабатывать события последовательно в рамках одного tenant и параллельно для разных tenant, а также иметь регулируемое ограничение на количество одновременно обрабатываемых событий.

Мы искали, что есть в мире, ничего не нашли и написали свой маленький сервис, который является прослойкой до подписчиков. 

У каждого tenant своя очередь событий в event store, которые попадают в брокер, и есть сервис, который на эти события подписан (в рамках монолитной архитектуры генерить и подписываться может один и тот же монолит). В изначальной реализации сервис сам подписывался на брокер и обрабатывал события. 

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

Использование данного message limiter не является обязательным при подключении новых сервисов к брокеру, но если возникают инциденты, описанные выше, мы рекомендуем его использовать командам. Модульный монолит использует message limiter по умолчанию для всех модулей.

В итоге после реализации описанных выше решений мы:

  • не теряем события (они сохраняются в одной транзакции с моделью);

  • имеем единую шину событий на несколько контекстов и сервисов;

  • обрабатываем события в multi tenant модели.

Полезные ссылки:

Комментарии (35)


  1. oxidmod
    26.07.2021 18:56

    Мне наоборот второй вариант генерации ивентов кажется более удобным
    1. Можно обмазаться мидлварями, ивент сабскрайберами которые по наличии EventProducerInterface у доменного объекта заберут у него ивенты и запулят их в диспатчер или даже сделают что-то такое
    $model->releaseEvents($this->eventDispatcher);

    2. А если прийти к схеме хранения ивентов в базе, то ивенты становятся просто one-to-many релейшеном и мапятся на базку средствами вашей ОРМ и никаких дополнительных методов делать не нужно


    1. ilyashikhaleev Автор
      26.07.2021 21:55

      При выборе решения меня во втором варианте смутил лишний интерфейс у доменных объектов и необходимость либо ручками писать для каждой модели работу с коллекцией событий, либо использовать trait. Потому выбрал доменные сервисы) Когда выбирал реализацию, смотрел варианты в книжке DDD in PHP, рекомендую :) По мапингу в ОРМ - для модульного монолита придётся для каждого агрегата делать отдельный event store. Я решил хранить в одной таблице все события монолита, в каждом микросервисе тоже поднимать одну таблицу событий на микросервис.

      Все варианты имеют свои плюсы и минусы. В итоге в любом случае придётся идти на некоторые компромиссы) Ваше вариант имеет отличный плюс - модель сама отвечает за всю доменную логику, включая отправку событий)


  1. theRavel
    26.07.2021 19:28

    А другие сервисы (не монолит) живут в том же инстансе базы? Просто не очень понятен компромис с хранением ивентов в БД:

    1) Если инстанс один и тот же, то консистентность и транзакционность гарантирована, да. Но получаем БД как single point of failure (ВСЕ сервисы падают несмотря на микросервисную архитектуру) и БД заодно является performance bottleneck.

    2) Если инстансы разные, то получаем опять проблему distributed storage, и можно было бы с таким же успехом сообщения сразу писать в message bus.


    1. ilyashikhaleev Автор
      26.07.2021 22:03
      +2

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

      Ивенты в базе хранятся для того, чтобы быть сохранёнными в одной транзакции с агрегатами. После того, как транзакция закрывается и мы гарантированно сохранили изменения в модели, отдельная горутина (либо этот же скрипт PHP по событию закрытия транзакции) отправляет сообщения в брокер. То есть мы гарантированно сохранили изменения в базу и гарантированно, но с некоторой задержкой, отправили события в message bus. Про данный паттерн можно подробнее почитать тут - https://microservices.io/patterns/data/transactional-outbox.html Конечно же в рамках реализации есть ряд интересных моментов)

      Если не ответил, пожалуйста, опишите подробнее проблему)


      1. theRavel
        27.07.2021 00:00
        +1

        Спасибо, теперь понятнее.

        Предполагаю, что у вас свои библиотеки/сервисы для взаимодействия с bus layer (отправка по завершению транзакции, tenant throttling, и т.п.) - не хотите что-то из этого в open-source выложить?


        1. ilyashikhaleev Автор
          27.07.2021 00:23
          +1

          Не планировали, но идея хорошая, спасибо) Если доработок для обобщения будет не много, то закинем вот сюда - https://github.com/ispringtech


  1. laatoo
    27.07.2021 11:52

    Не совсем понимаю почему вы рассматриваете именно статический диспатчер в 1м варианте.
    Почему не заинджектить диспетчер через конструктор агрегата? Тогда и проблемы с тестированием пропадут (если ещё и события каким-то образом внедрять, а не хардкодить new SomeDomainEvent внутри метода).


    class User
    {
    public function __construct(DomainEventDispatcherInterface $dispatcher)
        {
            $this->dispatcher = $dispatcher;
        }
    
    public function getEventDispatcher(): DomainEventDispatcherInterface 
        {
            return $this->dispatcher;
        }
    
    public function rename(Name $name): void
        {
            // ... переименовываем пользователя
    
            $this->getEventDispatcher()->dispatch(new UserRenamed($user->getId(), $name));
        }
    }

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

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


    Не понимаю проблемы, что именно вас смутило?


    То, что вы вынуждены создавать по отдельному сервису-обёртке чтобы агрегаты выбрасывали свои события — большее зло.


    1. Как теперь разобраться, какой из методов агрегата нужно вызывать через сервис, а какой — напрямую?
    2. Как объяснять почему событие UserRenamed не срабатывает, если мы вызываем $user->rename(), но срабатывает если вызвать $userService->rename($user, $newUserName) ?

    По мере появления новых событий UserService продублирует весь публичный интерфейс агрегата User, только в сигнатуре каждого метода появится ещё User $user


    interface SomeAggregate {
    
    public function someMethod(//... someMethod arguments);
    
    }
    
    interface SomeAggregateService {
    
    public function someMethod(SomeAggregate $aggregate, //... someMethod arguments);
    
    }

    Тогда уже декораторы для агрегатов сочинять (что-то вроде EventAwareUser($user,$dispatcher);), но польза, как по мне, не очевидна, а сложность растёт.


    1. laatoo
      27.07.2021 17:53

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


      1. Как теперь разобраться, какой из методов агрегата нужно вызывать через сервис, а какой — напрямую?
      2. Как объяснять почему событие UserRenamed не срабатывает, если мы вызываем $user->rename(), но срабатывает если вызвать $userService->rename($user, $newUserName) ?

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


      1. ilyashikhaleev Автор
        27.07.2021 23:41

        Только добрался до вашего вопроса) за минусы других людей не могу отвечать)

        По поводу инъекции диспатчера в модель - такой вариант имеет место быть и я его тоже рассматривал, но не использовал последующим причинам:

        • В первую очередь агрегат при восстановлении из базы у нас собирается в рамках монолита с помощью ORM, усложнять логику его конструирования, прокидывая актуальный dispatcher в методы восстановления агрегата, не хотелось. Наш диспатчер, ввиду multi tenant модели, инициализируется на каждый запрос с контекстом запроса и там же на него подписываются нужные handler'ы (ведь важно в рамках сервисов инициализировать, в каком tenant идёт сейчас работа и местами другие контекстные параметры). В рамках PHP монолита пришлось бы писать свои обёртки над ORM, что в последующем могло усложнить её обновление. В рамках go микросервисов мы восстанавливаем агрегаты специальными методами, данные подготавливают реализации репозиториев ручками из базы. В этом случае пришлось бы прокидывать диспатчер в репозитории. В целом, восстановление состояния агрегата тоже интересная задача со своими подводными камнями)

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

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

        По вопросу попадания лишних методов в модель - это некоторый мой личный пуризм) Идея в том, что предметная модель отвечает за свои инварианты, наличие у неё метода "получить события" с точки зрения экспертов предметной области непонятно)

        По поводу дублирования методов агрегата в сервисе - это тот самый компромисс, который был принят, в итоге данное решение зафиксировано у нас на уровне архитектурного стандарта) Плюс, похожий подход описывается в книге DDD in PHP. В рамках статьи у меня не было намерения показать, что выбранные мной методы единственно верные, хотелось поделиться опытом, поскольку в целом вопрос с доменными событиями мне интересен, а в процессе реализации возникло много нюансов, и ответы на возникающие вопросы приходилось искать по нескольким книжкам по DDD, сайту microsoft и различным статьям) В итоге собрал различные нюансы по событиям в рамках статьи)


        1. laatoo
          28.07.2021 02:09
          +2

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

          Всё упирается в сложности при создании/восстановлении инстанса агрегата и линковкой его с другими слоями. Архитектурная ошибка где-то здесь, получилась жёсткая связь, которой старались избегать, IoC нарушен: ORM диктует как создавать агрегат.


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

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


          Спасибо за статью! За этим путём интересно наблюдать: такие пограничные моменты авторы книжек почему-то тактично обходят стороной


          1. godzie
            28.07.2021 10:39
            +2

            ORM как раз ничего не создает, а восстанавливает из хранилища в соответствии с persistance ignorance. Если вы в коллекцию сущностей в оперативке добавите а потом запросите сущность, вы же не ожидаете что вызовется конструктор при запросе?

            Другими словами корень агрегата создается ровно 1 раз, что как раз по DDDшному ибо отражает язык бизнеса.


            1. laatoo
              28.07.2021 11:35

              Если вы в коллекцию сущностей в оперативке добавите а потом запросите сущность, вы же не ожидаете что вызовется конструктор при запросе?

              Либо я не понял вас, либо я принципиально что-то не понимаю.


              Ожидаю, конечно. Как вы без new SomeEntity() создадите новый инстанс объекта (агрегат это, сущность, или любой другой объект)? Тут вопрос только в том, где и кто это будет делать. Если эти new с последующими сеттерами будут разбросаны по всему проекту — будет больно вносить изменения, если где-то в фабрике — проще.


              Другими словами корень агрегата создается ровно 1 раз, что как раз по DDDшному ибо отражает язык бизнеса.

              Давайте на примере, не понимаю вас. Вот у нас магазин, Order — это Aggregate Root. Нужно получить 5 последних заказов, и с каждым из них провести какие-то операции (третий отменить, для второго изменить адрес доставки, по первому получить трек код, итд итп). Как вы предалаете создать Order "ровно 1 раз" для 5 заказов?


              В классическом случае мы сходим в OrderRepository и получим 5 Order'ов, для каждого из них вызовется new, соответственно, и конструктор. Что предлагаете вы?


              1. godzie
                28.07.2021 12:17

                e = new entity(id: 1)

                eCollection.add(e)

                alsoE = eCollection.byID(1) <- создается ли тут новый инстанс entity? Если нет почему он должен создаваться когда eCollection это база данных?


                1. laatoo
                  28.07.2021 12:22

                  Так понятно.
                  Нет, не должен, всё верно.


              1. ilyashikhaleev Автор
                28.07.2021 12:19

                "ORM как раз ничего не создает, а восстанавливает из хранилища в соответствии с persistance ignorance." - тут всё верно. При восстановлении агрегата из базы конструктор не вызывается. Это особо важно в случаях, когда есть определённые бизнес правила создания агрегата. Потому за всё время жизни он и правда создаётся один раз. В примере с заказами - заказ создан один раз, во всех остальных use case он восстанавливается из базы в текущем состоянии.

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

                "Архитектурная ошибка где-то здесь, получилась жёсткая связь". Жёсткой связи между ORM и доменом нет, как создавать агрегат решается в домене, а вот как его восстановить - это отдельный вопрос вопрос. В рамках ORM может быть прописан маппинг на базу (инфраструктурный код), в рамках go сервисов мы пробовали в домен добавлять специальный метод "восстановления" с набором параметров, мапающихся на состояние агрегата. При этом в обоих случаях надо будет вызывать дополнительно инъекцию диспатчера, если он будет полем агрегата. И вот этого как раз делать не хочется.


                1. laatoo
                  28.07.2021 12:31

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

                  Не понимаю.
                  Вот вам нужно восстановить из базы агрегат User с id 5. Можете показать пример кода как это происходит?


                  Там же наверняка что-то вроде UserRepository->byId(5), в UserRepository через DI заинджекчена UserFactory, внутри которой и происходит пресловутый new Domain\User()


                  1. ilyashikhaleev Автор
                    28.07.2021 14:43

                    На примере используемой нами Doctrine: https://www.doctrine-project.org/projects/doctrine-orm/en/2.9/tutorials/getting-started.html

                    Doctrine ORM does not use any of the methods you defined: it uses reflection to read and write values to your objects, and will never call methods, not even __construct.

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


                1. godzie
                  28.07.2021 12:55

                  рамках go сервисов мы пробовали в домен добавлять специальный метод "восстановления" с набором параметров, мапающихся на состояние агрегата.

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


  1. to0n1
    27.07.2021 13:37

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


    1. ilyashikhaleev Автор
      27.07.2021 23:51

      Интеграционные события у нас не обрабатываются синхронно даже в рамках монолита. Они всегда проходят через брокер и обрабатываются асинхронно. Если же надо обновить данные модуля А на изменения модели из модуля Б синхронно, то тут обычно делается сага через оркестрацию с синхронным вызовом методов модулей А и Б и с компенсирующими действиями. Мы реализуем это либо отдельно написанной сагой, либо путём вызова из app сервиса модуля Б через anti corruption layer методов API модуля А. И тут в целом не важно, оба модуля в монолите, или один из них уже перенесён в микросервис. Но важно помнить, что синхронный вызов может увеличивать связанность модулей.

      А насчёт получения данных из домена для отдачи в ответ на запрос - тут просто добавляется подписка на доменное событие и данные, полученные из него, возвращаются наружу)


  1. AlexViolin
    03.08.2021 16:24

    Уважаемый автор!

    Откуда Вы взяли, что контекст охватывает все слои приложения? Насколько я себе представляю domain layer состоит из набора контекстов. К другим слоям приложения данное понятие контекста не относится.


    1. ilyashikhaleev Автор
      03.08.2021 16:50

      На вопрос "откуда" - одного конкретного источника не укажу)

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

      Реализация всех слоёв в рамках модуля позволяет нам легко вынести контекст в отдельный микросервис и изолирует код контекстов. Используя deptrac мы настроили правила, по которым модули друг от друга не зависят ни на одном из уровней, кроме anti corruption layer.


      1. laatoo
        04.08.2021 13:58

        Ну вот смотрите.


        1. Если бизнес-логика растекается по слоям (например, обработка события UserRenamed, которая происходит на уровне приложения; то есть смотря только на домен нельзя понять что происходит когда переименовывается пользователь), то в чем здесь фокусировка на домене? Есть бизнес требование "Когда пользователь переименовывается, должно произойти N", и смотря на модель мы только видим что "Когда пользователь переименовывается, выбрасывается событие, а кто его поймает (и поймает ли) и что произойдёт (и произойдёт ли) — непонятно". Бизнес требование потеряно из домена, и по-моему, по самой сути DDD, этого происходить не должно, но происходит во всех реализациях доменных событий в DDD что я видел.


        2. Раз интерфейс сущностей растекается по сервисам (например, реализация метода User->rename() утекает из сущности User в UserRenameService, или, что ещё хуже, в UserService), то чем эта модель rich (чем она лучше анемичной)? Таким образом можно весь интерфейс растащить в сервисы, и тогда сущность превратится в попу POPO (Plain Old PHP Object), только ещё из без сеттеров (потому что инварианты, констистентность итп).


        3. Если в бизнес слой проникают чужеродные для бизнеса (инженерные, т.е. из мира software engineering) понятия вроде EventDispatcher, то где здесь "единый язык" (ubiquitous language)?


        4. Если в рамках контекста домена вы реализуете все нижележащие слои, то чем это отличается от разаработки разных приложений в рамках одной инфраструктуры?



        Это не столько вопросы именно к вашей реализации (не поймите неправильно, я вовсе не пытаюсь вас поставить в тупик/поумничать), а к самому DDD, к его концептам и предлагаемым реализациям.


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


        Истории про то, как легко при DDD вынести контекст в микросервис, по моему, вообще не женятся с реальностью: да, у вас есть API бизнеса в домене (который вы в любом случае сделаете, только с DDD раньше), и вам всего лишь нужно дописать к нему клиент, не потерять обработку событий и прочие важные для бизнеса вещи. Всего лишь сделать всё то же самое, что и обычно, только в других терминах и более сложной кодовой базой.


        1. julianikolaeva
          04.08.2021 15:22
          +1

          Выскажу свое мнение. По поводу пункта 2. Доменные сервисы, согласно DDD, вообще-то должны быть очень редким явлением. Например, когда нужно реализовать какую-то логику, требующую взаимодействия агрегатов, например. Или когда эта бизнес-логика не является частью какого-либо агрегата (пример искать лень :). В данной реализации, по большому счету, они являются middleware, основная задача которых диспатчинг событий. Мне этот вариант тоже кажется переусложнением, но автор объяснил, почему он так сделал. Вы же можете реализовывать хоть через агрегаты, хоть через poco+services, самое главное, как мне кажется, чтобы был один и только один способ вызова нужного поведения (или мутации) объекта. В данном варианте, как и в варианте с poco+services это "гарантируется" договоренностями. В случае агрегатов это гарантировано самой реализацией. Однако, из своей практики я делаю вывод, что реализация poco+services является наиболее простой и гибкой.


          1. laatoo
            04.08.2021 15:46

            Доменные сервисы, согласно DDD, вообще-то должны быть очень редким явлением

            Да, и вот как раз у вернона:


            Чрезмерное увлечение СЛУЖБАМИ обычно приводит к нега­тивным последствиям и созданию АНЕМИЧНОЙ МОДЕЛИ ПРЕДМЕТНОЙ ОБЛАСТИ [Fowler, Anemic], в которой вся логика предметной области заключена в службах, а не распределена по СУЩНОСТЯМ и ОБЪЕКТАМ-ЗНАЧЕНИЯМ

            Примерно об этом же я и говорил


            Знание, относящееся исключительно к предметной области, никогда не
            должно уходить к клиентам
            . Даже если клиентом является ПРИКЛАДНАЯ СЛУЖ­
            БА (Application Service)

            И в этом моя главная претензия. Все реализации доменных событий что я видел нарушают этот принцип.


            Если моделировать pub/sub внутри домена — неизбежно получается EventDispatcher, который нарушает единый язык. А если избавиться от событий, и хардкодить внутри методов (например, прям из метода агрегата отправлять email) — то получается нарушение SRP, лишние зависимости, грязь, ужас, проблемы.


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


            1. julianikolaeva
              04.08.2021 19:59
              +1

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

              Насчёт того, что EventDispatcher нарушает единый язык - ну это уж совсем, извиняюсь, ddd головного мозга ​

              Вот из статьи microsoft:

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

              Но мне кажется, что если вам важно что-то делать на уровне домена, то почему бы и нет. Вообще, все это деление на слои иногда бывает довольно условным. Вот отправка уведомления пользователям - это бизнес-логика или нет? Надо руководствоваться больше здравым смыслом.


              1. laatoo
                04.08.2021 20:43

                Насчёт того, что EventDispatcher нарушает единый язык — ну это уж совсем, извиняюсь, ddd головного мозга ​

                вовсе нет. EventDispatcher — термин из мира разработки софта, инженерный, а бизнес (не знаю, сеть магазина цветов) об этом термине знать не должен.


                отправка уведомления пользователям — это бизнес-логика или нет

                конечно это бизнес логика.


                1. это прямой контакт с клиентом
                2. уведомление — часть сервиса (в смысле обслуживание клиента)

                если вам важно что-то делать на уровне домена, то почему бы и нет

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


                с другой стороны, наверное, тогда можно определить все эти интерфейсы в доменном слое, а реализовать на нижних уровнях, и тогда проблема будет решена. но что-то подсказывает, что это очередной уровень over-engineering (как и всё в DDD)


                Надо руководствоваться больше здравым смыслом

                сколькая дорожка, потому что руководствуясь субъективным "здравым смыслом" можно нагородить серьёзных проблем


                1. julianikolaeva
                  07.08.2021 22:08

                  EventDispatcher — термин из мира разработки софта, инженерный, а бизнес (не знаю, сеть магазина цветов) об этом термине знать не должен.

                  Это, наверно, в идеальном мире, где бизнес читает код :) А в реальном мире бизнес про него и не узнает ;) Мне кажется, что самое важное, чтобы все говорили на едином языке и понимали его одинаково, и в коде использовали те же термины. Но это совсем не исключает возможности введения доп/вспомогательных терминов, имхо. В конце концов, читать и поддерживать этот код будут программисты, а не аналитики или владельцы бизнеса. ДДД не должно быть самоцелью, вы же не курсовой проект на эту тему пишите и не будете его защищать Вернону или Эвансу.


        1. julianikolaeva
          04.08.2021 15:42
          +1

          Истории про то, как легко при DDD вынести контекст в микросервис, по моему, вообще не женятся с реальностью: да, у вас есть API бизнеса в домене (который вы в любом случае сделаете, только с DDD раньше), и вам всего лишь нужно дописать к нему клиент, не потерять обработку событий и прочие важные для бизнеса вещи. Всего лишь сделать всё то же самое, что и обычно, только в других терминах и более сложной кодовой базой

          Обработка событий не потеряется, так как события попадают в другие модули из шины. При вынесении модуля - источника событий в микросервис для потребителей ничего не изменится.

          Насчёт API. При вынесении модуля в микросервис с его стороны нужно будет дописать только код по преобразованию ответов, например, в json в случае с JSON API . Со стороны модулей-клиентов надо будет поменять код только в anti-corruption layer, а по сути заменить вызов php классов на вызов удаленных эндпойнтов и парсинг ответов. Далее этот слой и так преобразует входные данные в модели модуля-клиента. Это гораздо быстрее и проще, чем заменять вызовы методов по всему приложению.

          Возможно, вы как-то по-другому представляете устройство нашего модуля.


          1. laatoo
            04.08.2021 16:01

            Обработка событий не потеряется, так как события попадают в другие модули из шины

            Да, у вас выбросится событие, и другой модуль его обработает. Но вы потеряли из домена/поддомена/контекста знание о том, что происходит при обработке доменного события.


            Пример:


            Бизнес требование: Когда заказ отменяется, клиенту отправляется email "Заказ #14 отменён"

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


            Агрегат Order выбрасывает событие OrderCancelled, событие попадает в шину, из шины его подхватывает кто-то и делает что-то. В рамках домена неизвестно кто и что сделает с этим событием.


            Знание о том, что при отмене заказа клиенту отправляется email теряется из домена.


            Чтобы вернуть это знание в домен, вам придётся в рамках домена сделать что-то вроде UserNotiferService->sendNotification(NotificationFactory::fromEvent(OrderCancelled), $user), а чтобы сделать это в рамках домена придётся поступиться с каким-то из принципов: метод отмены заказа станет зависим от службы уведомлений, а служба уведомлений от инфраструктурных деталей.


            1. julianikolaeva
              04.08.2021 20:06

              Не поняла, при чем тут вынесение в микросервис. Вы тут термин домен используете как доменный слой, я же "читаю" и использую его, как "предметная область/контекст". То есть ваша проблема в том, что из доменного слоя "выпадают" потоки данных. Ну дак если это вас парит, сделайте это в доменном слое. Просто на уровне домена объявите соответствующие интерфейсы нотификатора и что ещё там вам нужно. А реализации пусть лежат хоть в app слое, хоть в инфраструктурном.


        1. julianikolaeva
          04.08.2021 15:48

          Если в рамках контекста домена вы реализуете все нижележащие слои, то чем это отличается от разаработки разных приложений в рамках одной инфраструктуры?

          Не понятно, что вы имеете ввиду под разными приложениями в рамках одной инфраструктуры. Если я назову это не контекстом домена, а модулем, будет понятнее? Один модуль полностью реализует весь домен (а не только доменный слой). Это как микросервис, но в рамках монолита. Вы же не делаете отдельные микросервисы для доменных слоев и отдельный микросервис для всех их реализаций? Или мы с вами друг друга не понимаем?


          1. laatoo
            04.08.2021 16:50

            Один модуль полностью реализует весь домен (а не только доменный слой)

            Так понятнее, только решительно непонятно причем тут DDD. DDD оно про 1 домен, который делится на поддомены и контексты.


            Если у вас несколько доменов (вы их называете модулями) — это несколько разных приложений.


            Яндекс.Поиск, Яндекс.Музыка и Яндекс.Маркет — это не разные контексты домена, не поддомены одного домена, а разные бизнесы, следовательно разные домены, следовательно разные приложения. Каждое из них отдельно может быть спроектировано по DDD, все вместе в одном (как поддомены/модули) — нет, потому что области проблем совершенно разные.


            Если мы сойдёмся с вами здесь — то да, мы понимаем друг друга :)


            Один модуль полностью реализует весь домен (а не только доменный слой)
            Если я назову это не контекстом домена, а модулем

            То возникает терминологическая путаница


            Следует иметь в виду, что ОГРАНИЧЕННЫЕ КОНТЕКСТЫ нельзя считать заменой МОДУЛЕЙ. МО­ДУЛИ используются для агрегации связанных объектов предметной области и отделения от объектов, которые не являются связанными или являются слабо связанными


            1. julianikolaeva
              04.08.2021 20:37
              +1

              Так...началось жонглирование терминами :) хорошо, если говорить на строгом языке ддд, то модуль у нас - это контекст/поддомен. Говоря, домен, я говорю "контекст/поддомен", ибо мало кто вникает в разницу, да и в рамках разговора про монолит мне казалось это очевидным :) В моем комментарии была важна разница между доменом (поддоменом) и доменным слоем. Вы же не думаете, что кто-то пихает в монолит разные "бизнесы"?

              Насчёт модулей. Это не постулат, могут быть разные реализации модулей, мы выбрали для себя такую. Да и DDD по большому счету - это не про конкретную реализацию.

              Сейчас, возможно, я бы не заморачивалась с разделением на app и domain слои, а сделала бы все в одном сервисном слое с poco объектами. Но это, как водится, не точно, и в процессе реализации мое мнение могло бы измениться ​ делайте как проще и как считаете разумнее.


              1. laatoo
                04.08.2021 20:49

                если говорить на строгом языке ддд, то модуль у нас — это контекст/поддомен. Говоря, домен, я говорю "контекст/поддомен", ибо мало кто вникает в разницу, да и в рамках разговора про монолит мне казалось это очевидным :)

                Камень в огород DDD: границы понятий настолько нечеткие, что разработчики между собой договориться не могут :) Какой там uniquitous language!