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-контейнера, но мне проще объявить два класса.

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


  1. vtvz_ru
    31.01.2018 00:53

    Пишу на PHP. Использую что-то подобное cqrs, что упростило код, сделало его симпатичнее, проще в поддержании. Я не знаю, как на шарпе все реализовано, но у меня один маленький тоненький command bus и ещё один такой же query bus куда я кидаю команды и запросы. Они просто пробрасывают запрос/команду в нужный обработчик. Так я смогу в будущем переключить выполнение команд с основного потока в очередь. Единственное, что мне до сей поры очень плохо ясно, это куда во всей этой схеме пихать валидацию? Мне предлагали сделать что-то типо middleware, где я бы перехватывал команду и валидировал данные в ней. В итоге пришел к самому простому: команда с валидацией реализует интерфейс ValidatableInterface с методом validate и command bus выбрасывал бы исключение при ошибке. А что, если мне нужно проверить одни и те же данные в разных местах? Писать валидаторы для каждого свойства? Может быть есть какой-то толковый материал на эту тему? Можно англоязычный. Да и вообще не тему cqrs, но без es.

    Автору спасибо за статью. Собираю по крупицам информацию. Не задумывался, почему может быть плохо использовать query внутри command. Тут был поднят вопрос без ответа на него: что делать с автосгенерированными БД ID? Я полагаю, тут истинно верного решения нет?


    1. marshinov Автор
      31.01.2018 01:44

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

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

      1. некоторые сущности могут быть вычитана по два раза
      2. транзакционностью придется управлять за границей CQRS-стека

      Тут был поднят вопрос без ответа на него: что делать с автосгенерированными БД ID? Я полагаю, тут истинно верного решения нет?

      Если у вас синхронный CQRS без шины — возвращайте Id из команды / хендлера и не мучайтесь. Если с шиной, то у вас либо должен быть механизм доставки доменного события «заказ создан» до пользователя, например web sockets. Либо отказ от автогенерируемых Id — создаем Guid'ы на клиенте.


      1. VolCh
        31.01.2018 21:03

        Необязательно до пользователя, достаточно до клиента, а клиентом может быть и, например, контроллер http-интерфейса. То есть контроллер прежде чем отправить команду в шину команд, подписывается на ожидаемое событие.


    1. Anton_Zh
      31.01.2018 07:32

      Так я смогу в будущем переключить выполнение команд с основного потока в очередь.
      Сомневаюсь. Придется переделать UI, частино придется переделать валидацию, и, скорее всего, сами обработчики (чтобы ничего не возвращали). Прозрачно «переключить» точно не получится.



    1. boblgum
      31.01.2018 14:35

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

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


      А что, если мне нужно проверить одни и те же данные в разных местах?

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


      • Приходит запрос (request) который мы трансформируем в query/command-объект. Для инициализации самого объекта валидируем наличие и типы параметров запроса.
      • далее скомпанованный объект доходит до обработчика (command-/query-handler). Для обработки валидируем те же самые параметры, но уже в другом контексте. Идет проверка на соответствие с требованиями бизнес-логики.

      ну и тд и тп


    1. ghost404
      31.01.2018 20:59

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

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


      Валидировать команды в command heandlers или middleware не правильно, так как вы не сможете корректно отреагировать на обнаруженные ошибки.


      Да, вы можете сделать валидирующий middleware и бросать ValidationExcepton, но это неверно с точки зрения семантики, так как невалидные данные не являются исключительной ситуацией.


      Рекомендую посмотреть готовые библиотеки реализующие CQRS, Middleware, Payload.


      1. VolCh
        31.01.2018 22:37

        Смотря что называть валидацией. Минимум три вещи ею называют:
        1. Грубая проверка пользовательского запроса: форма, обязательные параметры, их типы, глобальные статические правила
        2. Локальные (без обращения к внешним сервисам) бизнес-правила
        3. Глобальные бизнес-правила
        Соответственно и размещать их нужно на нужных слоях и часто приходится выбирать или выворачивать кишки наружу, или бросать исключения.


        1. ghost404
          31.01.2018 23:50

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


          1. VolCh
            01.02.2018 08:26
            +2

            Допустим, платежная система у нас, приходит запрос на оплату со счёта. В контроллере делать запрос на остаток по счёту?


            1. ghost404
              01.02.2018 09:23

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


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


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


              1. VolCh
                01.02.2018 09:43

                Исключение само по себе не плохо. Но можно обходиться и без исключений, если их недостатки перевешивают в конкретном случае преимущества. Например, как в Go или в старом добром C.


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


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


              1. marshinov Автор
                01.02.2018 11:15

                А этот вариант вам не подходит? При необходимости валидацию расширяете просто доменными правилами.


                1. ghost404
                  01.02.2018 14:07

                  Это тоже самое, что ValidatorMiddleware. И middleware на мой взгляд предпочтительней декораторов.
                  Но сути это не меняет. Нужно бросить исключение чтоб прекратить выполнение и поднять сообщение(я) об ошибке наверх в контроллер. Я не в восторге от такого подхода.


                  1. marshinov Автор
                    01.02.2018 14:11

                    Но сути это не меняет. Нужно бросить исключение чтоб прекратить выполнение и поднять сообщение(я) об ошибке наверх в контроллер. Я не в восторге от такого подхода.

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


                    1. ghost404
                      01.02.2018 15:30

                      Окей. Вы подмешиваете данных в возвращаемое значение. Тоже метод решения задачи. С таким же успехом можно использовать аргумент out (возвращаемся к разделу ICommandHandler должен всегда возвращать void?).
                      Можно придумать еще с 10 костылей, но зачем?


                      1. marshinov Автор
                        01.02.2018 15:51

                        Тогда я не понимаю, чего вы хотите. Все возможные варианты не устраивают:)


          1. Veikedo
            01.02.2018 08:49

            Есть такой подход в ddd, как ContextValidation. Думаю, его лучше юзать


            1. marshinov Автор
              01.02.2018 11:29

              А ссылочкой не поделитесь?


              1. Veikedo
                01.02.2018 12:00
                +1

                https://martinfowler.com/bliki/ContextualValidation.html
                https://lostechies.com/jimmybogard/2009/02/15/validation-in-a-ddd-world/


                Главный посыл таков — "Instead of answering the question, “is this object valid”, try and answer the question, “Can this operation be performed?”.


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


  1. nomoreload
    31.01.2018 01:09

    А если на начальном этапе разработки в качестве хранилища взять тот же EF Core, и оперировать его контекстом, как в командах, так и в запросах. А потом, по мере роста нагрузок и усложнения логики — проводить постепенный рефакторинг, устраняя узкие места (с использованием того же Dapper или вообще ADO).
    Допустимо ли строить архитектуру таким образом?
    Потому что строить чистый CQRS изначально, с отдельным представлением данных для CUD и R — довольно дорого, особенно в условиях, когда даже нет целостного представления о том какой должна быть модель (а это означает постоянный рефакторинг. Много рефакторинга).

    И второй вопрос — что скажете на счёт MediatR в качестве инфраструктуры для CQRS. Там правда всего один интерфейс, но проблема деления на команды и запросы решается нэймингом.


    1. Fesor
      31.01.2018 01:31
      +1

      строить чистый CQRS изначально

      тут у меня два вопроса — что в вашем понимании "чистый CQRS" и почему введение двух интерфейсов вместо одного для разделения операций чтения и записи вдруг стало дорого?


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


      У Грега Янга по поводу всех этих безумств с шинами команд и query bus есть отдельная статья: CQRS, Task Based UIs, Event Sourcing agh!


      1. nomoreload
        31.01.2018 07:49

        «Чистый» — это с полноценно раздельной записью и чтением, когда модификация состояния — это EF + DDD с транзакциями, а чтение — Dapper/ADO+DTO.
        Я спросил, на счёт того — допустимо ли использование бизнесовых сущностей и контекста БД не только для модификации, но и для чтения на начальных этапах проектирования системы, так как домен там постоянно изменяется по мере уточнения требований. А так же спросил на счёт возможности использования готовой библиотеки в качестве инфраструктурной основы.


        1. Fesor
          31.01.2018 13:08

          допустимо ли использование бизнесовых сущностей

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


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


          1. marshinov Автор
            31.01.2018 13:18

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

            Мы довольно долго так думали, пока не нашли QueryableExtension'ы. Кейсы, где нужно возвращать сущности как-то после этого кончились.


            1. Fesor
              31.01.2018 15:46
              +1

              Очень удобная штука. Потому я не очень понимаю почему DTO это дорого...


          1. VolCh
            31.01.2018 22:42

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


            1. Fesor
              01.02.2018 01:25

              Ну так то да, но мне больше приятен вариант с DTO в качестве этого интерфейса с геттерами и нормальная модель на запись.


    1. marshinov Автор
      31.01.2018 01:39

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

      Я в абзаце «Возвращать из QueryHandler сущности или DTO» примерно это и предлагаю. Что вас смутило?:)


      1. nomoreload
        31.01.2018 07:37

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


        1. marshinov Автор
          31.01.2018 08:53

          Вы имеете в виду дорого писать маппинги для Select? Если да, посмотрите в сторону Mapster Queryable Extensions. Вы же в любом случае в JSON сериализуете не сущность, а какие-то агрегированные данные. Зачем тащить из базы ненужные поля, если можно выбрать только то, что нужно?


          1. nomoreload
            31.01.2018 12:47

            Дорого поддерживать DTO и SQL, так как схема может ощутимо так измениться в процессе рефакторинга. За этим и спросил по поводу «срезать углы» в этом моменте. А уже после, когда будет уверенность в том, что со схемой всё ок, и мы описали нашу предметную область на столько, на сколько это было возможным — уже «заплатить по счетам» и таки реализовать чтение полностью независимым от домена и EF, возможно переработав API и то, как данные возвращаются из API.


            1. Fesor
              31.01.2018 13:01
              +1

              уже «заплатить по счетам»

              практика показывает что никто потом не платит.


              Тут важный момент — если на момент разработки у вас нет профита от разделения чтения и записи — то может быть профита и дальше не будет? Ну то есть, если все работает и так — для чего вы дальнейшие действия будете делать?


              схема может ощутимо так измениться в процессе рефакторинга

              Если делать рефакторинг чаще, можно уменьшать объем изменений. Актуализация же DTO в целом не настолько дорогая штука. Скучно — да, но не долго.


          1. Veikedo
            31.01.2018 15:50

            BTW у автомаппера также есть возможность не писать явный маппинг


    1. marshinov Автор
      31.01.2018 09:16

      И второй вопрос — что скажете на счёт MediatR в качестве инфраструктуры для CQRS. Там правда всего один интерфейс, но проблема деления на команды и запросы решается нэймингом.

      MediatR реализует идею separation of concerns, только вместо декораторов использует Behaviors. Даже используется «фишка» с пустым дженерик-интерфейсом, чтобы указать возвращаемый тип:

      public class Ping : IRequest<string> { }

      В данном контексте я не вижу разницы: использовать SimpleInjector с декораторами или MediatR. Есть смысл проверить производительность и выбрать, что работает быстрее. SimpleInjector компилирует деревья выражений, а MediatR использует механизм фабрик.


      1. nomoreload
        31.01.2018 12:53

        Фабрики там служат сугубо для того, чтобы обернуть вызов IoC контейнера. Вопрос был в том — а нормально ли использовать его как основу для CQRS? API нравится, вроде даёт всё нужное. Правда интерфейс один — IRequest, но можно ведь сделать команды и запросы в разных сборках и следить за тем, чтобы обработчики запросов не изменяли состояния. Что скажете на этот счёт?


        1. Veikedo
          31.01.2018 14:33

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


          1. marshinov Автор
            31.01.2018 14:44

            Все сделали на IRequest без сервисного слоя?


            1. Veikedo
              31.01.2018 14:57

              Есть несколько сервисов типа LdapServise/AuthService, но всякие бизнес и crud операции напрямую в обработчике.


              Вот кстати еще вопрос — а кошерно ли использовать CommandHandler'ы внутри CommandHandler'ов?


              Джимми Боггард пишет, что нет и лучше юзать композицию/реализовывать несколько commandHandler'ов в одном классе


              1. marshinov Автор
                31.01.2018 16:09

                Дописал. Все сходятся, что это неудобно. Мы пробовали компоновать QueryHandler'ы. Получается нечитаемо и многословно.


        1. Veikedo
          31.01.2018 14:40
          +1

          По поводу только одного интерфейса- достаточно завести свой собственный интерфейс и унаследовать от IRequest
          Что-то вроде
          interface IQuery: IRequestКовариацию не забудьте только, мне с телефона неудобно редактировать


        1. marshinov Автор
          31.01.2018 15:12

          Один интерфейс решает проблему с двумя декораторами, которую я решил с помощью кастинга к делегатам. Это плюс.

          Субъективно кажется, что имея IRequestHandler<SomeCommand> и IRequestHandler<SomeQuery> проще запутаться и случайно добавить мутацию в IRequestHandler<SomeQuery>, чем в случае двух интерфейсов: IRequestHandler<SomeQuery>, а в IQueryHandler<SomeQuery>. Это минус.

          Библиотека выглядит приятно. Если у вас нет проблем с соблюдением нейминага и код-ревью не в web-морде, а в IDE (чтобы можно было по файлам пробежаться, посмотреть, проверить наличие мутаций), решение вполне имеет право на жизнь.


          1. Veikedo
            31.01.2018 15:47

            С двумя интерфейсами проще вешать декоратор/сегрегировать пайплан в том же медиатре.
            Например, запускать транзакцию только для комманд. Можно пойти еще дальше и добавить отдельный маркерный интерфейс/атрибут для комманд, требующих транзакцию с параметром уровня изоляции.


      1. Veikedo
        31.01.2018 14:36
        +1

        У медиатра ваш прием с ResultHandler'ом не пройдет, поскольку вы меняете тип возвращаемого значения.
        Но всякие валидации, транзакции и прочие шаблоны очень здорово залетают в pipeline behavior


  1. Veikedo
    31.01.2018 10:25

    Спасибо за статью!
    У меня нубский вопрос — а как быть с загрузкой данных во write части? Городить ещё репозиториев для CommandHandler'ов?


    Сейчас я в обработчиках использую EF контекст напрямую и при тестировании подменяю хранение на InMemoryStorage (у нас ef core) и вроде всё хорошо, но как-то не по себе.
    В общем-то, пример можно посмотреть здесь у Jimmy Bogard'a


    1. marshinov Автор
      31.01.2018 12:04

      Сейчас я в обработчиках использую EF контекст напрямую и при тестировании подменяю хранение на InMemoryStorage (у нас ef core) и вроде всё хорошо, но как-то не по себе.

      А что вас не устраивает в таком подходе? DbContext уже реализует репозиторий, зачем городить абстракцию поверх. Можно отвязаться от EF и инжектировать IQueryable<T> через фабрику: dbContext.Set<T>(), но тогда EF не будет кешировать обращения по Id (по-умолчанию кешируются только вызовы метода Find). Это тоже можно исправить, но работа с контекстом — это вообще тема отдельной статьи.

      На практике абстракция от ORM создает больше проблем. Если можно говорить о какой-то заменяемости между EF и NHibernate, то остальные, даже если поддерживают LINQ требуют значительного переписывания кода для замены. Так что первая ложь — ORM абстрагирует вас от БД и вы сможете через 5 лет разработки переехать с Оракла на Постргес. Вторая — IQueryable абстрагирует вас от деталей реализации.


  1. boblgum
    31.01.2018 14:41
    +1

    сначала хотелось бы поблагодарить за одну из немногих более или менее вменяемых статей по CQRS и DDD. на удивление очень много пишут явный бред. спасибо!


    далее к вашему примеру


    Но как быть с Id, генерируемыми БД? Например, мы отправили команду «оформить заказ». Номеру заказа соответствует его Id из БД. Id нельзя получить, пока запрос INSERT не выполнен.

    а никак. потому как тут явно кто-то забыл один из главных принципов DDD — Persistence Ignorance. Ваша система, разрабатываемая по принципам DDD, по умолчанию "забила" на БД. Идентификатор Entity является неотъемлимым признаком сущности. Нет идентификатора, нет Entity. Независимо от существования или отсутствия БД.
    Как только возникает необходимость или зависимость от автогенерированных ID — фэйл, рукалицо и фтопку.


    1. marshinov Автор
      31.01.2018 14:48

      Я имел в виду, что доменная модель смоделирована так: сначала есть «корзина», а в момент оформления появляется «заказ». Заказ создает наша система и сообщает его Id клиенту. Вы знаете альтернативу автогенерируемым БД Id и Guid для сущностей, для которых нет естественных Id (типа ИНН, КПП, паспортных данных и т.д.)?


      1. boblgum
        31.01.2018 15:28

        я не совсем понимаю, почему Вы автогенерируемые Id вместе с GUID/UUID "в кучу скидываете". как раз GUID и есть отличная замена для БД-идентификаторов.
        попробуйте ответить самому себе на вопрос: На каком основании Вы в своей системе создаете зависимость от имплементации БД-идентификаторов? кто гарантирует, что со следующим обновлением БД-движка или его заменой останутся те же самые идентификаторы?
        это, конечно, действительно толко в ситуации, если вы не пишите свой БД-движок


        1. marshinov Автор
          31.01.2018 15:39

          На основании, что предметная модель не предоставляет естественного Id. Guid гарантирует уникальность за счет математики (хотя и подвержен коллизиям), Id — за счет СУБД. У Эванса, на сколько я помню, про выбор Id написано четко: если есть Id в домене — берите из домена. Нет — положитесь на технические срадства.

          кто гарантирует, что со следующим обновлением БД-движка или его заменой останутся те же самые идентификаторы?

          Приведите реальный пример обновления СУБД, чтобы слетели первичные ключи?

          Guid'ы совсем не бесплатные. Если есть возможность использовать автоинкремент в БД зачем усложнять себе жизнь?


          1. boblgum
            31.01.2018 15:56

            Приведите реальный пример обновления СУБД

            конечно, это самый жесткий аргумент :) зачем нам теория если она не подтверждается реальностью сиюминутно и постоянно. https://bugs.mysql.com/bug.php?id=199


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


            А вот насчет Эванса — согласен. Вот только там ничего насчет БД нет. "Технические средства" могут быть генератор GUID для предотвращения коллизий, например. А домен по умолчанию не БД. Ну, если вы, конечно не создаете новый phpMyAdmin


            1. Andreyika
              31.01.2018 16:20
              +3

              ну а чем технически некий "генератор GUID" (насколько я понимаю, их GUIDы не с неба ангелы передают) и некий "генератор автоинкремента" отличаются? оба зависят от реализации, оба имеют свои странности, оба могут быть забагованы


              1. boblgum
                31.01.2018 17:01

                насчет реализации и багов — не спорю, согласен. попросили реальный пример — получите (см ссылку).


                а вот отличаются они тем, зачем и для кого они. автоинкремент — идентификатор строки в таблице. а GUID как идентификатор "вообще". то есть, одно есть решение проблемы БД на уровне БД. догадайтесь с полпинка, с какой проблемой сталкиваются в первую очередь при переезде с MySQL на PostgreSQL?


                То есть отличаются они как раз тем на чем держится вся идея DDD. Контекстом и целесообразностью — то бишь доменом.


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


                соглашусь со следующей реализацией, хотя и полный бред:
                для инициализации сущности (Entity) Вы делаете запрос в БД на доступный автоинкремент. Получаете сий идентификатор. И только после этого "сохраняете" Entity.
                Если же у вас есть момент, когда Entity "ждет" (те он уже есть как некий объект) свой идентификатор — ищите прокол в Вашей архитектуре. Такая ситуация по DDD не имеет места быть


                1. michael_vostrikov
                  31.01.2018 20:13

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

                  Автоинкремент это способ генерации уникального числового id. Integer такой же идентификатор «вообще», как и GUID.


                  1. boblgum
                    31.01.2018 21:06

                    Если Вы по поводу приведённого мною примера, то должен вас огорчить. Да, частично вы правы: пока сущности существуют ничего страшного не произойдет. Но


                    1. Проблема была в завязке идентификации сущностей посредством автоинкремента из БД. На что я сказал, что при обновлении БД-движка может измениться подход самого движка к автоинкременту.
                    2. По данной ссылке как раз видно, что я имел в виду. С данным "багом" БД использовала id заново. Надеюсь, Вам не надо разъяснять в чём тут проблема с точки зрения DDD?
                    3. ID совершенно не то же самое что есть GUID. Дело в том, в каком контексте эти идентификаторы являются таковыми. Исходя из приведенного мною выше примера, должно стать понятным, что ID используется БД и только ей. Разрабов БД ну ни в коем случае не интересуют проблемы использования их технических решений не по назначению. Надеюсь, что и это не надо разъяснять


                    1. michael_vostrikov
                      31.01.2018 21:34

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

                      2. У генератора случайных GUID при неправильной реализации может быть такой же баг, если seed при запуске устанавливается в одно и то же число. И нет, у сущностей, хранящихся в этой таблице, не было одних и тех же id.

                      3. Автоинкрементный целочисленный первичный ключ используется БД точно так же, как и любой другой неинкрементный. Счетчик инкремента это отдельная сущность, в некоторых СУБД она отдельно от таблицы (SEQUENCE), а в MySQL связана с ней и снаружи недоступна.


                      1. boblgum
                        31.01.2018 22:32

                        К 1. Нет. В этом "баге" речь о том, что после затирания записи из таблицы значение id используется БД-движком при сохранении новой строки (reuse). А это есть совсем другой подход к автоинкременту как идентификатору. Что на уровне БД вполне допустимо.
                        К 2. Тут речь не о том, что баги возможны, а о том что вообще в каком контексте будет багом и какие последствия от них будут. См 1
                        К 3. И опять, речь абсолютно о другом.


                        1. michael_vostrikov
                          31.01.2018 23:36

                          1. Ну и пусть используется. Той сущности уже нет, и того id уже нет. Как будто его и не было. Вы же сами сказали «Нет идентификатора, нет Entity».
                          2. Так точно тот же контекст и те же баги. В одном случае вы запрашиваете генератор, он возвращает 128 бит, в другом вы тоже запрашиваете генератор, он возвращает 32 бита.
                          3. Так нет принципиальной разницы. И то и другое это первичный ключ, и то и другое надо генерировать. Либо приведите конкретные примеры, показывающие разницу.


                          1. VolCh
                            01.02.2018 08:39
                            +1

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


                            1. michael_vostrikov
                              01.02.2018 09:40

                              Если проверки по foreign key нет, то целостность может быть нарушена и другими способами.

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


                              1. VolCh
                                01.02.2018 09:50
                                -1

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


                                1. michael_vostrikov
                                  01.02.2018 10:38

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

                                  Сущность не исчезает, а перемещается, поэтому нельзя сказать, что сущности нет. Если она перемещается за пределы БД, то нужно хранить хотя бы значение автоинкремента последней внешней записи.

                                  Об этом же случае пишут в комментариях к багу. Там тоже данные перемещаются в архивную таблицу. То есть проблема не в самом сбросе счетчика, а в том, что он должен быть общим для двух хранилищ. Просто в MySQL такого нет. Допустим, в одной таблице есть записи 1,2, а запись 3 перемещена в архивную таблицу. Счетчик в первой вполне может сбрасываться на 4.


                                  1. VolCh
                                    01.02.2018 20:00

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


                                    Проблема именно в сбросе счётчика. Он отдаёт одинаковые значения при повторном обращении.


                                    1. michael_vostrikov
                                      01.02.2018 20:49

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

                                      Ну так пусть отдает. Главное чтобы прежние значения нигде не использовались. Какие проблемы будут, если мы, скажем, откатом транзакций увеличим счетчик до 10, а потом сбросим на 4? Но я согласен, что сохранять счетчик решение более простое и логичное.


                                      1. VolCh
                                        02.02.2018 17:48

                                        Контексту ничего неизвестно о какой-то общей модели, но если в требованиях говорится, что идентификатор сущности должен быть уникальным в рамках контекста и этот идентификатор входит в публичный интерфейс, то он не должен повторяться, если явно не сказано обратного. Если сказано, что он должен монотонно увеличиваться на 1, начиная с 1, то тоже недопустимо создание двух сущностей с одним идентификатором, даже если одновременно они не существуют.


                          1. boblgum
                            01.02.2018 12:39
                            +1

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


                            1. michael_vostrikov
                              01.02.2018 13:31

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


                              Судя по Вашим комментариям, Вы ну ни разу не поняли, что такое Entity.

                              Вас конечно же не затруднит привести конкретные цитаты с указанием противоречий?
                              Я говорю про Entity в логическом смысле, как про объект, имеющий первичный ключ и состояние. И о том, что способ генерации первичного ключа никак на нее не влияет.


                              Доказательств, почему "ID совершенно не то же самое что есть GUID", я так понимаю не будет.


                              1. boblgum
                                01.02.2018 15:13

                                ладно, еще раз пережуём
                                В статье шла речь о том, что, используя БД-генеририванный ID, сталкиваются с трудностями (ID генерируется только после сохранения записи в БД). На что я сказал, что, придерживаясь принципов DDD (persistence ignorance) такие проблемы не возникают. К тому же БД-идентификаторы не предусмотрены для использования вне контекста самой БД. Автор не согласился и запросил пример, когда обновление БД-движка может отразиться на бизнес-логике системы. Мой пример "бага" с повторным использованием ID как раз и показывает такой фэйл.


                                Вас конечно же не затруднит привести конкретные цитаты с указанием противоречий?

                                Если Вы про Ваши высказывания.


                                Ну и пусть используется. Той сущности уже нет, и того id уже нет

                                Повторное использование ID подразумевает, что у вас может появиться Entity с тем же самым идентификатором. А этого по определению не может быть.
                                Так как не похоже, что Вы понимаете о каком контексте идет речь в DDD, попробую объяснить на примере.
                                Родился Вася. При рождении он был зарегистрирован в системе в которой использовались ID генерированные БД. Васю зарегистрировали под номером 5. Деревня была маленькая. Вася пожил какое-то время и умер. Чтобы не заморачиваться его учетную запись стерли. С глаз долой, из сердца вон.
                                Через некоторое время родилась Маша. Её так же зарегистрировали. Угадайте какой номер получила она. Да — 5.
                                Маша подросла и пошла получать паспорт. Но выдали ей только похоронку.

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


                                Разница между ID и GUID видна хотя бы уже в самом "наименовании". GUID — globaly.
                                Ещё раз, используя ID из БД Вы работаете против принципов DDD в любом случае.


                                1. Andreyika
                                  01.02.2018 15:46

                                  ладно, допустим DDD не про все эти контексты, единый язык и тд, а про технические вопросы…
                                  ну так у вас persistence ignorance и при этом вы рассказываете истории про хранение данных о маше и васе в реляционной бд… одна история интереснее другой.
                                  это получается на каком-нибудь редисе и при отсутствии в языке/окружении генератора уникальных айди у вас "DDD" не взлетит?


                                1. michael_vostrikov
                                  01.02.2018 15:50

                                  К тому же БД-идентификаторы не предусмотрены для использования вне контекста самой БД.

                                  Вот это утверждение и вызывает вопросы. Почему вы так решили? Int такой же первичный ключ, как и GUID.


                                  Мой пример "бага" с повторным использованием ID как раз и показывает такой фэйл.

                                  Не показывает. После этого обновления в существующей бизнес-логике не поменяется ничего.


                                  Повторное использование ID подразумевает, что у вас может появиться Entity с тем же самым идентификатором. А этого по определению не может быть.

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


                                  Маша подросла и пошла получать паспорт. Но выдали ей только похоронку.

                                  С чего это, если о Васе никаких записей не осталось?


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

                                  Вы понимаете неправильно. Ее не надо предотвращать, она просто не случится в правильно построенной системе без битых ссылок. И неважно, будет в ней автоинкремент или нет.
                                  В баге с MySQL проблема именно в битых ссылках. Ее можно решить маленькой вероятностью повторения ссылки, общим инкрементом на обе таблицы, отключением сброса в виде сохранения значения инкремента, или как-то еще. Общий/сохраняемый инкремент, кстати, дает 100% вероятность неповторения, в отличие от GUID.


                                  Разница между ID и GUID видна хотя бы уже в самом "наименовании". GUID — globaly.

                                  И все, слово в названии это единственный ваш аргумент? Я возьму и скопирую себе в БД какой-нибудь GUID. Все, он уже не глобальный. Или автоинкремент 128-битный сделаю. Все, он сможет адресовать больше объектов, чем GUID, в котором пропуски.
                                  А повторение высказывания "еще раз" аргументом не является.


                                  1. boblgum
                                    01.02.2018 16:19
                                    -1

                                    ещё раз — удачи


                                  1. VolCh
                                    01.02.2018 20:13

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

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


                                    1. michael_vostrikov
                                      01.02.2018 21:01

                                      Хм, при такой постановке вопроса согласен. Сброс счетчика неприемлем.


                                      1. michael_vostrikov
                                        01.02.2018 21:22

                                        С другой стороны, битая ссылка это почти то же что и неправильная ссылка. Допустим, мы распечатали номер заказа, но потом переиспользовали его. Если мы обратимся по этому id, то теоретически должны получить NotFound. Но допустим, распечаталось плохо, мы вводим другую цифру, и получаем другую сущность. В чем разница между этими ситуациями? Почему мы одну должны контролировать, а другую нет?

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


                                1. VolCh
                                  01.02.2018 20:08

                                  Ещё раз, используя ID из БД Вы работаете против принципов DDD в любом случае.

                                  В какой-то мере это справедливо только в случае, если мы не можем получить значение счётчика перед или при создании сущности. По сути это справедливо именно для автоинкремент полей в СУБД, в которых единственный способ получить значение идентификатора для невставленной записи без конфликтов, это вставить её. Хотя можно и обойти костылями типа предварительной записи фейковой сущности, либо созданием псевдопоследовательности на базе таблицы с единственным автоинкрментным полем. Но для приложения это не будет нарушением persistence ignorance, просто у нас будет сервис или метод в сервисе генерации нового идентификатора для новой сущности. Будет внутри него база, генератор UUID, генератор рандомных чисел, функция хэширования или иного вычисления, или даже пользовательский ввод — деталь реализации, скрытая за абстракцией.


                                  1. boblgum
                                    02.02.2018 12:50

                                    о, а это, кажется новая и интересная мысль! хотя…
                                    https://habrahabr.ru/post/347908/#comment_10645562


                                    соглашусь со следующей реализацией, хотя и полный бред


                1. VolCh
                  02.02.2018 17:51
                  +1

                  Если же у вас есть момент, когда Entity "ждет" (те он уже есть как некий объект) свой идентификатор — ищите прокол в Вашей архитектуре. Такая ситуация по DDD не имеет места быть

                  Считаю, что Entity ждать может, например в конструкторе, не могут ждать клиенты Entity, уже получив ссылку на неё.


          1. Veikedo
            31.01.2018 15:59
            +2

            С guid'ами проще, например, мержить две таблицы в одну либо данные с двух разных бд. Был реальный кейс объединения двух клиентских баз


            1. VolCh
              01.02.2018 08:45

              Делал два ключа в таблицах, автоинкрементный как PK в рамках сущности и uuid как глобальный. Одно из требований бизнеса были человекчитаемые (точнее диктуемые :) ) ссылки. Да и требование глобальности гораздо позже появилось.


      1. michael_vostrikov
        31.01.2018 16:33
        +1

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

        У меня есть мысль, что id это адрес в логической оперативной памяти. Соответственно, создание идентификатора это аналог операции new. Можно использовать большое пространство и выбирать случайную ячейку (GUID), можно выделять память последовательно (autoincrement).


    1. VolCh
      31.01.2018 17:17

      Где-то в UserRepository делаем метод UserId generateNextId() и уже в реализации решаем что конкретно будем использовать, guid, serial, autoincrement и т. д. Можно cделать сигнатуру конструктора User типа (?UserId id, ?UserRepository repo) и в нём код типа this.id = id ?: repo. generateNextId()


      1. boblgum
        31.01.2018 18:53

        Я надеюсь, что вы это не серьёзно имеете в виду. У вас Entity зависит от Repository — не по фэншую.


        1. VolCh
          31.01.2018 19:04

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


          1. boblgum
            31.01.2018 20:06

            очень печально. а по вашему конструктор составная часть чего? то, что Вы не "храните" параметр конструктора в сущности еще не отменяет её зависимость от таковых. посмотрите ещё раз, что подразумевается под зависимостями.


          1. Fesor
            01.02.2018 01:28

            Какой смысл делать это в конструкторе сущности? Это ж усложняет и тесты и вообще… Да и вы же сами прописали на уровне интерфейса что вы уже передаете готовый ID. это может быть как UUID так и следующий элемент секвенса и это уже можно спрятать в фабрику.


        1. Fesor
          31.01.2018 19:40

          UserId передается сверху, потому сущность от инфраструктуры не зависит. Как был получен этот UserId — дело третье.


          1. boblgum
            31.01.2018 20:01

            если Вы это как комментарий к тому, что написал я, то посмотрите еще раз. Там в конструкторе вторым параметром идет репозиторий


            1. Fesor
              01.02.2018 01:27

              да, что-то проглядел. Тогда действительно как-то некрасиво.