При использовании архитектуры в стиле вертикальных слайсов рано или поздно встает вопрос «а что делать, если появляется код, который нужно использовать сразу в нескольких хендлерах?»
TLDR: нужно создать промежуточный слой обработчиков и добавить специализированные маркерные интерфейсы, чтобы было ясно, какие обработчики — холистические абстракции, а какие нет.
Ответ на этот вопрос не всегда очевиден. Джимми Боггард, например, предлагает «просто использовать приемы рефакторинга». Я всецело поддерживаю такой подход, однако форма ответа видится мне такой же обескураживающей, как и предложение воспользоваться свободной монадой для внедрения зависимостей в функциональном программировании. Такая рекомендация точна и коротка, но не слишком полезна. Я попробую ответить на этот вопрос более развернуто.
Рефакторинг
Итак, я буду пользоваться двумя приемами рефакторинга:
Допустим, код обработчика выглядит следующим образом:
public IEnumerable<SomeDto> Handle(SomeQuery q)
{
// 100 строчек кода,
// которые потребуются в нескольких обработчиках
// 50 строчек кода, которые специфичны именно
// для этого обработчика
return result;
}
В реальности, бывает и так, что первые 100 и вторые 50 строчек перемешаны. В этом случае, сначала придется их размотать. Чтобы код не «запутывался», заведите привычку жамкать на ctrl+shift+r -> extract method прямо по ходу разработки. Длинные методы — это фу.
Итак, извлечем два метода, чтобы получилось что-то вроде:
public IEnumerable<SomeDto> Handle(SomeQuery q)
{
var shared = GetShared(q);
var result = GetResult(shared);
return result;
}
Композиция или наследование?
Что же выбрать дальше: композицию или наследование? Композицию. Дело в том, что по мере разрастания логики код может приобрести следующую форму:
public IEnumerable<SomeDto> Handle(SomeQuery q)
{
var shared1 = GetShared1(q);
var shared2 = GetShared2(q);
var shared3 = GetShared3(q);
var shared4 = GetShared4(q);
var result = GetResult(shared1,shared2, shared3, shared4);
return result;
}
В особо сложных случаях структура зависимостей может оказаться весьма разветвленной и тогда вы рискуете нарваться на проблему множественного наследования.
Так что, гораздо безопаснее воспользоваться внедрением зависимостей и паттерном компоновщик.
public class ConcreteQueryHandler:
IQueryHandler<SomeQuery, IEnumerable<SomeDto>>
{
??? _sharedHandler;
public ConcreteQueryHandler(??? sharedHandler)
{
_sharedHandler = sharedHandler;
}
}
Тип промежуточных хендлеров
В слоеной/луковой/чистой/порты-адаптершной архитектурах такая логика обычно находится в слое сервисов предметной области (Domain Services). У нас вместо слоев будут соответствующие вертикальные разрезы и специализированный интерфейс IDomainHandler<TIn, TOut>
, наследуемый от IHandler<TIn, TOut>
.
Некоторые предпочитают использовать вертикальные слайсы на уровне запроса и сервисы на уровне домена. Такой подход безусловно жизнеспособен. Однако он подвержен тем же недостаткам, что и слоеная архитектура — в первую очередь, значительно повышается риск сильной связности приложения.
Поэтому мне больше нравится использовать вертикальную компоновку и на уровне домена.
public class ConcreteQueryHandler2:
IQueryHandler<SomeQuery, IEnumerable<SomeDto>>
{
IDomainHandler<???, ???> _sharedHandler;
public ConcreteQueryHandlerI(IDomainHandler<???, ???> sharedHandler)
{
_sharedHandler = sharedHandler;
}
}
public class ConcreteQueryHandler2:
IQueryHandler<SomeQuery, IEnumerable<SomeDto>>
{
IDomainHandler<???, ???> _sharedHandler;
public ConcreteQueryHandlerI(IDomainHandler<???, ???> sharedHandler)
{
_sharedHandler = sharedHandler;
}
}
Зачем нужны специализированные маркерные интерфейсы?
Возможно, у вас появится соблазн использовать IHandler
во всех случаях. Я не рекомендую так поступать, потому что такой подход почти наверняка приведет либо к проблемам с производительностью, либо к проблемам с сильной связностью в долгосрочном сценарии.
Иронично, что несколько лет назад мне попадалась статья, предостерегающая от использования одного интерфейса на все случаи жизни. Тогда я решил ее проигнорировать, потому что «я сам умный и мне виднее». Вам решать, следовать моему совету или проверять его на практике.
Тип промежуточных хендлеров
Осталось чуть-чуть: решить, какой тип будет у IDomainHandler<???, ???>
. Этот вопрос можно разделить на два:
- Стоит ли мне передавать
ICommand/IQuery
в качестве входного параметра? - Стоит ли мне использовать
IQueryable<T>
в качестве возвращаемого значения?
Стоит ли мне передавать ICommand/IQuery
в качестве входного параметра?
Не стоит, если ваши интерфейсы определены как:
public interface ICommand<TResult>
{
}
public interface IQuery<TResult>
{
}
В зависимости от типа возвращаемого значения IDomainHandler
вам может потребоваться добавлять дополнительные интерфейсы на Command/Query
, что не улучшает читабельность и увеличивает связность кода.
Стоит ли мне использоватьIQueryable<T>
в качестве возвращаемого значения?
Не стоит, если у вас нет ORM:) А вот если он есть… Не смотря на явные проблемы LINQ с LSP я думаю, что ответ на этот вопрос — «зависит». Бывают случаи, когда условия получения данных настолько запутаны и сложны, что одними спецификациями выразить их не получается. В этом случае передача IQueryable
во внутренних слоях приложения — меньшее из зол.
Итого
- Выделяем метод
- Выделяем класс
- Используем специализированные интерфейсы
- Внедряем зависимость слоя предметной области в качестве аргументов конструктора
public class ConcreteQueryHandler:
IQueryHandler<SomeQuery, IEnumerable<SomeDto>>
{
IDomainHandler<
SomeValueObjectAsParam,
IQueryable<SomeDto>>_sharedHandler;
public ConcreteQueryHandler(
IDomainHandler<
SomeValueObjectAsParam,
IQueryable<SomeDto>>)
{
_sharedHandler = sharedHandler;
}
public IEnumerable<SomeDto> Handle(SomeQuery q)
{
var prm = new SomeValueObjectAsParam(q.Param1, q.Param2);
var shared = _sharedHandler.Handle(prm);
var result = shared
.Where(x => x.IsRightForThisUseCase)
.ProjectToType<SomeDto>()
.ToList();
return result;
}
}
KaiOvas
Спасибо за статью. С интересом прочитал про детали имплементации. Обычно такие подробности все спикеры упускают считая их тривиальными или же просто не углубляясь в такие детали (в которых обычно и кроется самое интересное).
Я пару раз начинал работать с CQRS и vertical slices но вот меня всегда останавливало то что потом сыпалось много жалоб от коллег на то что очень сложно проследить взаимозависимость и связи между обработчиками «команд» и «запросами» или же другими «командами» т.е. код обработчика каждой отдельной команды становится прост, и удобен в сопровождении но когда один обрабочик вызывает допустим 2-5 запросов (это могут быть запросы к внешним источникам данных, сервисам поверхностной и глубокой валидации, сервисам внешних правил т.п) и нескольким другим командным обработчикам то это уже становится сложно определяемые зависимости… Я не могу просто привести пример к сожалению. Допускаю что просто не разобрался в этом архитектурном подходе и это было вызвано недостатком понимания или же плохой декомпозицией.
Еще раз спасибо за статью.
marshinov Автор
На прошлом DotNext появился вот такой репозиторий. Там демо-проект со всеми деталями реализации. Без устного пояснения не все может быть понятно, но если интересны детали реализации, то их там полно.
KaiOvas
Спасибо огромное! Это то что я искал.