Основы CQRS

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

    Системы управления предприятиями, проектами, сотрудниками давно вошли в нашу жизнь. И пользователи таких enterprise приложений все более требовательны: возрастают требования к масштабируемости, сложность бизнес-логики, требования к системам меняются быстро, да и отчетность требуется в реальном времени.

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

    Типовой подход к проектированию приложения


    image

    Многослойная архитектура – один из самых популярных способов организации структуры веб-приложений. В простой её вариации, как на приведенной выше схеме, приложение делится на 3 части: слой данных, слой бизнес-логики и слой пользовательского интерфейса.

    В слое данных имеется некий Repository, который абстрагирует нас от хранилища данных.
    В слое бизнес-логики есть объекты, которые инкапсулируют бизнес-правила (обычно их названия варьируются в пределах Services/BusinessRules/Managers/Helpers). Запросы пользователя проходят от UI сквозь бизнес-правила, и дальше через Repository производится работа с хранилищем данных.

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

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

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

    Command and Query Responsibility Segregation (CQRS)


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

    В основе этого подхода лежит принцип Command-query separation (CQS).

    Основная идея CQS в том, что в объекте методы могут быть двух типов:
    • Queries: Методы возвращают результат, не изменяя состояние объекта. Другими словами, у Query не никаких побочных эффектов.
    • Commands: Методы изменяют состояние объекта, не возвращая значение.

    Для примера такого разделения рассмотрим класс User с одним методом IsEmailValid:
    1. public class User
    2. {
    3.     public string Email { get; private set; }
    4.  
    5.     public bool IsEmailValid(string email)
    6.     {
    7.         bool isMatch = Regex.IsMatch("email pattern", email);
    8.  
    9.         if (isMatch)
    10.         {
    11.             Email = email; // Command
    12.         }
    13.  
    14.         return isMatch; // Query
    15.     }
    16. }

    В данном методе мы спрашиваем (делаем Query), является ли валидным переданный email. Если да, то получаем в ответ True, иначе False. Кроме возврата значения, здесь также определено, что в случае валидного email сразу присваивать его значение (делаем Command) полю Email.

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

    Если воспользоваться принципом CQS и разделить методы на Command и Query, получим следующий код:
    1. public class User
    2. {
    3.     public string Email { get; private set; }
    4.  
    5.     public bool IsEmailValid(string email) // Query
    6.     {
    7.         return Regex.IsMatch("email pattern", email);
    8.     }
    9.  
    10.     public void ChangeEmail(string email) // Command
    11.     {
    12.         if (IsEmailValid(email) == false)
    13.             throw new ArgumentOutOfRangeException(email);
    14.  
    15.         Email = email;
    16.     }
    17. }

    Теперь пользователь нашего класса не увидит никаких изменений состояния при вызове IsEmailValid, он лишь получит результат – валиден ли email или нет. А в случае вызова метода ChangeEmail пользователь явно поменяет состояние объекта.

    В CQS у Query есть одна особенность. Раз Query никак не меняет состояние объекта, то методы типа Query можно хорошо распараллелить, разделяя приходящуюся на операции чтения нагрузку.

    Если CQS оперирует методами, то CQRS поднимается на уровень объектов. Для изменения состояния системы создается класс Command, а для выборки данных – класс Query. Таким образом, мы получаем набор объектов, которые меняют состояние системы, и набор объектов, которые возвращают данные.

    Типовой дизайн системы, где есть UI, бизнес-логика и база данных:

    image

    CQRS говорит, что не надо смешивать объекты Command и Query, нужно их явно выделить. Система, разделенная таким образом, будет выглядеть уже так:

    image

    Разделение, преследуемое CQRS, достигается группированием операций запроса в одном уровне, а команд – в другом. Каждый уровень имеет свою модель данных, свой набор сервисов и создается с применением своей комбинации шаблонов и технологий. Еще важнее, что эти два уровня могут находиться даже в двух разных звеньях (tiers) и оптимизироваться раздельно, никак не затрагивая друг друга.

    Простое понимание того, что команды и запросы являются разными вещами, оказывает глубокое влияние на архитектуру ПО. Например, вдруг становится легче предвидеть и кодировать каждый уровень предметной области. Уровень предметной области (domain layer) в стеке команд нуждается лишь в данных, бизнес-правилах и правилах безопасности для выполнения задач. С другой стороны, уровень предметной области в стеке запросов может быть не сложнее прямого SQL-запроса.

    С чего начать при работе с CQRS?


    1. Стек команд

    В CQRS на стек команд возлагается только выполнение задач, которые модифицируют состояние приложения. Команде присущи следующие свойства:
    • Изменяет состояние системы;
    • Ничего не возвращает;
    • Контекст команды хранит нужные для ее выполнения данные.

    Объявление и использование команды условно можно поделить на 3 части:
    • Класс команды, представляющий собой данные;
    • Класс обработчика команд;
    • Класс с методом или методами, которые принимают команду на вход и вызывают именно тот обработчик, который реализует команду с данным типом.

    Суть команд и запросов заключается в том, что они имеют общий признак, по которому они могут быть объединены. Иначе говоря, у них имеется общий тип. В случае команд это будет выглядеть следующим образом:
    1. public interface ICommand
    2. {
    3. }

    Первым шагом объявляется интерфейс, который, как правило, ничего не содержит. Он будет использоваться как параметр, который может быть получен на стороне сервера непосредственно из пользовательского интерфейса (UI), или же быть сформирован иным образом, для передачи обработчику команды.

    Далее объявляется интерфейс обработчика команды.
    1. public interface ICommandHandler<in TCommand> where TCommand : ICommand
    2. {
    3.     void Execute(TCommand command);
    4. }

    Он содержит лишь 1 метод, принимающий данные с типом интерфейса, объявленным ранее.

    После этого остается определить способ централизованного вызова обработчиков команд в зависимости от конкретного типа переданной команды (ICommand). Эту роль могут выполнять сервисная шина или диспетчер.
    1. public interface ICommandDispatcher
    2. {
    3.     void Execute<TCommand>(TCommand command) where TCommand : ICommand;
    4. }

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

    Пример команды. Допустим, есть интернет-магазин, для него нужно создать команду, которая создаст товар в хранилище. В начале создадим класс, где в его имени указываем то, какое действие производит данная команда.
    1. public class CreateInventoryItem : ICommand
    2. {
    3.     public Guid InventoryItemid { get; }
    4.     public string Name { get; }
    5.  
    6.     public CreateInventoryItem(Guid inventoryItemld, string name)
    7.     {
    8.         InventoryItemId = inventoryItemId;
    9.         Name = name;
    10.     }
    11. }

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

    Реализация обработчика, то есть уже непосредственно самой команды, сводится к довольно простым действиям: создается класс, который реализует интерфейс ICommandHandler. Аргументом типа указывается команда, объявленная ранее.
    1. public class InventoryCommandHandler : ICommandHandler<CreateInventoryItem>
    2. {
    3.     private readonly IRepository<InventoryItem> _repository;
    4.  
    5.     public InventoryCommandHandlers(IRepository<InventoryItem> repository)
    6.     {
    7.         _repository = repository;
    8.     }
    9.  
    10.     public void Execute(CreateInventoryItem message)
    11.     {
    12.         var item = new InventoryItem(message.InventoryItemld, message.Name);
    13.         _repository.Save(item);
    14.     }
    15.  
    16.     // ...
    17. }

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

    Теперь, чтобы вызывать подходящий обработчик команды, нужно создать класс, реализующий интерфейс ICommandDispatcher. В отличие от прошлых двух, данный класс создается единожды и может иметь разные реализации в зависимости от стратегии регистрации и вызова обработчиков команд.
    1. public class CommandDispatcher : ICommandDispatcher
    2. {
    3.     private readonly IDependencyResolver _resolver;
    4.  
    5.     public CommandDispatcher(IDependencyResolver resolver)
    6.     {
    7.         _resolver = resolver;
    8.     }
    9.  
    10.     public void Execute<TCommand>(TCommand command) where TCommand : ICommand
    11.     {
    12.         if (command == null) throw new ArgumentNullException("command");
    13.  
    14.         var handler = _resolver.Resolve<ICommandHandler<TCommand>>();
    15.  
    16.         if (handler == null) throw new CommandHandlerNotFoundException(typeof(TCommand));
    17.  
    18.         handler.Execute(command);
    19.     }
    20. }

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

    2. Стек запросов

    Стек запросов имеет дело только с извлечением данных. Запросы используют модель данных, максимально соответствующую данным, применяемым на презентационном уровне. Вам вряд ли нужны какие-либо бизнес-правила – обычно они применяются к командам, которые изменяют состояние.

    Запросу присущи следующие свойства:
    • Не изменяет состояние системы;
    • Контекст запроса хранит нужные для ее выполнения данные (пейджинг, фильтры и т.п.);
    • Возвращает результат.

    Объявление и использование запросов также можно условно поделить на 3 части:
    • Класс запроса, представляющий собой данные с типом возвращаемого результата;
    • Класс обработчика запросов;
    • Класс с методом или методами, которые принимают запрос на вход и вызывают именно тот обработчик, который реализует запрос с данным типом.

    Как и для команд, для запросов объявляются похожие интерфейсы. Единственное отличие – в них указывается то, что должно быть возвращено.
    1. public interface IQuery<TResult>
    2. {
    3. }

    Здесь в качестве аргумента типа указывается тип возвращаемых данных. Это может быть произвольный тип, например, string или int[].

    После объявляется обработчик запросов, где также указывается тип возвращаемого значения.
    1. public interface IQueryHandler<in TQuery, out TResult> where TQuery : IQuery<TResult>
    2. {
    3.     TResult Execute(TQuery query);
    4. }

    По аналогии с командами объявляется диспетчер для вызова обработчиков запросов.
    1. public interface IQueryDispatcher
    2. {
    3.     TResult Execute<TQuery, TResult>(TQuery query) where TQuery : IQuery<TResult>;
    4. }

    Пример запроса. Допустим, нужно создать запрос, возвращающий пользователей по поисковому критерию. Здесь также с помощью осмысленного имени класса указываем, что за запрос будет производится.
    1. public class FindUsersBySearchTextQuery : IQuery<User[]>
    2. {
    3.     public string SearchText { get; }
    4.     public bool InactiveUsers { get; }
    5.  
    6.     public FindUsersBySearchTextQuery(string searchText, bool inactiveUsers)
    7.     {
    8.         SearchText = searchText;
    9.         InactiveUsers = inactiveUsers;
    10.     }
    11. }

    Далее создаём обработчик, реализующий IQueryHandler с аргументами типа запроса и типа его возвращаемого значения.
    1. public class UserQueryHandler : IQueryHandler<FindUsersBySearchTextQuery, User[]>
    2. {
    3.     private readonly IRepository<User> _repository;
    4.  
    5.     public UserQueryHandler(IRepository<User> repository)
    6.     {
    7.         _repository = repository;
    8.     }
    9.  
    10.     public User[] Execute(FindUsersBySearchTextQuery query)
    11.     {
    12.         var users = _repository.GetAll();
    13.         return users.Where(user => user.Name.Contains(query.SearchText)).ToArray();
    14.     }
    15. }

    После чего остается создать класс для вызова обработчиков запросов.
    1. public class QueryDispatcher : IQueryDispatcher
    2. {
    3.     private readonly IDependencyResolver _resolver;
    4.  
    5.     public QueryDispatcher(IDependencyResolver resolver)
    6.     {
    7.         _resolver = resolver;
    8.     }
    9.  
    10.     public TResult Execute<TQuery, TResult>(TQuery query) where TQuery : IQuery<TResult>
    11.     {
    12.         if (query == null) throw new ArgumentNullException("query");
    13.  
    14.         var handler = _resolver.Resolve<IQueryHandler<TQuery, TResult>>();
    15.  
    16.         if (handler == null) throw new QueryHandlerNotFoundException(typeof(TQuery));
    17.  
    18.         return handler.Execute(query);
    19.     }
    20. }

    Вызов команд и запросов


    Чтобы можно было вызывать команды и запросы, достаточно использовать соответствующие диспетчеры и передать конкретный объект с необходимыми данными. На примере это выглядит следующим образом:
    1. public class UserController : Controller
    2. {
    3.     private IQueryDispatcher _queryDispatcher;
    4.  
    5.     public UserController(IQueryDispatcher queryDispatcher)
    6.     {
    7.        _queryDispatcher = queryDispatcher;
    8.     }
    9.  
    10.     public ActionResult SearchUsers(string searchString)
    11.     {
    12.         var query = new FindUsersBySearchTextQuery(searchString);
    13.  
    14.         User[] users =_queryDispatcher.Execute(query);
    15.  
    16.         return View(users);
    17.     }
    18. }

    Имея контроллер для обработки запросов пользователя, достаточно передать в качестве зависимости объект нужного диспетчера, после чего сформировать объект запроса или команды и передать методу диспетчера Execute.

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

    Регистрация обработчиков

    Регистрировать обработчики можно разными способами. С помощью DI-контейнера можно регистрировать по-отдельности или автоматически просматривая сборку с нужными типами. Второй вариант может выглядеть следующим образом:
    using SimpleInjector;

    var container = new Container();

    container.Register(typeof(ICommandHandler<>), AppDomain.CurrentDomain.GetAssemblies());

    container.Register(typeof(IQueryHandler<,>), AppDomain.CurrentDomain.GetAssemblies());

    Здесь используется контейнер SimpleInjector. Регистрируя обработчики методом Register, первым аргументом указывается тип интерфейсов обработчиков команд и запросов, а вторым – сборка, в которой производится поиск классов, реализующих данные интерфейсы. Тем самым не нужно указывать конкретные обработчики, а лишь только общий интерфейс, что крайне удобно.

    Что если при вызове Command/Query надо проверять права доступа, записать информацию в лог и тому подобное?
    Их централизованный вызов позволяет добавить действия до или после выполнения обработчика, не изменяя ни один из них. Достаточно внести изменения в сам диспетчер, или создать декоратор, который через DI-контейнер подменит исходную реализацию (в документации SimpleInjector довольно хорошо расписаны примеры подобных декораторов).

    image

    Достоинства CQRS
    • Меньше зависимостей в каждом классе;
    • Соблюдается принцип единственной ответственности (SRP);
    • Подходит практически везде;
    • Проще заменить и тестировать;
    • Легче расширяется функциональность.

    Ограничения CQRS
    • При использовании CQRS появляется много мелких классов;
    • При использовании простой реализации CQRS могут возникнуть сложности с использованием группы команд в одной транзакции;
    • Если в Command и Query появляется общая логика, нужно использовать наследование или композицию. Это усложняет дизайн системы, но для опытных разработчиков не является препятствием;
    • Сложно целиком придерживаться CQS и CQRS. Самый простой пример – метод выборки данных из стека. Выборка данных – это Query, но нам надо обязательно поменять состояние и сделать размер стека -1. На практике вы будете искать баланс между жестким следованием принципами и производственной необходимостью;
    • Плохо ложится на CRUD-приложения.

    Где не подходит
    • В небольших приложениях/системах;
    • В преимущественно CRUD-приложениях.

    Заключение


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

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

    Источники
    Блог Александра Бындю — CQRS на практике
    На переднем крае — CQRS для обычного приложения
    Как мы попробовали DDD, CQRS и Event Sourcing и какие выводы сделали
    CQRS Documents by Greg Young
    Simple CQRS example
    DDDD, CQRS and Other Enterprise Development Buzz-words
    SimbirSoft
    93.95
    Лидер в разработке современных ИТ-решений на заказ
    Share post

    Comments 88

      0
      Кроме возврата значения, здесь также определено, что в случае валидного email сразу присваивать его значение (делаем Command) полю Email.

      То есть достаточно назвать метод "TryChangeEmail", который пытается изменить email и возвращает статус изменения, и вашей проблемы с недопониманием что делает метод не будет. Разделение на 2 метода тут не требуется.


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

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

        0

        Запросы лучше делать не из команд, а из process-менеджеров. Для таких транзакций тут не хватает event sourcing.

          +3

          Больше абстракций богу абстракций :-)

          0

          Командам нужно делать в процессе работы запросы к базе, но эти запросы не будут буквой Q в CQRS.

            0

            А смысл дублировать код запросов?

              0

              А дублирования обычно и не происходит.

                0

                Q: найти всех программистов
                C: найти всех программистов и уволить найденных

                  +3

                  Это — плохая команда, поскольку подвержена состоянию гонки. Между Q и C наверняка же кто-то просмотрел список программистов и утвердил — и команда должна исполнить именно утвержденное действие, а не абы какое.


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


                  C: взять сотрудников с id из некоторого списка и уволить найденных

                    +1

                    Вы не фантазируйте про обычно/необычно. Есть бизнес-задача: "Реализовать кнопку быстрого увольнения всех программистов, безо всяких утверждений/подтверждений".


                    Ну или более реалистичный вариант: "Реализовать кнопку быстрой выплаты всем менеджерам зарплаты" :-)

                      +1

                      В такой задаче запрос (Q) вообще не нужен, достаточно одной команды (C).

                        0

                        Реализовать отчёт "список всех менеджеров". Опа, и Q появился. И грех не воспользоваться им из C.

                          0

                          Отчёт — это отчёт, к чему там команды?

                            –3

                            Q: дай список всех менеджеров
                            C: возьми (Q) и выдай им зарплату.

                              +2

                              Если это две стадии одного сценария использования — то команда должна использовать явный список менеджеров, найденных на первом шаге.


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

                                –3

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

                                +1

                                Немного странно) Отчёт в моём понимании — это, грубо говоря, табличка с выводом сгруппированных данных. Тот же вывод на экран счёта с конкретными позициями, например.


                                Если я вас правильно понял, то как в таком случае табличка (т.е. сводка) с менеджерами относится к выплате им зарплаты?

                                  –1

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

                                    0

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

                      0

                      "найти всех программистов" не обязательно выполняются в одном контексте. Разделение Q и C происходит как раз для того, чтобы было удобнее оптимизировать чтение и запись, например читать из быстрого для чтения хранилища (денормализованный nosql), а записывать в стандартную реляционку.

                        0

                        Не воспринимайте query как какой-то промежуточный запрос в базу и серии "найти -> уволить найденных".
                        Query — "Найти всех программистов" для, например отображения в списке в UI.
                        Command — "Уволить всех имеющихся программистов", которая попадает в процесс-менеджер, который идет в сервис/аггрегат и с ним уже может общаться через несколько методов и/или через ES или просто через один готовый метод.

                          0

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

                            +1

                            Зависит от того, где эти программисты лежат — во Write DB или в Read DB.
                            CQRS не решает вопрос переиспользования какого-либо кода, а лишь вопрос масштабируемости.
                            Никто вам не мешает все сваливать в один сервис с одной базой и с методом tryFireEverybody. Когда придется масштабироваться — будете накручивать абстракции.

                              0
                              т.е. останется всего лишь решить «тривиальный» вопрос репликации WriteDB -> ReadDB, чтобы чтение всегда было консистентным?
                                0

                                Да, но мы помним про eventual consistency, как тут уже несколько раз упоминали.

                                  0
                                  Соответственно, в случаях, когда нужно прочитать, то, что только что записали — этот подход не очень хорош.

                                  В этом случае нужно будет бороться с CQRS:
                                  1. ждем, пока база синхронизируется (долгий/очень долгий запрос команды)
                                  2. делаем «синхронизацию» руками (сложный и скользкий путь, с множеством проблем)

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

                                  Правильно?
                                    0

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

                                      +1

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


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


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

                                        +1
                                        обработчики команд могут обновлять данные для чтения, но никогда не читают их.

                                        В случае, когда нужно перед записью данных их прочитать (а нужно практически всегда), то читают из данных для записи

                                        все так

                            0

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

                              0

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

                                +3

                                Если они не отличаются, CQRS не нужен.


                                Смысл его в том, чтобы оптимизировать производительность приложения, выполняя расчеты (трансформации данных) во время записи (а не во время чтения).


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

                                  –3

                                  Никто не мешает "оптимизировать производительность приложения, выполняя расчеты во время записи" и без разделения кода на кучки. От того, что вы разделили код на C и Q предагрегация у вас в коде волшебным образом не появится.

                                    +1

                                    Ну, в общем-то, да. Можно.


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


                                    И разделение кода на кучки — это так само получается, если все это реализовывать.


                                    ПС Разделение кода на C и Q — это, вообще-то, здравая идея, которая называется CQS (command-query separation). Этот принцип говорит, что каждый метод в приложении должен либо возвращать данные, либо их модифицировать, но модификация при чтении — это плохая идея.

                                      –1

                                      Модификация при чтении — это либо говнокод, либо такая бизнес задача (считать число запросов, например). CQ®S тут ни при чём. А вот чтение при модификации — необходимая штука. А корень всех проблем — неидемпотентность. Например, запрос getTime, хотя и ничего не изменяет, но не идемпотентен, поэтому его ни закешировать, ни дёрнуть лишний раз нельзя. А вот команда setUserName(name), хотя и изменяет данные, но идемпотентен, а значит её можно спокойно вызывать сколько угодно раз, получая один и тот же результат, или наоборот, не вызывать, если имя пользователя и так уже равно передаваемому.

                      0
                      То есть достаточно назвать метод «TryChangeEmail», который пытается изменить email и возвращает статус изменения, и вашей проблемы с недопониманием что делает метод не будет. Разделение на 2 метода тут не требуется.

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

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

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

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

                      В CQRS предлагается стремиться к тому, чтобы команда (или запрос) выполняла только строго определенную задачу, а вот сама команда уже может являться частью какой-либо бо́льшей задачи.
                      То есть все необходимые запросы и валидация должны быть сделаны до того, как команда начнет выполняться, и ей должны быть переданы необходимые данные. Тем самым будут иметься отдельные Query, вместо того, чтобы выполнять их в команде.
                        +1
                        И как cqrs предлагает справляться с проблемой консистентности изменений?
                        Как теперь делать создание нового пользователя и получение его id?
                        Как поиск его по имени пользователя? А если кто-то другой это имя пользователя в это же время зарегистрировал, как мы сможем это понять и не начать использовать чужую запись?
                        Как я понимаю, ваша описанная схема вообще не дружит с транзакциями.
                        Можно придумать обходные пути (двухфазный коммит, версионирование), но это уже усложнение, а не упрощение. Подобные усложнения приходится использовать для big data, но это вынужденная мера, а не преимущество подхода.
                        А вот если бы вы разрешили командам одновременно писать и получать данные, а запросам бы запретили писать — то уже стало бы удобнее этим пользоваться.
                          0
                          И как cqrs предлагает справляться с проблемой консистентности изменений?

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

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

                            Чтение в командах нужно хотя бы для того чтобы избежать конфликтов изменений.

                            0

                            В данных для записи консистентность будет строгая (как в обычном приложении). А вот между базами для чтения и для записи будет eventual consistency, то есть они будут согласованы когда-нибудь, но не прямо сейчас. Именно поэтому в обработчиках команд обычно не используется Read DB.

                            +1
                            имеется метод, в котором идет создание нового пользователя, а после этого метод возвращает связанный с ним объект или его Id. Тем самым смешиваются команда и запрос.

                            И как потом найти свежесозданного пользователя, если команда не будет возвращать нам его id?


                            Это сразу отбрасывает возможность асинхронной операции

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


                            Т.к. мы вынуждены возвращать результат сразу после команды, мы не сможем, например, добавить дополнительную БД, которая бы хранила и возвращала данные для запросов

                            Можем. Создаём 2 соединения: из одного читаем, в другое пишем. Оба соединения могут быть легко инкапсулированы в одном субд адаптере, позволяя программисту вообще не думать о том, что у него есть 2 базы.


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

                            image

                              +1
                              И как потом найти свежесозданного пользователя, если команда не будет возвращать нам его id?

                              Генерировать Id перед созданием пользователя, если только это не автоинкремент в БД (возможные проблемы с которым уже не относятся к CQRS).

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

                              Если используется обычный async/await, то да. Если же команда обрабатывается в очереди, то здесь не получится что-либо вернуть.

                              Можем. Создаём 2 соединения: из одного читаем, в другое пишем. Оба соединения могут быть легко инкапсулированы в одном субд адаптере, позволяя программисту вообще не думать о том, что у него есть 2 базы.

                              Если только эти БД обновляются одновременно, но обычно БД для чтения обновляется позже, через какое-то время после изменений в БД для записи, иначе в ней пропадет смысл при её частом обновлении. То есть не получится сразу после создания пользователя получить по нему данные, его просто еще не будет существовать.
                                0
                                Генерировать Id перед созданием пользователя, если только это не автоинкремент в БД

                                Вот абстракции и потекли. Не, я, конечно, за предгенерацию id, но это далеко не всегда возможно.


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

                                Идентификатор элемента очереди. Или вы исповедуете принцип "с моей стороны вылетело, а дальше судьба команды меня не волнует"?


                                То есть не получится сразу после создания пользователя получить по нему данные, его просто еще не будет существовать.

                                Ну так сами себе же создали проблемы :-) Если бы команда сразу же и возвращала данные свежесозданного пользователя или запросы могли сходить в основную базу и заполнить кеш, то таких проблем бы не было.

                                  –1
                                  Идентификатор элемента очереди. Или вы исповедуете принцип "с моей стороны вылетело, а дальше судьба команды меня не волнует"?

                                  Задаете correlation id команде и ждете себе спокойно по каналу чтения событие "пользователь создан" (или что там у вас в бизнес логике прописано) с нужным cid. Получили ивент, достали все нужные данные. Выигрыш тут в прекрасной масштабируемости канала чтения.


                                  Более того, в конечном приложении вы это разделение можете спрятать обратно в модель и отдать один метод "создать пользователя", возвращающий ID. Только внутрянка у вас CQRS+ES.

                                    0
                                    Вот абстракции и потекли. Не, я, конечно, за предгенерацию id, но это далеко не всегда возможно.

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

                                    Идентификатор элемента очереди. Или вы исповедуете принцип «с моей стороны вылетело, а дальше судьба команды меня не волнует»?

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

                                    Ну так сами себе же создали проблемы :-) Если бы команда сразу же и возвращала данные свежесозданного пользователя или запросы могли сходить в основную базу и заполнить кеш, то таких проблем бы не было.

                                    Потому что БД для чтения используется для повышения производительность запросов на чтение, а не записи, в первой ссылке в источниках об этом рассказывается подробнее.
                                    UX меняется в случае использования CQRS с разными БД или Event Sourcing.
                              0
                              Командам как правило необходимо делать запросы в процессе работы. И какой толк в этом случае от разделения команд и запросов, если они всё равно получаются сильно связанными?

                              Если это использовать в связке с DDD — Domain Aggregate, то у агрегата должна быть вся информация для выполнения команды и он не должен делать запросы по ходу этого дела. Единственные запросы у агрегата — сохранение и восстановление своего состояния. При применении eventsourcing там все вообще очень хорошо и красиво абстрагируется.

                              –1

                              Вообще CQRS имеет смысл быть для высоко нагруженных систем.


                              Одна БД для записи информации, 4 БД для чтения информации. Иначе зачем делить логику, как мне кажется это методология или новомодное словечко вечно вырывается из контекста !


                              Хотелось бы узнать у Бертрана Мейера какое у него было железо !


                              https://ru.wikipedia.org/wiki/CQRS
                              На практике, CQRS дает возможность пропустить все проверки утверждений в действующей системе, чтобы повысить её производительность, не боясь того, что это изменит её поведение. CQRS также предотвращает возникновение некоторых гейзенбагов.

                              Как я и говорил для повышения производительности !

                                –3

                                Чтение из одной базы, а запись в другую реализуется одним if-ом. Для этого не нужны никакие CQ®S.

                                  +2
                                  вы тролль?
                                    –2

                                    Ага. Буквально вчера троллил это для файлов:


                                    struct Stream
                                    {
                                        File output;
                                        File input;
                                    
                                        this( string output , string input = output )
                                        {
                                            this.output = File( path , "ab" );
                                            this.input = File( path , "rb" );
                                        }
                                    
                                        auto put( Data )( Data data )
                                        {
                                            auto offset = this.output.tell;
                                            this.output.lockingBinaryWriter.put( data );
                                            return offset;
                                        }
                                    
                                        auto read( Data )( ulong offset )
                                        {
                                            Unqual!Data[1] buffer;
                                            this.input.seek( offset );
                                            this.input.rawRead( buffer );
                                            return cast( Data ) buffer[0];
                                        }
                                    
                                    }

                                    void main()
                                    {
                                        auto stream = Stream( "target.bin" , "source.bin" );
                                        stream.put( 777 );
                                        stream.put( "Hello" );
                                        writeln( stream.read!ulong( 0 ) );
                                    }
                                    0

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

                                      0

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

                                        +1

                                        АПИ CQRS систем — это отражение их eventual consistency. То есть факта, что отправив команду системе, результат нужно получать другим способом.

                                          –1

                                          Представьте себе популярную нынче multi-master репликацию. eventual consistency есть, а cqrs — нет.

                                            0

                                            БД внутри — это как раз cqrs + event sourcing, они журнал операций ведут.

                                              0

                                              Что там у БД внутри — совершенно не важно. Впрочем, нет там никакого cqrs и Event Sourcing. Там есть лишь временный WAL, который периодически чистится, а все запросы что-то возвращают (банально, чтобы не делать лишних лукапов).

                                    +1
                                    CQRS — это про архитектуру системы. И в архитектуру в том числе входит необходимость её поддерживать. Даже без требований к, например, производительности, в некоторых проектах имеет смысл разнести логику на изолированные команды и изолированные запросы только из соображений понятности структуры кода или просто более удобной навигации.
                                      0

                                      Разделение всей логики на 2 кучки вас не сильно спасёт :-)

                                        0
                                        Вот именно «кучек» логики и не будет. А будут изолированные, атомарно (в нестрогом смысле) выполняющиеся сущности. И это уже заявка на спасение.
                                      0

                                      ну да, CQRS нужен для масштабирования на чтение

                                      +2
                                      1. redux очень похож на CQRS+event sourcing. Данные для чтения — это state, данные для записи — это бекэнд. В серверную архитектуру его, конечно, один в один не перенести, но подходы и проблемы очень похожи.


                                      2. В реальных приложениях практически невозможно использовать "чистые" команды, которые не возвращают результата. Нужно будет обрабатывать ошибки, а также получать какие-то минимальные результаты, например, идентификаторы созданных сущностей и т.д.


                                      3. Не стоит использовать CQRS без необходимости, его нужно применять только в самых нагруженных местах (или в системах, которые хорошо на него ложатся, например, основанных на событиях, типа приложений для такси). Реализовывать на нем CRUD — это удовольствие ниже среднего.
                                        0
                                        redux очень похож на CQRS+event sourcing. Данные для чтения — это state, данные для записи — это бекэнд. В серверную архитектуру его, конечно, один в один не перенести, но подходы и проблемы очень похожи.

                                        А redux-saga — process-менеджеры, все верно.

                                          +1
                                          В реальных приложениях практически невозможно использовать "чистые" команды, которые не возвращают результата. Нужно будет обрабатывать ошибки, а также получать какие-то минимальные результаты, например, идентификаторы созданных сущностей и т.д.

                                          не возвращать данные — общепринятое, но не обязательное решение. Об этом в т.ч. упомянул и Грег Янг, назвав это правило самым большим недопониманием парадигмы.

                                            +2
                                            redux очень похож на CQRS+event sourcing. Данные для чтения — это state, данные для записи — это бекэнд. В серверную архитектуру его, конечно, один в один не перенести, но подходы и проблемы очень похожи.

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

                                              0

                                              мы реализовывали эту связку в своем биллинге. И удовольствия, действительно, никакого. Поменялся ивент (а в базе уже лежит пачка ивентов старой версии) — нужно писать адаптеры для того, чтоб аггрегат мог в принципе развернуться, иначе весь прод лежит. В общем, не сказал бы, что store в redux очень похож на eventsourcing, все таки на фронте состояние хранится относительно кратковременно и с такими проблемами наверняка не сталкиваются)

                                                0

                                                А в ином варианте вы будете проводить миграцию базы. Принципиального отличия нет. Кроме того, в случае event sourcing часто используется гибридный подход с хранением snapshot'а на некоторый момент и event'ов после снэпа. В той же Akka есть готовые куски для реализации такого подхода и обкатанные best practice.

                                              +4
                                              Всё хорошо, только вот пример с методом «IsEmailValid» крайне неудачный. В принципе так нельзя называть метод, который изменяет состояние, какой подход вы бы не использовали.
                                                –1

                                                Добавьте в систему события (не путать с event-sourcing) и вы получите отличную архитектуру для микросервисов.

                                                  –1
                                                  Приведу пример:
                                                  // contract.dll
                                                  public class RegisterUser : ICommand { ... }
                                                  public class UserRegistered : IEvent { ... }
                                                  public class SendEmail : ICommand { ... }
                                                  
                                                  public class UserData { ... }
                                                  public class GetUserById : IQuery<UserData> { ... }
                                                  
                                                  // api.exe
                                                  public class UserController
                                                  {
                                                    private IDispatcher _dispatcher;
                                                  
                                                    public void Register(RegisterUserModel model)
                                                    {
                                                      _dispatcher.Execute(new RegisterUser { ... });
                                                    }
                                                  }
                                                  
                                                  // domain.exe
                                                  public class User { ... }
                                                  public interface IUserRepository { ... }
                                                  
                                                  public class UserDomainHandler : ICommandHandler<RegisterUser>, IQueryHandler<GetUserById, UserData>
                                                  {
                                                    private IDispatcher _dispatcher;
                                                    private IUserRepository _repository;
                                                  
                                                    public void Handle(RegisterUser command)
                                                    {
                                                      var user = new User(...);
                                                  
                                                      _repository.Insert(user);
                                                  
                                                      _dispatcher.Publish(new UserRegistered { ... });
                                                    }
                                                  
                                                    public UserData Handle(GetUserById query)
                                                    {
                                                      var user = _repository.GetById(...);
                                                  
                                                      return new UserData { ... };
                                                    }
                                                  
                                                  // email.exe
                                                  public interface IEmailService { ... }
                                                  
                                                  public class UserEmailHandler : IEventHandler<UserRegistered>
                                                  {
                                                    private IDispatcher _dispatcher;
                                                  
                                                    public void Handle(UserRegistered event)
                                                    {
                                                      var userData = _dispatcher.Query(new GetUserById { ... });
                                                  
                                                      _dispatcher.Execute(new SendEmail { ... });
                                                    }
                                                  }
                                                  public class CommonEmailHandler : ICommandHandler<SendEmail>
                                                  {
                                                    private IEmailService _emailService;
                                                  
                                                    public void Handle(SendEmail command)
                                                    {
                                                      _emailService.SendEmail(...);
                                                    }
                                                  }
                                                  

                                                  Все три процесса независимы и легко маштабируются. Соединяется всё через AMQP (например RabbitMQ).
                                                    –1
                                                    или ещё один пример:
                                                    // contract.dll
                                                    public class ProductData { ... }
                                                    
                                                    public class CreateProduct : ICommand { ... }
                                                    public class ProductCreated : IEvent { ... }
                                                    public class GetProductById : IQuery<ProductData> { ... }
                                                    public class SearchProducts : IQuery<List<ProductData>> { ... }
                                                    
                                                    // api.exe
                                                    public class ProductController
                                                    {
                                                      private IDispatcher _dispatcher;
                                                    
                                                      public void Create(CreateProductModel model)
                                                      {
                                                        _dispatcher.Execute(new CreateProduct { ... });
                                                      }
                                                    
                                                      public ProductModel Get(int id)
                                                      {
                                                        var productData = _dispatcher.Query(new GetProductById { ... });
                                                    
                                                        return new ProductModel { ... };
                                                      }
                                                    
                                                      public List<ProductModel> Search(string query)
                                                      {
                                                        var productsData = _dispatcher.Query(new SearchProducts { ... });
                                                    
                                                        return new List<ProductModel>(...)
                                                      }
                                                    }
                                                    
                                                    // domain.exe
                                                    public class ProductDomainHandler : ICommandHandler<CreateProduct>, IQueryHandler<GetProductById, ProductData>
                                                    {
                                                      private IDispatcher _dispatcher;
                                                      private IProductRepository _repository;
                                                      
                                                      public void Handle(CreateProduct command)
                                                      {
                                                        var product = new Product(...);
                                                    
                                                        _repository.Insert(product);
                                                    
                                                        _dispatcher.Publish(new ProductCreated { ... });
                                                      }
                                                    
                                                      public ProductData Handle(GetProductById query)
                                                      {
                                                        var product  = _repository.GetById(...);
                                                    
                                                        return new ProductData { ... };
                                                      }
                                                    }
                                                    
                                                    // index.exe
                                                    public class ProductIndexHandler : IEventHandler<ProductCreated>, IQueryHandler<SearchProducts, List<ProductData>>
                                                    {
                                                      private IDispatcher _dispatcher;
                                                      private IElasticClient _client;
                                                    
                                                      public void Handle(ProductCreated event)
                                                      {
                                                        var productData = _dispatcher.Query(new GetProductById { ... });
                                                    
                                                        _client.Index(productData);
                                                      }
                                                    
                                                      public List<ProductData> Search(SearchProducts query)
                                                      {
                                                        var data = _client.Search(...);
                                                    
                                                        return data;
                                                      }
                                                    }
                                                    

                                                    И что самое интересное, вам даже не обязательно разносить по разным процессам, просто переименуйте в domain.dll и index.dll и запускайте внутри одного процесса, минуя AMQP.
                                                    0
                                                    а как быть с асинхронными запросами?
                                                    Это и не команда, которая ничего не возвращает, и не запрос, который вернет «здесь и сейчас»?
                                                      0
                                                      Например?
                                                        0

                                                        например, запросить примерно это


                                                        Task<TResult> GetSomething(какие-то параметры запроса)

                                                        результат вернется, но не сразу

                                                          +1

                                                          Так в сущности-то запрос остаётся запросом, асинхронен он или нет. Для асинхронных нужно будет использовать что-то вроде async/await.

                                                            –1

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


                                                            Но у меня не браузер, а программа для Windows, но все равно есть операции, когда результат придет, но ОЧЕНЬ далеко не сразу. А поток приложения как понятно блокировать никак нельзя — пользователь не поймет :)

                                                              0

                                                              Ну, как я понимаю, это решается либо отдельными потоками, либо серверами очередей типа RabbitMQ и ему подобными.

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

                                                                Данные в базе должны уже лежать максимально приближённые к тем, которые запрашивает UI. Если обработка занимает от 30сек, значит, скорей всего, Вы что-то делаете не так.
                                                                  0

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

                                                                    0
                                                                    Ну я понимаю, из-за этого и написал «скорей всего», но вообще не представляю такой кейс если данные лежат уже в готовом виде. Просто если хранить данные в базе для чтения в таком же формате как и в базе для записи, то почему бы просто не использовать master-slave? Вообще почему никто не задумывается о неком гибриде подходов. Т.е. у нас есть некая РСУБД которая используется не просто для записи, а полностью для работы команд. Базы для чтения используются только по запросам из слоя UI. Решается вопрос с консистентностью на уровне команд, которая так важна.

                                                                    Допустим есть баланс пользователя. И 2 страницы: Зачислить/Снять средства и страница транзакций.
                                                                    Команда зачисления/снятия работает с актуальной версией данных, и если всё успешно, то создаёт событие, которые передаются по шине и обрабатываются в дальнейшем на сервисах которые отвечают за состояние баз для чтения. Т.е. одна транзакция в БД для записи это одно событие. События пишем что бы потом накатывать данные для новых Query.
                                                                    Некий псевдокод такой команды:
                                                                    // Первый аргумент - обновление рабочей базы. Все запросы в рамках одной транзакции.
                                                                    // Второй аргумент - список событий которые генерируются если транзакция не откатилась
                                                                    doQuery(function($queue) {
                                                                        // Первой аргумент - запрос, второй аргумент - число ожидаемых затронутых строк
                                                                        $queue->add('UPDATE Account SET balance = balance - 20 WHERE user_id = 1 AND balance >= 20', 1)
                                                                    }, function($queue) {
                                                                        $queue->add(UserChangeBalance(1, -20));
                                                                    })

                                                                    Такой псевдокод на выходе генерирует некий SQL:
                                                                    START TRANSACTION;
                                                                    UPDATE Account SET balance = balance - 20 WHERE user_id = 1 AND balance >= 20;
                                                                    SELECT ROW_COUNT() INTO @affected_rows_1;
                                                                    IF (affected_rows_1 == 1) THEN
                                                                        INSERT INTO events ...
                                                                        COMMIT;
                                                                    ELSE
                                                                        ROLLBACK;
                                                                    END IF
                                                                    


                                                                    Если коммит был успешным, то мы отправляем событие UserChangeBalance на шину, там его подхватывает сервис который пишет в БД для Query «Получить историю транзакций», который используется на странице транзакций.

                                                                    Реализация выглядет ужасно конечно, но это то что быстро пришло в голову, если допилить, я думаю такой подход мог бы лечь и на CRUD приложения.
                                                            +2
                                                            Тогда может использоваться async/await, в этом случае метод интерфейса IQueryHandler должен будет возвращать Task<>. Для браузера же особой разницы не будет, т.к. он всё так же будет ожидать ответа.

                                                            public interface IQueryHandler<in TQuery, out TResult> where TQuery : IQuery<TResult>
                                                            {
                                                                Task<TResult> Execute(TQuery query);
                                                            }
                                                            

                                                            public async Task<TResult> Execute(...)
                                                            {
                                                                var result = await GetSomething(...);
                                                                
                                                                return result;
                                                            }
                                                            
                                                        –1

                                                        public ActionResult SearchUsers(string searchString)
                                                        {
                                                        var query = new FindUsersBySearchTextQuery(searchString);


                                                            User[] users =_queryDispatcher.Execute(query);
                                                        
                                                            return View(users);
                                                        }

                                                        Достоинства CQRS
                                                        Меньше зависимостей в каждом классе;


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

                                                          –1

                                                          если минусуете — делайте это аргументированно )

                                                            +1

                                                            FindUsersBySearchTextQuery — это DTO, объект с данными без поведения; у него никогда не появится своих зависимостей. Такие объекты создавать через new можно и нужно.

                                                              +1
                                                              Во-первых мы прибегаем к антипаттерну диктатор (по Марку Симену), когда зависимости мы создаем напрямую через new. На мой взгляд было бы уместнее использовать что-то вроде абстрактной фабрики запросов

                                                              Теперь мы вынуждены зависеть от фабрики, да и как она будет создавать объекты запросов/команд? Неужели new так плох?

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

                                                              Диспетчер делегирует вызов обработчиков по переданному сообщению, используется ли в нём Service Locator или нет — это не важно, он оперирует только определенным списком обработчиков. По этому поводу есть довольно интересная статья (англ.).

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

                                                              Это хоть и допустимо, но не эффективно, т.к. стоит учитывать, что количество обработчиков может быть неопределенным, тем более если речь идет о рефакторинге.
                                                                0

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

                                                                  0
                                                                  И тогда возникают следующие вопросы:

                                                                  1. Какую ответственность покажет фабрика? (Да и зачем для достаточно простых DTO фабрика?) То, что в контроллере используются команды/запросы? А из переданного диспетчера это будет не ясно?

                                                                  2. Чем передача множества обработчиков вместе с возможными другими зависимостями, которые не относятся к CQRS, упростит чтение контроллера. Может, наоборот — усложнит? (Не считая того, что если контроллер содержит слишком много зависимостей, то, возможно, он делает слишком много, и стоит выделить дополнительный контроллер под более конкретные задачи, или же объединить часть зависимостей в одном высокоуровневом компоненте (вроде того же диспетчера)).

                                                                  3. Из методов и названия контроллера не будет ясно, какие действия он производит?
                                                                    –1

                                                                    1) в первую очередь она просто возьмет на себя ответственность за создание запросов/команд и в этом случае будет единственной точка входа для этого. Что полезно
                                                                    2) Вот говорится как раз о моменте, описанном в скобочках. Передача конкретных обработчиков в конструкторе позволит контролировать сложность класса и вовремя понять, что начинает становиться God-объектом и разделить. При использовании диспетчера это не ясно
                                                                    3) Это point-of-failure. Если Вы сами пишете эти методы — некоторое время можно будет рассчитывать на то, что название методов адекватно отражает происходящее в них (а по прошествии некоторого времени — уже нет)
                                                                    В ином же случае помимо названий методов (которые легко посмотреть в студии) придется уже пересматривать эти методы для того, чтоб понять что именно там происходит

                                                                      0
                                                                      Тут уже больше зависит от вас. Также эта статья может помочь пролить свет на вопрос об использовании диспетчера/шины.
                                                                  0
                                                                  На моем проекте произошел примерно такой рефакторинг как описан: запросы на чтение были отделены на команды меняющие данные. Один из самых весомых профитов: запросы на чтение можно оптимизировать как угодно сильно, уменьшая время ответа никак не влияя на команды.
                                                                  Из интересного, что вылезло.
                                                                  Вызов обработчика команды скрыли за одним классом, который собирал все обработчики и по переданной ему в обработку команде определял подходящий обработчик. Интересные моменты стали вылазить позже. Система довольно сложная и количество обработчиков выросло до нескольких сотен. Они сами по себе довольно простые все с минимумом логики (большая часть бизнес-логики хранится в моделях), но выбор подходящего занимает время (речь о 50-100 милисекундах, но иногда это может быть критично. В итоге пришли к тому, что можно было обойтись и без Service Locator, по причине:
                                                                  1. При вызове обработки команды и так всегда известно какой обработчик должен ее обрабатывать. Нет необходимости скрывать выбор обработчика, потому что никакой логики выбора там нет.
                                                                  2. Реализация Service Locator может содержать ошибки, которые как раз всплыли. Это человеческий фактор и он сыграл.
                                                                  3. Выбор обработчика команды занимает время, которое можно было бы сэкономить.

                                                              Only users with full accounts can post comments. Log in, please.