CQRS. Факты и заблуждения (2024)

CQRS. Факты и заблуждения (1)

CQRS — это стиль архитектуры, в котором операции чтения отделены от операций записи. Подход сформулировал Грег Янг на основе принципа CQS, предложенного Бертраном Мейером. Чаще всего (но не всегда) CQRS реализуется в ограниченных контекстах (bounded context) приложений, проектируемых на основе DDD. Одна из естественных причин развития CQRS — не симметричное распределение нагрузки и сложности бизнес-логики на read и write — подсистемы Большинство бизнес-правил и сложных проверок находится во write — подсистеме. При этом читают данные зачастую в разы чаще, чем изменяют.

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

От ICommand к ICommandHandler

Многие начинают реализацию CQRS с применения паттерна «команда», совмещая данные и поведение в одном классе.

public class PayOrderCommand{ public int OrderId { get; set; } public void Execute() { //... }}

Это усложняет сериализацию / десериализацию команд и внедрение зависимостей.

public class PayOrderCommand{ public int OrderId { get; set; } public PayOrderCommand(IUnitOfWork unitOfWork) { // WAT? } public void Execute() { //... }}

Поэтому, оригинальную команду делят на «данные» — DTO и поведение «обработчик команды». Таким образом сама «команда» больше не содержит зависимостей и может быть использована как Parameter Object, в т.ч. в качестве аргумента контроллера.

public interface ICommandHandler<T>{ public void Handle(T command) { //... }}public class PayOrderCommand{ public int OrderId { get; set; }}public class PayOrderCommandHandler: ICommandHandler<PayOrderCommand>{ public void Handle(PayOrderCommand command) { //... }}

Если вы хотите использовать сущности, а не их Id в командах, чтобы не заниматься валидацией внутри обработчиков, можно переопределить Model Binding, хотя этот подход сопряжен с недостатками. Чуть позже мы рассмотрим, как вынести валидацию, не меняя стандартный Model Binidng.

ICommandHandler должен всегда возвращать void?

Обработчики не занимаются чтением, для этого есть read — подсистема и часть Query, поэтому всегда должны возвращать void. Но как быть с Id, генерируемыми БД? Например, мы отправили команду «оформить заказ». Номеру заказа соответствует его Id из БД. Id нельзя получить, пока запрос INSERT не выполнен. Чего только не придумают люди, что обойти это выдуманное ограничение:

  1. Последовательный вызов CreateOrderCommandHandler, а затем IdentityQueryHandler<Order&gt
  2. Out — параметры
  3. Добавление в команду специального свойства для возвращаемого значения
  4. События
  5. Отказ от автоинкрементных Id в пользу Guid. Guid приходи в теле команды и записывается в БД

Хорошо, а как быть с валидацией, которую невозможно провести без запроса к БД, например, наличие в БД сущности с заданным Id или состояние счета клиента? Здесь все просто. Чаще всего просто выбрасывают исключение, несмотря на то, что ничего «исключительного» в валидации нет.

Грег Янг четко обозначает свою позицию по этому вопросу (25 минута): «Должен ли обработчик команды всегда возвращать void? Нет, список ошибок или исключение может быть результатом выполнения». Обработчик может возвращать результат выполнения операции. Он не должен заниматься работой Query — поиском данных, что не значит, что он не может возвращать значение. Главным ограничением на этот счет являются ваши требования к системе и необходимость использования асинхронной модели взаимодействия. Если вы точно знаете, что команда не будет выполнена синхронно, а вместо этого попадет в очередь и будет обработана позже, не рассчитывайте получить Id в контексте HTTP-запроса. Вы можете получить Guid операции и опрашивать статус, предоставить callback или получить ответ по web sockets. В любом случае, void или не void в обработчике – меньшая из ваших проблем. Асинхронная модель заставит изменить весь пользовательский опыт, включая интерфейс (посмотрите, как выглядит поиск авиабилетов на Ozon или Aviasales).

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

На всякий случай, на одном из DotNext я спросил мнение Дино Эспозито по этому поводу. Он согласен с Янгом: обработчик может возвращать ответ. Это может быть не void, но это должен быть результат операции, а не данные из БД. CQRS – это высокоуровневый концепт, дающий выигрыш в некоторых ситуациях (разные требования к read и write подсистемам), а не догма.

Грань между void и не void еще менее заметна в F#. Значению void в F# соответствует тип Unit. Unit в функциональных языках программирования – своеобразный синглтон без значений. Таким образом разница между void и не void обусловлена технической реализацией, а не абстракцией. Подробнее о void и unit можно прочесть в блоге Марка Симана

А что с Query?

Query в CQRS чем-то может напомнить Query Object. Однако, на деле это разные абстракции. Query Object – специализированный паттерн для формирования SQL c помощью объектной модели. В .NET с появлением LINQ и Expression Trees паттерн утратил свою актуальность. Query в CQRS — это запрос на получение данных в удобном для клиента виде.

По аналогии с Command CommandHandler логично разделить Query и QueryHandler. И в данном случае QueryHandler уже действительно не может возвращать void. Если по запросу ничего не найдено, мы можем вернуть null или использовать Special Case.

Но в чем тогда принципиальная разница между CommandHandler<TIn, TOut> и QueryHandler<TIn, TOut>? Их сигнатуры одинаковы. Ответ все тот же. Разница в семантике. QueryHandler возвращает данные и не меняет состояние системы. CommandHandler, наоборот меняет состояние и, возможно, возвращает статус операции.

Если одной семантики вам мало, можно внести такие изменения в интерфейс:

public interface IQuery<TResult>{} public interface IQueryHandler<TQuery, TResult> where TQuery : IQuery<TResult>{ TResult Handle(TQuery query);}

Тип TResult дополнительно подчеркивает, что у запроса есть возвращаемое значение и даже связывает его с ним. Эту реализацию я подсмотрел в блоге разработчика Simple Injector'а и соавтора книги Dependency Injection in .NET Стивена ван Дейрсена. В своей реализации мы ограничились заменой названия метода с Handle на Ask, чтобы сразу видеть на экране IDE, что выполняется запрос без необходимости уточнять тип объекта.

public interface IQueryHandler<TQuery, TResult>{ TResult Ask(TQuery query);}

А нужны ли другие интерфейсы?

В какой-то момент может показаться, что все остальные интерфейсы доступа к данным можно сдать в утиль. Берем несколько QueryHandler'ов, собираем из них хендлер по больше, из них еще больше и так далее. Компоновать QueryHandler'ы имеет смысл только если у вас существуют отдельно use case'ы A и B и вам нужен еще use case, который вернет данные A + B без дополнительных преобразований. По типу возвращаемого значения не всегда очевидно, что вернет QueryHandler. Поэтому легко запутаться в интерфейсах с разными generic-параметрами. Кроме того C# бывает многословным.

public class SomeComplexQueryHandler{ IQueryHandler<FindUsersQuery, IQueryable<UserInfo>> findUsers; IQueryHandler<GetUsersByRolesQuery, IEnumerable<User>> getUsers; IQueryHandler<GetHighUsageUsersQuery, IEnumerable<UserInfo>> getHighUsage; public SomeComplexQueryHandler( IQueryHandler<FindUsersQuery, IQueryable<UserInfo>> findUsers, IQueryHandler<GetUsersByRolesQuery, IEnumerable<User>> getUsers, IQueryHandler<GetHighUsageUsersQuery, IEnumerable<UserInfo>> getHighUsage) { this.findUsers = findUsers; this.getUsers = getUsers; this.getHighUsage = getHighUsage; }}

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

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

Можно ли write-подсистеме использовать read-подсистему и наоборот?

Еще один догмат – никогда нельзя перемешивать write и read – подсистемы. Строго говоря, здесь все верно. Если вам захотелось получить данные из QueryHandler внутри обработчика команды, скорее всего это значит, что CQRS в данной подсистеме не нужен. CQRS решает конкретную проблему: read — подсистема не справляется с нагрузками.

Одним из самых популярных вопросов в DDD-группе до недавнего времени был: «Мы используем DDD и у нас тут есть годовой отчет. Когда мы пытаемся его построить наш слой бизнес-логике поднимает в оперативную память агрегаты и оперативная память заканчивается. Как нам быть?». Ясно как: написать оптимизированный SQL-запрос вручную. Это же касается посещаемых веб-ресурсов. Нет нужды поднимать все ООП-великолепие, чтобы получить данные, закешировать и отобразить. CQRS – предлагает отличный водораздел: в обработчиках команд мы используем доменную логику, потому что команд не так много и потому что мы хотим, чтобы были выполнены все проверки бизнес-правил. В read — подсистеме, наоборот, желательно обойти слой бизнес-логики, потому что он тормозит.

Смешивая read и write подсистемы, мы теряем водораздел. Смысл семантической абстракции теряется даже на уровне одного хранилища. В случае, когда read — подсистема использует другое хранилище данных, вообще нет гарантии, что система находится в согласованном состоянии. Раз актуальность данных не гарантирована, теряется смысл проверок бизнес-слоя. Использование write — подсистемы в read — подсистеме вообще противоречит смыслу операции: команды по определению меняют состояние системы, а query – нет.

У каждого правила, впрочем, есть исключения. В том же видео минутой раньше Грег приводит пример: «вам требуется загрузить миллионы сущностей, чтобы сделать расчет. Вы будете грузить все эти данные в оперативную память или выполните оптимальный запрос?». Если в read — подсистеме уже есть подходящий query handler и вы используете один источник данных никто не посадит вас в тюрьму за вызов query из обработчика команды. Просто держите в голове аргументы против этого.

Возвращать из QueryHandler сущности или DTO?

DTO. Если клиенту требуется весь агрегат из БД что-то не так с клиентом. Более того, обычно требуются максимально плоские данные. Вы можете начать используя LINQ и Queryable Extensions или Mapster на этапе прототипирования. И по необходимости заменять реализации QueryHandler на Dapper и / или другое хранилище данных. В Simple Injector есть удобный механизм: можно зарегистрировать все объекты, реализующие интерфейсы открытых дженериков из сборки, а для остальных оставить fallback с LINQ. Один раз написав такую конфигурацию не придется ее редактировать. Достаточно добавить в сборку новую реализацию и контейнер автоматом подхватит. Для других дженериков будет продолжать работать фолбек на LINQ-реализацию. Mapster, кстати не требует создавать профайлы для маппинга. Если вы соблюдаете соглашения в названиях свойств между Entity и Dto проекции будут строиться автоматом.

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

CommandHandler и QueryHandler — холистические абстракции

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

Cross Cutting Concerns и декораторы

У CQRS есть одно большое преимущество над стандартной сервисной архитектурой: у нас всего 2 generic-интерфейса. Это позволяет многократно повысить полезность шаблона «декоратор». Есть ряд функций, необходимых любому приложению, но не являющихся бизнес-логикой в прямом смысле: логирование, обработка ошибок, транзакционность и т.п. Традиционно варианта два:

  1. смириться и замусоривать бизнес-логику такими зависимостями и сопутствующим кодом
  2. посмотреть в сторону АОП: с помощью интерцепторов в runtime, например Castle.Dynamic Proxy или переписывая IL на этапе компиляции, например PostSharp

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

Помните, я обещал решить проблему валидацией входных параметров без изменения ModelBinder’а? Вот и ответ, реализуйте декоратор для валидации. Если вас устраивает использование исключений, то выбросите ValidationExcepton.

public class ValidationQueryHandlerDecorator<TQuery, TResult> : IQueryHandler<TQuery, TResult> where TQuery : IQuery<TResult>{ private readonly IQueryHandler<TQuery, TResult> decorated; public ValidationQueryHandlerDecorator(IQueryHandler<TQuery, TResult> decorated) { this.decorated = decorated; } public TResult Handle(TQuery query) { var validationContext = new ValidationContext(query, null, null); Validator.ValidateObject(query, validationContext, validateAllProperties: true); return this.decorated.Handle(query); }}

Если нет, — можно сделать небольшую оберточку и использовать Result в качестве возвращаемого значения.

 public class ResultQueryHandler<TSource, TDestination> : IQueryHandler<TSource, Result<TDestination>> { private readonly IQueryHandler<TSource, TDestination> _queryHandler; public ResultQueryHandler(IQueryHandler<TSource, TDestination> queryHandler) { _queryHandler = queryHandler; } public Result<TDestination> Ask(TSource param) => Result.Succeed(_queryHandler.Ask(param)); }

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

Есть определенное неудобство с двумя интерфейсами IQueryHandler и ICommandHandler. Если мы хотим включить логирование или валидацию в обеих подсистемах, то придется написать два декоратора, с одинаковым кодом. Что-ж, это не типичная ситуация. В read-подсистеме, вряд ли потребуется транзакционность. Тем не менее, примеры с валидацией и логированием вполне себе жизненные. Можно решить эту проблему перейдя от интерфейсов к делегатам.

public abstract class ResultCommandQueryHandlerDecorator<TSource, TDestination> : IQueryHandler<TSource, Result<TDestination>> , ICommandHandler<TSource, Result<TDestination>> { private readonly Func<TSource, Result<TDestination>> _func; // Хендлеры превращаются в элегантные делегаты protected ResultCommandQueryCommandHandlerDecorator( Func<TSource, Result<TDestination>> func) { _func = func; } // Для Query protected ResultCommandQueryCommandHandlerDecorator( IQueryHandler<TSource, Result<TDestination>> query) : this(query.Ask) { } // Для Command protected ResultCommandQueryCommandHandlerDecorator( ICommandHandler<TSource, Result<TDestination>> query) : this(query.Handle) { } protected abstract Result<TDestination> Decorate( Func<TSource, Result<TDestination>> func, TSource value); public Result<TDestination> Ask(TSource param) => Decorate(_func, param); public Result<TDestination> Handle(TSource command) => Decorate(_func, command); }

Да, в этом случае тоже есть небольшой оверхед: придется объявить два класса только для кастинга передаваемого в конструктор параметра. Это тоже можно решить путем усложнения конфигурации IOC-контейнера, но мне проще объявить два класса.

Альтернативный вариант — использовать интерфейс IRequestHandler для Command и Query, а чтобы не путаться использовать naming convention. Такой подход реализован в библиотеке MediatR.

CQRS. Факты и заблуждения (2024)

FAQs

What is the CQRS command pattern? ›

The command query responsibility segregation (CQRS) pattern separates the data mutation, or the command part of a system, from the query part. You can use the CQRS pattern to separate updates and queries if they have different requirements for throughput, latency, or consistency.

What is the difference between CQS and CQRs? ›

CQRS takes the defining principle of CQS and extends it to specific objects within a system, one retrieving data and one modifying data. CQRS is the broader architectural pattern, and CQS is the general principle of behaviour.

What is the CQRS pattern in C#? ›

The CQRS (Command Query Responsibility Segregation) pattern is a design pattern that separates the command (write) and query (read) operations of a system, allowing each operation to be optimized separately.

What is the architectural pattern CQS CQRS? ›

There is a strong relation between CQS and CQRS: basically, CQRS raises the CQS pattern to a higher architecture layer, embracing the separation between Commands and Queries at the entire application level - meaning that we want to separate Queries from Commands at an architectural level in the same way we typically ...

When should you avoid CQRS? ›

CQRS is particularly not suitable for small applications that do not require a high degree of scalability and that do not have complex domain logic, and for applications that have a direct impact on life or health, CQRS is not or only to a very limited extent suitable.

Is CQRS good for microservices? ›

Advantages of CQRS Design Pattern in Microservices

Independent Scaling: Command and query services can be scaled independently based on the workload they handle. This allows for better resource allocation and improved performance as each service can be optimized for its specific responsibilities.

Is CQRS pattern good? ›

Benefits of CQRS include: Independent scaling. CQRS allows the read and write workloads to scale independently, and may result in fewer lock contentions.

What problem does CQRS solve? ›

CQRS (command query responsibility segregation) is a programming design and architectural pattern that treats retrieving data and changing data differently. CQRS uses command handlers to simplify the query process and hide complex, multisystem changes.

When to use CQRS vs crud? ›

The report shows that when it comes to an event driven system using event sourcing, CQRS is recommended. Reason being CQRS is more compatible with events then CRUD. CRUD is more designed around data driven design and therefor is a better fit for other systems.

What is the difference between command and query in CQRS? ›

CQRS architecture pattern

In its simplest form, a command is an operation that changes the state of the application. And, a query is an operation that reads the state of the application. In an application, data is represented using models.

Is CQRS a framework? ›

A lightweight, opinionated CQRS and event sourcing framework targeting serverless architectures. Command Query Responsibility Segregation (CQRS) is a pattern in Domain Driven Design that uses separate write and read models for application objects and interconnects them with events.

What is CQRS in Kafka? ›

The solution to this problem is Command Query Responsibility Segregation (CQRS), which performs computations when the data is written, not when it's read. This way, each computation is performed only once, no matter how many times the data is read in the future.

What is the difference between MVC and CQRS pattern? ›

MVC is about routing user actions from a view and returning data. CQRS is a data access pattern to avoid side effects in queries and provide a simple scaling solution.

What is the principle of CQRS? ›

It is an intention to change something and doesn't return a value, only an indication of success or failure. And, a query is a request for information that doesn't change the system's state or cause any side effects. The core principle of CQRS is the separation of commands and queries.

What is the difference between CQRS and saga pattern? ›

The SAGA pattern is a failure management pattern that brings consistency in distributed applications and coordinates transactions between various microservices to maintain consistency. Command and Query Responsibility Segregation or CQRS is a pattern that separates read and update operations for a data store.

What problem does CQRS pattern solve? ›

CQRS stands for Command and Query Responsibility Segregation, a pattern that separates read and update operations for a data store. Implementing CQRS in your application can maximize its performance, scalability, and security.

What is the difference between CRUD and CQRS? ›

Command query responsibility segregation (CQRS) and create, read, update, and delete (CRUD) are well-known application data management architectures. Both systems are designed for the streamlined management of information. However, they differ in implementation, operations, use cases, benefits, and challenges.

What is the pattern of CQRS in Python? ›

In summary, this pattern provides a way to organize code so that business logic is encapsulated, but separated from the underlying delivery mechanism. This allows for better maintenance and fewer dependencies. With CQRS, we have a clear separation of concerns, making the codebase easier to understand and maintain.

Top Articles
Latest Posts
Article information

Author: Arielle Torp

Last Updated:

Views: 6598

Rating: 4 / 5 (61 voted)

Reviews: 84% of readers found this page helpful

Author information

Name: Arielle Torp

Birthday: 1997-09-20

Address: 87313 Erdman Vista, North Dustinborough, WA 37563

Phone: +97216742823598

Job: Central Technology Officer

Hobby: Taekwondo, Macrame, Foreign language learning, Kite flying, Cooking, Skiing, Computer programming

Introduction: My name is Arielle Torp, I am a comfortable, kind, zealous, lovely, jolly, colorful, adventurous person who loves writing and wants to share my knowledge and understanding with you.