Command-query separation (CQS) — это разделение методов на read и write.

Command Query Responsibility Segregation (CQRS) — это разделение модели на read и write. Предполагается в одну пишем, с нескольких можем читать. М — масштабирование.

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

Размышления навеяны этой статьей Паттерн CQRS: теория и практика в рамках ASP.Net Core 5 и актуальны для анемичной модели. Для DDD все по-другому.

Историческая справка


Начать пожалуй стоит с исторической справки. Сначала было как-то так:

public interface IEntityService
{
    EntityModel[] GetAll();
    EntityModel Get(int id);
    int Add(EntityModel model);
    void Update(EntityModel model);
    void Delete(int id);
}

public interface IEntityRepository
{
    Entity[] GetAll();
    Entity Get(int id);
    int Add(Entity entity);
    void Update(Entity entity);
    void Delete(int id);
}

С появлением CQS стало так:

public class GetEntitiesQuery
{
     public EntityModel[] Execute() { ... }
}

public class GetEntityQuery
{
     public EntityModel Execute(int id) { ... }
}

public class AddEntityCommand
{
     public int Execute(EntityModel model) { ... }
}

public class UpdateEntityCommand
{
     public void Execute(EntityModel model) { ... }
}

public class DeleteEntityCommand
{
     public void Execute(int id) { ... }
}

Эволюция


Как видим, два потенциальных god-объекта разделяются на много маленьких и каждый делает одну простую вещь — либо читает данные, либо обновляет. Это у нас CQS. Если еще и разделить на два хранилища (одно для чтения и одно для записи) — это будет уже CQRS. Собственно что из себя представляет например GetEntityQuery и UpdateEntityCommand (здесь и далее условный псевдокод):

public class GetEntityQuery
{
    public EntityModel Execute(int id)
    {
        var sql = "SELECT * FROM Table WHERE Id = :id";
        using (var connection = new SqlConnection(...connStr...))
        {
             var command = connection.CreateCommand(sql, id);
             return command.Read();
        }
    }
}

public class UpdateEntityCommand
{
    public void Execute(EntityModel model)
    {
        var sql = "UPDATE Table SET ... WHERE Id = :id";
        using (var connection = new SqlConnection(...connStr...))
        {
             var command = connection.CreateCommand(sql, model);
             return command.Execute();
        }
    }
}

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

public class UpdateEntityCommand
{
    public void Execute(EntityModel model)
    {
        var entity = db.Entities.First(e => e.Id == model.Id); // <-- опа, а что это? query?
        entity.Field1 = model.Field1;

        db.SaveChanges();
    }
}

Да, если ORM позволяет обновлять сущности сразу, то все будет хорошо:

public class UpdateEntityCommand
{
    public void Execute(EntityModel model)
    {
        var entity = new Entity { Id = model.Id, Field1 = model.Field1 };
        db.Attach(entity);

        db.SaveChanges();
    }
}

Так а что делать, когда надо достать сущность из базы? Куда девать query из command? На ум приходит сделать так:

public class GetEntityQuery
{
    public Entity Execute(int id)
    {
        return db.Entities.First(e => e.Id == model.Id);
    }
}

public class UpdateEntityCommand
{
    public void Execute(Entity entity, EntityModel model)
    {
        entity.Field1 = model.Field1;

        db.SaveChanges();
    }
}

Хотя я встречал еще такой вариант:

public class UpdateEntityCommand
{
    public void Execute(EntityModel model)
    {
        var entity = _entityService.Get(model.Id); // ))) 
        entity.Field1 = model.Field1;

        db.SaveChanges();
    }
}

public class EntityService
{
    public Entity Get(int id)
    {
        return db.Entities.First(e => e.Id == model.Id);
    }
}

Просто перекладываем проблему из одного места в другое. Эта строчка не перестает от этого быть query.

Ладно, допустим остановились на варианте с GetEntityQuery и UpdateEntityCommand. Там хотя бы query не пытается быть чем-то другим. Но куда это все сложить и откуда вызывать? Пока что есть одно место — это контроллер, выглядеть это будет примерно так:

public class EntityController
{
    [HttpPost]
    public EntityModel Update(EntityModel model)
    {
        var entity = new GetEntityQuery().Execute(model.Id);
        
        new UpdateEntityCommand().Execute(entity, model);

        return model;
    }
}

Да и через некоторое время нам понадобилось, например, отправлять уведомления:

public class EntityController
{
    [HttpPost]
    public EntityModel Update(EntityModel model)
    {
        var entity = new GetEntityQuery().Execute(model.Id);
        
        new UpdateEntityCommand().Execute(entity, model);
        
        _notifyService.Notify(NotifyType.UpdateEntity, entity); // <-- А это query или command?

        return model;
    }
}

В итоге контроллер у нас начинает толстеть.

Лирическое отступление IDEF0 и BPMN


Мало того, реальные бизнес-процессы сложные. Если взглянуть на диаграммы IDEF0 или BPMN можно увидеть несколько блоков, за каждым из которых может скрываться код наподобие нашего кода из контроллера или вложенная серия блоков.

image
(рисунок найден на просторах интернета)

И приведу пример одной реальной задачи: по гео-координатам получить погоду в заданной точке. Есть внешний условно-бесплатный сервис. Поэтому требуется оптимизация в виде кэша. Кэш не простой. Хранится в базе данных. Алгоритм выборки: сначала идем в кэш, если там есть точка в радиусе 10 км от заданной и в пределах 1 часа по времени, то возвращаем погоду из кэша. Иначе идем во внешний сервис. Здесь и query, и command, и обращение к внешнему сервису — все-в-одном.

Решение


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

Как видим искомый CQS изначально создан для абстрагирования на уровне доступа к данным. Там с ним проблем нет. Код, который расположился у нас в контроллере — это бизнес-код, еще один уровень абстракции. И именно для этого уровня выделим еще одно понятие — бизнес-история. Или Story.

Одна бизнес-история — это один из блоков на диаграмме IDEF0. Она может иметь вложенные бизнес-истории, как блок IDEF0 может иметь вложенные блоки. И она может обращаться к искомым понятиям CQS — это к Query и Command.

Таким образом, код из контроллера мы переносим в Story:

public class EntityController
{
    [HttpPost]
    public EntityModel Update(EntityModel model)
    {
        return new UpdateEntityStory().Execute(model);
    }
}

public class UpdateEntityStory
{
    public EntityModel Execute(EntityModel model)
    {
        var entity = new GetEntityQuery().Execute(model.Id);
        
        new UpdateEntityCommand().Execute(entity, model);
        
        _notifyService.Notify(NotifyType.UpdateEntity, entity);

        return model;
    }
}

И контроллер остается тонким.

Данная UpdateEntityStory инкапсулирует в себе законченный конкретный бизнес-процесс. Ее можно целиком использовать в разных местах (например в вызовах API). Она легко подвергается тестированию и никоим образом не ограничивает использование моков/фейк-объектов.

Диаграмму IDEF0/BPMN можно разбросать по таким Story, что даст более легкий вход в проект. Все изменения можно будет уложить в следующий процесс: сначала меняем документацию (диаграмму IDEF0) — затем дописываем тесты — а уже в конце дописываем бизнес-код. Можно наоборот, по этим Story автоматически построить документацию в виде IDEF0/BPMN диаграмм.

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

  1. Story — входная точка бизнес-логики. Именно на нее ссылается контроллер.
  2. Но внутрь Story не должны попадать такие вещи как HttpContext и тому подобное. Потому что тогда Story нельзя будет легко вызывать в другом контексте (например в hangfire background job или обработчике сообщения из очереди — там не будет никаких HttpContext).
  3. Входящие параметры Story опциональны. Story может возвращать что-либо или не возвращать ничего (хотя для сохранения тестируемости хорошо бы она что-нибудь возвращала).
  4. Story может работать как с бизнес-сущностями, так и с моделями и DTO. Может внутри вызывать соответствующие мапперы и валидаторы.
  5. Story может вызывать другие Story.
  6. Story может вызывать внешние сервисы. Хотя внешний вызов можно тоже оформить как Story. Об этом ниже с нашим сервисом погоды.
  7. Story не может напрямую обращаться к контексту базы данных. Это область ответственности Query и Command. Если нарушить это правило, все запросы и команды вытекут наружу и размажутся по всему проекту.
  8. На Story можно навешивать декораторы. Об этом тоже ниже.
  9. Story может вызывать Query и Command.
  10. Разные Story могут переиспользовать одни и те же Query и Command.
  11. Query и Command не могут вызывать другие Story, Query и Command.
  12. Только Query и Command могут обращаться к контексту базы данных.
  13. В простых случаях можно обойтись без Story и из контроллеров вызывать сразу Query или Command.

Теперь тот самый пример с сервисом погоды:

public class GetWeatherStory
{
    public WeatherModel Execute(double lat, double lon)
    {
        var weather = new GetWeatherQuery().Execute(lat, lon, DateTime.NowUtc);

        if (weather == null)
        {
             weather = _weatherService.GetWeather(lat, lon);
             new AddWeatherCommand().Execute(weather);
        }

        return weather;
    }
}

public class GetWeatherQuery
{
    public WeatherModel Execute(double lat, double lon, DateTime currentDateTime)
    {
        // Нативный SQL запрос поиска записи в таблице по условиям:
        // * в радиусе 10 км от точки lat/lon
        // * в пределах 1 часа от currentDateTime
        // С использованием расширений PostGis или аналогичных

        return result;
    }
}

public class AddWeatherCommand
{
    public void Execute(WeatherModel model)
    {
        var entity = new Weather { ...поля из model... };
        db.Weathers.Add(entity);
        db.SaveChanges();
    }
}

public class WeatherService
{
    public WeatherModel GetWeather(double lat, double lon)
    {
        var client = new Client();
        var result = client.GetWeather(lat, lon);
        return result.ToWeatherModel(); // маппер из dto в нашу модель
    }
}

Декораторы


И в заключении о декораторах. Чтобы Story стали более гибкими необходимо cложить их в DI контейнер / mediator. И добавить возможность декорировать их вызов.

Сценарии:

1. Запускать Story внутри транзакции scoped контекста базы данных:

public class EntityController
{
    [HttpPost]
    public EntityModel Update(EntityModel model)
    {
        return _mediator.Resolve<UpdateEntityStory>().WithTransaction().Execute(model);
    }
}

// или

[Transaction]
public class UpdateEntityStory
{
    ...
}

2. Кэшировать вызов

public class EntityController
{
    [HttpPost]
    public ResultModel GetAccessRights()
    {
        return _mediator
            .Resolve<GetAccessRightsStory>()
            .WithCache("key", 60)
            .Execute();
    }
}

// или

[Cache("key", 60)]
public class GetAccessRightsStory
{
    ...
}

3. Политика повторов

public class GetWeatherStory
{
    public WeatherModel Execute(double lat, double lon)
    {
        var weather = new GetWeatherQuery().Execute(lat, lon, DateTime.NowUtc);

        if (weather == null)
        {
             weather = _mediator
                 .Resolve<GetWeatherFromExternalServiceStory>()
                 .WithRetryAttempt(5)
                 .Execute(lat, lon);

             _mediator.Resolve<AddWeatherCommand>().Execute(weather);
        }

        return weather;
    }
}

// или

[RetryAttempt(5)]
public class GetWeatherFromExternalServiceStory
{
    ...
}

4. Распределенная блокировка

public class GetWeatherStory
{
    public WeatherModel Execute(double lat, double lon)
    {
        var weather = new GetWeatherQuery().Execute(lat, lon, DateTime.NowUtc);

        if (weather == null)
        {
             weather = _mediator
                 .Resolve<GetWeatherFromExternalServiceStory>()
                 .WithRetryAttempt(5).
                 .Execute(lat, lon);

             _mediator.Resolve<AddWeatherStory>()
                 .WithDistributedLock(LockType.RedLock, "key", 60)
                 .Execute(weather);
        }

        return weather;
    }
}

// или

[DistributedLock(LockType.RedLock, "key", 60)]
public class AddWeatherStory
{
    ...
}

И тому подобное.