Как мы попробовали DDD, CQRS и Event Sourcing и какие выводы сделали

Вот уже около трех лет я использую в работе принципы Spec By Example, Domain Driven Design и CQRS. За это время накопился опыт практического применения этих практик на платформе .NET. В статье я хочу поделиться нашим опытом и выводами, которые могут быть полезными командам, желающим использовать эти подходы в разработке.

DDD. Выводы


  1. ОЧЕНЬ дорого
  2. Работает хорошо в устоявшихся бизнес-процессах
  3. Иногда — это единственный способ сделать то, что нужно
  4. Плохо масштабируется
  5. Сложно реализовать в высоконагруженных приложениях
  6. Плохо работает в стартапах
  7. Не подходит для построения отчетов
  8. Требует особого внимания с ORM
  9. Слова Entity лучше избегать, потому что его все понимают по-своему
  10. С LINQ стандартная реализация Specification «не работает»

Очень дорого


Все руководители разработки, применяющие DDD, с которыми я обсуждал тему, отметили «дороговизну» этой методологии, в первую очередь из-за отсутствия в книге Эванса ответов на практические вопросы «как мне сделать FooBar, не нарушая принципов DDD?».

Самый распространенный в гугл-группе CQRS, вопрос по словам Грега Янга: «Босс просит меня построить годовой отчет. Когда я поднимаю в оперативную память все корни агрегации у меня начинает все тормозить. Что мне делать?». На этот вопрос есть очевидный ответ: «нужно написать SQL-запрос». Однако, написание ручного SQL-запроса — это однозначно против правил DDD.

Сам Эванс согласился с Янгом в том, что книгу следовало бы написать в другом порядке. Ключевыми являются концепции Bounded Context и Ubiquitous Language, а не Entity и ValueObject.

Отчеты не нуждаются доменной модели. Отчет — это просто таблица с данными. Data Driven — гораздо лучше подходит для отчетов, чем Domain Driven. На первый взгляд в этот момент нужно сказать DDD sucks. Однако, это не так. Просто применение DDD для построения отчетов — не верный Bounded Context.

Bounded Context


  1. Фаулер на английском
  2. Мой материал на русском

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

Entity все понимают по-своем


Мы на своем опыте убедились в том, что очень сложно договориться со всеми членами команды о терминологии. Для нас камнем преткновения стал термин Entity: мы пытались использовать интерфейс IEntity, однако быстро поняли, что Id могут использовать и ValueObject«ы для передачи команд. Использование IEntity для таких объектов путало людей, и мы отказались от IEntity в пользу IHasId.

DDD требует особого внимания с ORM


На Stack Overflow довольно много обсуждений NHibernate vs Entity Framework for DDD. NHibernate, в целом, справляется лучше, но проблем остается много. Стандартный подход при использовании ORM — использование беспараметрических конструкторов и установка значений через сеттеры свойств. Это разлом инкапсуляции. Есть определенные проблемы с коллекциями и Lazy Load. Кроме этого, команда должна принять решение о том, где заканчивается «домен» и начинается «инфраструктура» и как обеспечить Persistence Ignorance.

С LINQ стандартная реализация Specification «не работает»


Эванс — человек из мира Java. Кроме этого книга была написана достаточно давно.
public abstract class Specification
{
    public abstract bool IsSatisfiedBy(T entity)
}; 

Этот интерфейс позволяет работать с коллекциями в памяти, но никак не подходит для построения SQL-запросов. В современном C# больше подходит такой вариант:
public abstract class Specification
{
    public bool IsSatisfiedBy(T item)
    {
        return SatisfyingElementsFrom(new[] { item }.AsQueryable()).Any();
    }

    public abstract IQueryable SatisfyingElementsFrom(IQueryable candidates);
}

Область применения


Моделирование предметной области — не простая задача. DDD предполагает делегирование части задач по аналитике разработчикам. Это оправдано в случаях, когда стоимость ошибки велика. Не важно, как быстро вы написали код и как быстро работает ваша система, если она работает не верно, и вы теряете деньги. На самом деле, верно обратное — если вы разрабатывает ПО для HFT и до конца не понимаете, как оно должно работать, лучше, чтобы ваше ПО тормозило или вообще не работало. Так вы по крайней мере не будете терять деньги на супер-быстром, но не верном трейдинге:)

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

CQRS


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

В 1980 Бертран Мейер сформулировал очень простой термин CQS. В начале двухтысячных Грег Янг расширил и популяризовал эту концепцию. Так появился CQRS… и CQRS во многом повторил судьбу DDD, в том, смысле, что был неоднократно не верно истолкован.

Несмотря на то, что материалов по CQRS в интернете предостаточно, все «готовят» его по-разному. Многие команды используют принципы CQRS, хотя не называют это так. В системе может не быть абстракций Command и Query. Их Может заменить IOperation или даже Func и Action.

Этому есть простое объяснение. Первые результаты по запросу CQRS выдают нечто вроде изображения ниже:

ca1db7b1bb664a439141cddacbd3344e.PNG

Эту реализацию Дино Эспозито называет DELUXE. Дело здесь в том, что CQRS интересует Грега Янга в основном в контексте Event Sourcing. На самом деле для Event Sourcing необходимо использовать CQRS, но не наоборот.

c34399d5a21b481788efa1330e010242.png

Таким образом, используя CQRS мы можем решить проблему тормозных отчетов, разделив стеки приложения на Read и Write и не используя Domain Model в Read-стеке. Read-стек может использовать другую БД и/или другое более оптимальное API доступа к данным.

Разделение приложения на команды, обработчики и запросы имеет еще одно преимущество: лучшая прогнозируемость. В случае DDD, чтобы знать где искать ту или иную бизнес-логику необходимо понимать предметную область. В случае CQRS программист всегда знает, что запись происходит в обработчиках команд, а для доступа к данным используются Query. Кроме этого есть еще несколько не очевидных, на первый взгляд, плюсов. Их мы рассмотрим ниже.

CQRS основные выводы


  1. Event Sourcing требует CQRS, но не наоборот
  2. Дешево
  3. Подходит везде
  4. Масштабируется
  5. Не требует 2 хранилища данных. Эта одна из возможных реализаций, а не обязаловка
  6. Обработчик команды может возвращать значение. Если не согласны спорьте с Грегом Янгом и Дино Эспозито, а не со мной
  7. Если обработчик возвращает значение он хуже масштабируется, однако есть async/await, но надо понимать как они работают

Основные интерфейсы в CQRS могут выглядеть так:
    [PublicAPI]
    public interface IQuery
    {
        TOutput Ask();
    }

    [PublicAPI]
    public interface IQuery
    {
        TOutput Ask([NotNull] TSpecification spec);
    }

    [PublicAPI]
    public interface IAsyncQuery
        : IQuery>
    {
    }


    [PublicAPI]
    public interface IAsyncQuery
        : IQuery>
    {
    }

    [PublicAPI]
    public interface ICommandHandler
    {
         void Handle(TInput input);
    }

    [PublicAPI]
    public interface ICommandHandler
    {
        TOutput Handle(TInput input);
    }

    [PublicAPI]
    public interface IAsyncCommandHandler
        : ICommandHandler
    {
    }

    [PublicAPI]
    public interface IAsyncCommandHandler
        : ICommandHandler>
    {
    }

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

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

При необходимости Audit Log или полноценный Event Sourcing можно подключить ко всем обработчикам команд, через базовый класс.

a7c750269834465d9ba877bf695cb1d3.pngНе трудно заметить, что основные интерфейсы CQRS можно привести к Func и Action. Добавьте stateless и immutable, и вы получите чистые функции (привет функциональное программирование;) Строго говоря, это конечно не так, потому что большинство Query будут работать с файловой системой, БД или сетью. Вы также наверняка захотите закешировать результаты выполнения Query, однако пользу от линеаризации data-flow и компонуемости получить можно.

CQRS over HTTP


Принципы CQRS очень хорошо подходят для реализации по протоколу HTTP. Спецификация HTTP четко говорит GET-запросы должны возвращать данные с сервера. POST, PUT, PATCH — изменять состояние. Хорошим тоном в web-программировании считается редирект на GET после выполнения POST-операции, например, сабмита формы.

Итак


  1. GET– это Query
  2. POST/PUT/PATCH/DELETE — это Command

Базовые классы для часто используемых операций


Отчеты — не единственная частая задача чтения данных. Более общее определение типовых операций чтения это:
  1. Фильтрация
  2. Пагинация (постраничный вывод)
  3. Создание проекций (представление агрегатов в необходимом на клиентской стороне виде)

Мы активно используем AutoMapper для построения проекций. Одной из отличительных особенностей этого маппера являются Queryable-Extensions: возможность построить Expression для преобразования в SQL, вместо маппинга в оперативной памяти. Не всегда эти проекции точны и производительны, но быстрого прототипирования подходят идеально.

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

    public class ProjectionQuery
        : IQuery>
        , IQuery
        where TSource : class, IHasId
        where TDest : class
    {
        protected readonly ILinqProvider LinqProvider;
        protected readonly IProjector Projector;

        public ProjectionQuery([NotNull] ILinqProvider linqProvier, [NotNull] IProjector projector)
        {
            if (linqProvier == null) throw new ArgumentNullException(nameof(linqProvier));
            if (projector == null) throw new ArgumentNullException(nameof(projector));

            LinqProvider = linqProvier;
            Projector = projector;
        }

        protected virtual IQueryable GetQueryable(TSpecification spec)
        => LinqProvider
            .GetQueryable()
            .ApplyIfPossible(spec)
            .Project(Projector)
            .ApplyIfPossible(spec);

        public virtual IEnumerable Ask(TSpecification specification)
            => GetQueryable(specification).ToArray();

        int IQuery.Ask(TSpecification specification)
            => GetQueryable(specification).Count();
    }

    public class PagedQuery : ProjectionQuery,
        IQuery> 
        where TEntity : class, IHasId
        where TDto : class, IHasId
        where TSpec : IPaging
    {
        public PagedQuery(ILinqProvider linqProvier, IProjector projector)
            : base(linqProvier, projector)
        {
        }

        public override IEnumerable Ask(TSpec spec)
            => GetQueryable(spec).Paginate(spec).ToArray();

        IPagedEnumerable IQuery>.Ask(TSpec spec)
            => GetQueryable(spec).ToPagedEnumerable(spec);

        public IQuery> AsPaged()
            => this as IQuery>;
    }

Метод ApplyIfPossible проверит осуществляется фильтрация на уровне агрегата или проекции (бывает нужно и так и так). Метод Project создаст проекцию с помощью AutoMapper.

AutoFilter и Dynamic Linq могут помочь, если вы работает с большим количеством однотипных форм.

    public static class AutoFilterExtensions
    {
        public static IQueryable ApplyDictionary(this IQueryable query
           , IDictionary filters)
        {
            foreach (var kv in filters)
            {
                query = query.Where(kv.Value is string
                    ? $"{kv.Key}.StartsWith(@0)"
                    : $"{kv.Key}=@0", kv.Value);
            }
            return query;
        }

        public static IDictionary GetFilters(this object o) => o.GetType()
            .GetTypeInfo()
            .GetProperties(BindingFlags.Public)
            .Where(x => x.CanRead)
            .ToDictionary(k => k.Name, v => v.GetValue(o));
    }

    public class AutoFilter : ILinqSpecification
        where T: class
    {
        public IDictionary Filter { get; } 

        public AutoFilter()
        {
            Filter = new Dictionary();
        }

        public AutoFilter([NotNull] IDictionary filter)
        {
            if (filter == null) throw new ArgumentNullException(nameof(filter));
            Filter = filter;
        }

        public IQueryable Apply(IQueryable query)
            => query.ApplyDictionary(Filter);
    }

Для построения агрегатов из команд на создание/редактирование можно использовать обобщенный TypeConverter.

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

Заключение


Мы активно используем CQRS без Event Sourcing в работе и пока впечатления очень хорошие.
  1. Проще тестировать код, потому что классы маленькие и гарантированно отвечают только за одну вещь
  2. По этой-же причине упрощается внесение изменений в систему
  3. Упростилась коммуникация, исчезли споры о том где тот или иной код должен находиться. Код разных участников команды стал единообразным
  4. DDD используется для первоначального моделирования системы и создания агрегатов. Агрегаты могут вообще не инстанцироваться, в случае, если все методы над соответствующей таблице жестко оптимизированы (реализованы в обход ORM)
  5. Event Sourcing в full banana — реализации ни разу не потребовался, Audit Log реализуется довольно часто.

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

  • 19 октября 2016 в 16:04

    0

    Обработчик команды может возвращать значение. Если не согласны спорьте с Грегом Янгом

    Можно ссылку на то, где Янг это подтверждает?

    • 19 октября 2016 в 16:43 (комментарий был изменён)

      0

      Здесь на 25:00 после can I user query in command?
      • 19 октября 2016 в 17:11

        0

        И еще 28:00 Про ATM.
        There is no such thing as one way command…
  • 19 октября 2016 в 16:33

    0

    При редактировании объекта, post запрос содержит только изменяемые поля {id: someid, param: newval}, как решаете проблему проверка прав доступа? Если у объекта некоторые поля нельзя менять вообще (createdby), а некоторые может менять только создатель. Так или иначе, при обработке ICommandHandler, объект надо будет загрузить целиком из БД. Это будет IQuery внутри ICommandHandler? Или напрямую через EF запрос делать будете?
    • 19 октября 2016 в 16:51 (комментарий был изменён)

      +1

      При редактировании объекта, post запрос содержит только изменяемые поля {id: someid, param: newval}, как решаете проблему проверка прав доступа?

      В простейшем случае так, хотя лучше использовать АОП:
      void Handle(PostUpdateCommand command)
      {
          if(!CheckAccess(command)) throw new SecurityException("Don't try to hack me!");
          //...
      }
      

      Так или иначе, при обработке ICommandHandler, объект надо будет загрузить целиком из БД. Это будет IQuery внутри ICommandHandler? Или напрямую через EF запрос делать будете?

      По-умолчанию загружаем напрямую весь агрегат из ORM с помощью TypeConverter (ссылка на код в статье) для AutoMapper. ORM абстрагирована за ILinqProvider, поэтому завязки на конкретную реализацию нет. Может использоваться Query для получения каких-то объектов. Если система много пишет, то ORM не используется. Dapper пишет напрямую.
  • 19 октября 2016 в 17:30

    0

    Статья годная, спасибо. На мой взгляд есть проблема over engineering и перекос в сторону strong consistency. Как результат «Обработчик команды может возвращать значение.» и маштабирование через async/await. Если есть опыт работы с eventual consistency реализацией, то будет интересно прочитать аналогичную квинтэссенцию.
    • 19 октября 2016 в 17:38

      0

      Используйте тот ICommandHandler что с void и будет eventual consistency. Если вы когда-нибудь столкнетесь с ЭЦП, то понятно будет зачем возвращать значения. Ну и ссылка на видео с Янгом тоже для вас. Если CommandHandler возвращает bool, например, то это вполне себе ок. А async/await — это вообще холиворная тема. Для приложений активно взаимодействующих с БД / сетью как раз очень неплохо подходит.

© Habrahabr.ru