Як ми спробували 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<TKey>, проте швидко зрозуміли, що Id можуть використовувати і ValueObject's для передачі команд. Використання IEntity<TKey> для таких об'єктів плутало людей, і ми відмовилися від 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<T>
{
public abstract bool IsSatisfiedBy(T entity)
}; 

Цей інтерфейс дозволяє працювати з колекціями в пам'яті, але ніяк не підходить для побудови SQL-запитів. У сучасному C# більше підходить такий варіант:

public abstract class Specification<T>
{
public bool IsSatisfiedBy(T item)
{
return SatisfyingElementsFrom(new[] { item }.AsQueryable()).Any();
}

public abstract IQueryable < T> SatisfyingElementsFrom(IQueryable < T> candidates);
}

Область застосування
Моделювання предметної області – не просте завдання. DDD передбачає делегування частини завдань по аналітиці розробникам. Це виправдано у випадках, коли вартість помилки велика. Не важливо, як швидко ви написали код і як швидко працює ваша система, якщо вона працює не правильно, і ви втрачаєте гроші. Насправді, вірно зворотне – якщо ви розробляє ПЗ для HFT і до кінця не розумієте, як воно має працювати, краще, щоб ваше ПО гальмувало або взагалі не працювало. Так ви принаймні не будете втрачати гроші на супер-швидкому, але не вірному трейдингу :)

В неусталених бізнесах (особливо стартапах) часто немає ніякого розуміння предметної моделі. Все може змінюватися щодня. В цих умовах марно вимагати від учасників бізнес-процесу використовувати єдину термінологію.

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

У 1980 Бертран Мейер сформулював дуже простий термін CQS. На початку двохтисячних Грег Янг розширив і популяризував цю концепцію. Так з'явився CQRS… і CQRS багато в чому повторив долю DDD, в тому сенсі, що був неодноразово невірно витлумачений.

Незважаючи на те, що матеріалів по CQRS в інтернеті предостатньо, всі «готують його по-різному. Багато команд використовують принципи CQRS, хоча не називають це так. У системі може не бути абстракцій Command Query. Їх Може замінити IOperation або навіть Func<T1, T2> Action<T>.

Цьому є просте пояснення. Перші результати за запитом CQRS видають щось на зразок зображення нижче:



Цю реалізацію Діно Еспозіто називає DELUXE. Справа тут в тому, що CQRS цікавить Грега Янга в основному в контексті Event Sourcing. Насправді для Event Sourcing необхідно використовувати CQRS, але не навпаки.



Таким чином, використовуючи 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<out TOutput>
{
TOutput Ask();
}

[PublicAPI]
public interface IQuery<in TSpecification, out TOutput>
{
TOutput Ask([NotNull] TSpecification spec);
}

[PublicAPI]
public interface IAsyncQuery<TOutput>
: IQuery<Task<TOutput>>
{
}


[PublicAPI]
public interface IAsyncQuery<in TSpecification, TOutput>
: IQuery<TSpecification, Task<TOutput>>
{
}

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

[PublicAPI]
public interface ICommandHandler<in TInput, out TOutput>
{
TOutput Handle(TInput input);
}

[PublicAPI]
public interface IAsyncCommandHandler<in TInput>
: ICommandHandler<TInput, Task>
{
}

[PublicAPI]
public interface IAsyncCommandHandler<in TInput, TOutput>
: ICommandHandler<TInput, Task<TOutput>>
{
}

Ми домовилися про те, що:

  1. Query завжди отримує тільки дані, але не змінює стан системи. Для зміни системи використовуються команди
  2. Query можуть повертати необхідні проекції на пряму, в обхід доменної моделі
У цьому випадкуу відсутності команд все Query завжди повертають однакові результати на однакових вхідних даних. Така організація сильно спрощує налагодження, тому що в Query немає стану, яке могло б змінити повертається результат.

При необхідності Audit Log або повноцінний Event Sourcing можна підключити до всіх обробників команд, через базовий клас.
Не важко помітити, що основні інтерфейси CQRS можна привести до Func<T1, T2> Action<T>. Додайте 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<TSpecification, TSource, TDest>
: IQuery<TSpecification, IEnumerable<TDest>>
, IQuery<TSpecification, int>
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<TDest> GetQueryable(TSpecification spec)
=> LinqProvider
.GetQueryable<TSource>()
.ApplyIfPossible(spec)
.Project<TSource, TDest>(Projector)
.ApplyIfPossible(spec);

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

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

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

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

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

public IQuery<TSpec, IPagedEnumerable<TDto>> AsPaged()
=> this as IQuery<TSpec, IPagedEnumerable<TDto>>;
}

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

AutoFilter Dynamic Linq можуть допомогти, якщо ви працює з великою кількістю однотипних форм.

public static class AutoFilterExtensions
{
public static IQueryable < T> ApplyDictionary<T>(this IQueryable < T> query
, IDictionary<string, object> 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<string, object> 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<T> : ILinqSpecification<T>
where T: class
{
public IDictionary<string, object> Filter { get; } 

public AutoFilter()
{
Filter = new Dictionary<string, object>();
}

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

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

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

Для того, щоб спростити реєстрацію в контейнері можна використовувати угоди.

Висновок
Ми активно використовуємо CQRS без Event Sourcing в роботі і поки враження дуже хороші.

  1. Простіше тестувати код, тому що класи маленькі і гарантовано відповідають тільки за одну річ
  2. З цієї ж причини спрощується внесення змін в систему
  3. Спростилася комунікація, зникли суперечки про те, де той чи інший код повинен знаходитися. Код різних учасників команди став однаковим
  4. DDD використовується для початкового моделювання системи і створення агрегатів. Агрегати можуть взагалі не инстанцироваться, у разі, якщо всі методи над відповідній таблиці жорстко оптимізовані (реалізовані в обхід ORM)
  5. Event Sourcing в full banana – реалізації жодного разу не знадобився, Audit Log реалізується досить часто.

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.