Паттерн CQRS: теория и практика в рамках ASP.Net Core 5

Скорость разработки и производительность программистов могут отличаться в зависимости от их уровня и используемых в проектах технологий. Для проектирования ПО нет стандартов и ГОСТов, только вы выбираете, как будете разрабатывать свою программу. Один из лучших способов повысить эффективность работы — применить шаблон проектирования CQRS. 

Существует три вида паттерна CQRS: Regular, Progressive и Deluxe. В этой статье я расскажу о первом — классическом паттерне Regular CQRS, который мы используем в DD Planet в рамках разработки онлайн-сервиса «Выберу.ру». Progressive и Deluxe — более сложные архитектуры и влекут за собой использование обширного набора абстракций.

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

Классический Onion

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

Классическая «луковая» архитектура состоит из нескольких слоев:

  1. Доменный слой — наши сущности и классы.

  2. Слой бизнес-логики, где происходит вся обработка доменной логики.

  3. Слой приложения — логика самого приложения.

  4. Внешние слои: слой UI, базы данных или тестов.

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

Так произошло с нашим сайтом «Выберу.ру». Мы получили спагетти-код, в котором связанность была на очень высоком уровне. Новые разработчики приходили в шок, когда его видели. Самое страшное, что могло случиться — введение нового сотрудника в приложение. Объяснить, что и почему, казалось просто невозможным.

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

CQRS

Определение и задачи 

CQRS (Command Query Responsibility Segregation)— это шаблон проектирования, который разделяет операции на две категории:

  • команды— изменяют состояние системы;

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

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

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

  • повысить скорость разработки нового функционала без ущерба для существующего; 

  • снизить время подключения нового работника к проекту;

  • уменьшить количество багов;

  • упростить написание тестов;

  • повысить качество планирования разработки.

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

Практика

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

Мы используем ASP.NET Core 5.0, поэтому примеры реализации паттерна будут в контексте этого фреймворка.

Помимо встроенных механизмов ASP.NET Core 5.0, нам понадобятся еще две библиотеки:

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

  • FluentValidation— небольшая библиотека валидации для .NET, которая использует Fluent-интерфейс и лямбда-выражения для построения правил валидации.

Реализация REST API с помощью CQRS

Наши команды и запросы очень хорошо ложатся на REST API:

  • get — это всегда запросы; 

  • post, put, delete — команды.

Добавление и настройка MediatR:

Чтобы добавить библиотеку в наш проект, выполним в консоли команду:

dotnet add package MediatR.Extensions.Microsoft.DependencyInjection

Далее зарегистрируем все компоненты нашей библиотеки в методе ConfigureServices класса Startup:

namespace CQRS.Sample
{
   public class Startup
   {
       ...
       public void ConfigureServices(IServiceCollection services)
       {
           ...
           services.AddMediatR(Assembly.GetExecutingAssembly());
           services.AddControllers();
           ...
       }
   }
}

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

namespace CQRS.Sample.Features
{
   public class AddProductCommand : IRequest<Product>
   {
       /// <summary>
       ///     Алиас продукта
       /// </summary>
       public string Alias { get; set; }
 
       /// <summary>
       ///     Название продукта
       /// </summary>
       public string Name { get; set; }
 
       /// <summary>
       ///     Тип продукта
       /// </summary>
       public ProductType Type { get; set; }
   }
}

Далее нам нужно реализовать обработчик нашей команды с помощью IRequestHandler<TCommand, TResponse>. 

В конструкторе обработчика мы объявляем все зависимости, которые нужны нашей команде, и пишем бизнес-логику, в этом случае — сохранение сущности в БД.

namespace CQRS.Sample.Features
{
   public class AddProductCommand : IRequest<Product>
   {
       /// <summary>
       ///     Алиас продукта
       /// </summary>
       public string Alias { get; set; }
 
       /// <summary>
       ///     Название продукта
       /// </summary>
       public string Name { get; set; }
 
       /// <summary>
       ///     Тип продукта
       /// </summary>
       public ProductType Type { get; set; }
 
       public class AddProductCommandHandler : IRequestHandler<AddProductCommand, Product>
       {
           private readonly IProductsRepository _productsRepository;
 
           public AddProductCommandHandler(IProductsRepository productsRepository)
           {
               _productsRepository = productsRepository ?? throw new ArgumentNullException(nameof(productsRepository));
           }
 
           public async Task<Product> Handle(AddProductCommand command, CancellationToken cancellationToken)
           {
               Product product = new Product();
               product.Alias = command.Alias;
               product.Name = command.Name;
               product.Type = command.Type;
 
               await _productsRepository.Add(product);
               return product;
           }
       }
   }
}

Чтобы вызвать исполнение нашей команды, мы реализуем Action в нужном контроллере, пробросив интерфейс IMediator как зависимость. В качестве параметров экшена мы передаем нашу команду, чтобы механизм привязки ASP.Net Core смог привязать тело запроса к нашей команде. Теперь достаточно отправить команду через MediatR и вызвать обработчик нашей команды.

namespace CQRS.Sample.Controllers
{
   [Route("api/v{version:apiVersion}/[controller]")]
   [ApiController]
   public class ProductsController : ControllerBase
   {
       private readonly ILogger<ProductsController> _logger;
       private readonly IMediator _mediator;
 
       public ProductsController(IMediator mediator)
       {
           _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
       }
       
       ...
 
       /// <summary>
       ///     Создание продукта
       /// </summary>
       /// <param name="client"></param>
       /// <param name="apiVersion"></param>
       /// <param name="token"></param>
       /// <returns></returns>
       [HttpPost]
       [ProducesResponseType(typeof(Product), StatusCodes.Status201Created)]
       [ProducesDefaultResponseType]
       public async Task<IActionResult> Post([FromBody] AddProductCommand client, ApiVersion apiVersion,
           CancellationToken token)
       {
           Product entity = await _mediator.Send(client, token);
           return CreatedAtAction(nameof(Get), new {id = entity.Id, version = apiVersion.ToString()}, entity);
       }
   }
}

Благодаря возможностям MediatR мы можем делать самые разные декораторы команд/запросов, которые будут выполняться по принципу конвейера, по сути, тот же принцип реализуют Middlewares в ASP.Net Core при обработке запроса. Например, мы можем сделать более сложную валидацию для команд или добавить логирование выполнения команд.

Нам удалось упростить написание валидации команд с помощью FluentValidation.

Добавим FluentValidation в наш проект:

dotnet add package FluentValidation.AspNetCore

Создадим Pipeline для валидации:

namespace CQRS.Sample.Behaviours
{
   public class ValidationBehaviour<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
       where TRequest : IRequest<TResponse>
   {
       private readonly ILogger<ValidationBehaviour<TRequest, TResponse>> _logger;
       private readonly IEnumerable<IValidator<TRequest>> _validators;
 
       public ValidationBehaviour(IEnumerable<IValidator<TRequest>> validators,
           ILogger<ValidationBehaviour<TRequest, TResponse>> logger)
       {
           _validators = validators;
           _logger = logger;
       }
 
       public async Task<TResponse> Handle(TRequest request, CancellationToken cancellationToken,
           RequestHandlerDelegate<TResponse> next)
       {
           if (_validators.Any())
           {
               string typeName = request.GetGenericTypeName();
 
               _logger.LogInformation("----- Validating command {CommandType}", typeName);
 
 
               ValidationContext<TRequest> context = new ValidationContext<TRequest>(request);
               ValidationResult[] validationResults =
                   await Task.WhenAll(_validators.Select(v => v.ValidateAsync(context, cancellationToken)));
               List<ValidationFailure> failures = validationResults.SelectMany(result => result.Errors)
                   .Where(error => error != null).ToList();
               if (failures.Any())
               {
                   _logger.LogWarning(
                       "Validation errors - {CommandType} - Command: {@Command} - Errors: {@ValidationErrors}",
                       typeName, request, failures);
 
                   throw new CQRSSampleDomainException(
                       $"Command Validation Errors for type {typeof(TRequest).Name}",
                       new ValidationException("Validation exception", failures));
               }
           }
 
           return await next();
       }
   }
}

И зарегистрируем его с помощью DI, добавим инициализацию всех валидаторов для FluentValidation.

namespace CQRS.Sample
{
   public class Startup
   {
       ...
       public void ConfigureServices(IServiceCollection services)
       {
           ...
           services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehaviour<,>));
           services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly());
           ...
       }
   }
}

Теперь напишем наш валидатор.

public class AddProductCommandValidator : AbstractValidator<AddProductCommand>
{
   public AddProductCommandValidator()
   {
       RuleFor(c => c.Name).NotEmpty();
       RuleFor(c => c.Alias).NotEmpty();
   }
}

Благодаря возможностям C#, FluentValidation и MediatR нам удалось инкапсулировать логику нашей команды/запроса в рамках одного класса.

namespace CQRS.Sample.Features
{
   public class AddProductCommand : IRequest<Product>
   {
       /// <summary>
       ///     Алиас продукта
       /// </summary>
       public string Alias { get; set; }
 
       /// <summary>
       ///     Название продукта
       /// </summary>
       public string Name { get; set; }
 
       /// <summary>
       ///     Тип продукта
       /// </summary>
       public ProductType Type { get; set; }
 
       public class AddProductCommandHandler : IRequestHandler<AddProductCommand, Product>
       {
           private readonly IProductsRepository _productsRepository;
 
           public AddProductCommandHandler(IProductsRepository productsRepository)
           {
               _productsRepository = productsRepository ?? throw new ArgumentNullException(nameof(productsRepository));
           }
 
           public async Task<Product> Handle(AddProductCommand command, CancellationToken cancellationToken)
           {
               Product product = new Product();
               product.Alias = command.Alias;
               product.Name = command.Name;
               product.Type = command.Type;
 
               await _productsRepository.Add(product);
               return product;
           }
       }
 
       public class AddProductCommandValidator : AbstractValidator<AddProductCommand>
       {
           public AddProductCommandValidator()
           {
               RuleFor(c => c.Name).NotEmpty();
               RuleFor(c => c.Alias).NotEmpty();
           }
       }
   }
}

Это сильно упростило работу с API и решило все основные задачи.

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

Текущие результаты можно посмотреть на GitHub.

Комментарии 20

    +1
    Перечитал раза три, но так и не уловил суть преимуществ такого подхода. Судя по описанию у меня похожий подход используется в самописной CRM. В контроллеры через DI передаются различные объекты «подсистемы» для управления той или иной областью бизнес-логики. То есть, например, есть подсистема типо ProductsSystem (условно), принимающая через DI многострадальный ProductsRepository и в этой подсистеме осуществляется работа с редактированием товаров. И контроллер всю деятельность осуществляет через эту подсистему.
    Я так понимаю, что в вашем случае, между контроллерами и репозиторием в качестве прокладки выступает этот самый медиатор, а точнее одна из реализованных им команд. А зачем? Почему не создаётся более конкретная реализация какой-то области бизнес-логики? И, опять же, с шаблоном проектирования с репозиториями, обычно имеет свойство пухнуть в объёмах именно репозиторий, а не контроллеры и системы бизнес логики, которые можно вовремя дробить и рефакторить. Как справлялись с распухающим репозиторием?
      +3
      Никто не ответит, потому что ответ — надо быть модным.
      Тут в заголовке написано про CQRS, хотя на самом деле статья про медиатр (который неплох).
      Не поясняются плюсы CQRS, не поясняются плюсы медиатра.

      А самое главное, на HelloProduct это все не прочувствовать.
        +1
        Статья, увы, не очень, если хотите более интересное и подробное описание и разбор CQRS, почитайте статьи Максима Аршинова habr.com/ru/users/marshinov
        вот, например — habr.com/ru/post/353258
          0
          это проба пера и вообще моя первая статья) в следующей постараюсь получше охватить саму суть паттерна
          0
          данная статья это скорее туториал, как реализовать самый простой CQRS в вашем приложении в рамках ASP.NET Core 5 и некая точка для сбора фидбэка, чтобы понять, что людям хочется узнать. В вашем случае вы изолируете всё в подсистемы, но ваша подсистема может так же распухать как и контроллер и репозиторий, и точно также могут распухать и её зависимости, CQRS помогает избежать именно этого. в дальнейших статьях будут рассмотрены более сложные реализации, когда одно приложение выступает в качестве обработчика команд и кладет сырые данные в бд, а второе будет приложение будет возвращать вам подготовленные для вывода данные.
            0

            Медиатор в такой архитектуре служит для вынесения бизнес логики в application слой. А контроллеры реализуют исключительно presentation слой.

            +1

            А можно подробней про остальные варианты — что имелось ввиду? И было бы здорово теперь все тоже самое, но когда бизнес логика посложнее обычного CRUD? Во внешний сервис пойти, в кэш, в очередь что-нибудь отправить. Или хотя бы получить из базы сущность, что-нибудь сгенерировать на основе того что в базе, что пришло с клиента и только потом сохранить. Ну и тяжелая артиллерия — какая-нибудь распределенная транзакция, охватывающая несколько query/command.

              0

              В чем вы видите сложность? Реализуете всю необходимую логику в обработчике медиатора. Через DI подтягиваете в него нужные сервисы, будь то репозитории, шины или внешние сервисы.

                0
                Наводящие вопросы, на которые мне приходилось как-то отвечать при работе в рамках подхода query/command (т.е. запрос — это выборка данных, без модификации, а команда — только модификация данных):
                1. Куда деть дублирующийся бизнес-код, который используется в нескольких query/command? Например генерация и отправка уведомления. Генерация — здесь query или command? А отправка?
                2. Если этот код выделить в IXYZService, то внутри таким сервисам может потребоваться добрать что-то еще из базы или даже сохранить (номер попытки отправки в БД? что-нибудь в лог?), т.е. вызов такой: command1 -> IXYZService -> query2/command2. И в какого монстра это все превращается?
                3. Обращение к внешнему сервису — это query или command?
                4. Как делать, если сначала надо получить сущность из базы через query, а потом сохранить через command? В query вызывать command или в command первым делом вызвать query? Может перенести это в контроллер? Тогда бизнес-логика размажется, да и контроллер начнет толстеть
                5. Разрешать ли использовать внутри query вложенные query, а внутри command вложенные query/command. По канону нельзя
                6. Как вообще ложатся методики описания бизнес-процессов (IDEF0 и BPMN) на одиночные query/command, которые нельзя вкладывать друг в друга?
                7. А что у нас там с SOLID и другими принципами? Отдельные простые query/command еще могут отвечать этим принципам, а когда внутри все усложняется?

                Изначально CQRS был создан для разделения хранилищ данных (основное для записи и несколько реплик для чтения). С тех пор как его стали использовать в качестве паттерна организации кода — проблемы полезли из всех щелей. И в оригинале он только и годится для CRUD. Но по другую сторону у нас те самые IXYZService сервисы, у которых есть риск превратиться в god-объекты. И query/command хорошее подспорье, чтобы это упростить и разделить.

                PS Да и это я не имел ввиду EventSourcing — потому что это совсем отдельная тема.
                  0

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

                    0
                    выносить повторяющуюся логику в свои сервисы и вызвать их в нужных обработчиках

                    Вот это и есть «command1 -> IXYZService -> query2/command2». Как-то я работал на проекте, где IXYZService обращался к БД напрямую (через EF context). И все эти запросы были по сути подвисшими в нигде query/command.
                      0

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

                        0
                        Тем самым мы просто перенесем подвисший код из IXYZService в IXYZRepository, хотя по сути этот код тоже является query/command.
                      0

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


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

                  +1
                  в следующей статье я постараюсь поднять этот вопрос, как раз в deluxe версии там предполагается EventSourcing и так далее
                  0
                  del
                    0
                    Как при таком подходе будет выглядеть валидатор, который зависит от контекста? Что-то вроде добавлять продукты с определенными типами может только пользователем с определенным статусом. Или для продукта с таким-то типом alias может быть пустым.
                      0
                      При желании можно передать в конструктор пользователя и делать условные проверки.
                      0
                      Я не совсем понял кто «матчит» и вызывает валидатор для соотв. команды. интуиция подсказывает что медиатор, однако сначала сложилось впечатление что реализовывали мы интерфейсы из библиотеки флюент. Или IPipelineBehavior это из медиатора?
                        0
                        Можно пожалуйста больше информации откуда вы взяли «Существует три вида паттерна CQRS: Regular, Progressive и Deluxe. » и где можно почитать детальнее?

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

                        Самое читаемое