
QueryHandler<TIn, TOut>, CommandHanler<TIn, TOut>
.Такой подход позволяет «навешивать» на обработчики то, что принято называть cross-cutting concerns без
CQRS не top level architecture, поэтому хочется иметь такие-же декораторы и для классических Application Service. Под катом я расскажу как это сделать.
Что такое сквозная функциональность (cross-cutting concern)
Сross-cutting concern — термин из АОП. К сквозной относится «вспомогательная» функциональность модуля, не относящаяся напрямую к выполняемой задаче, но необходимая, например:
- синхронизация
- обработка ошибок
- валидация
- управление транзациями
- кеширование
- логирование
- мониторинг
Эту логику обычно сложно отделить от основной. Обратите внимание на два примера ниже.
Код без cross-cutting concern
public Book GetBook(int bookId)
=> dbContext.Books.FirstorDefault(x => x.Id == bookId);
Код с cross-cutting concern
public Book GetBook(int bookId)
{
if (!SecurityContext.GetUser().HasRight("GetBook"))
throw new AuthException("Permission Denied");
Log.debug("Call method GetBook with id " + bookId);
Book book = null;
String cacheKey = "getBook:" + bookId;
try
{
if (cache.contains(cacheKey))
{
book = cache.Get<Book>(cacheKey);
}
else
{
book = dbContext.Books.FirstorDefault(x => x.Id == bookId);
cache.Put(cacheKey, book);
}
}
catch(SqlException e)
{
throw new ServiceException(e);
}
Log.Debug("Book info is: " + book.toString());
return book;
}
}
Вместо одной строчки получилось больше двадцати. И главное, этот код придется повторять снова и снова. На помощь приходят декораторы.
Декоратор (англ. Decorator) — структурный шаблон проектирования, предназначенный для динамического подключения дополнительного поведения к объекту. Шаблон Декоратор предоставляет гибкую альтернативу практике создания подклассов с целью расширения функциональности.
Декораторы в CQRS
Например, хочется включить глобальную валидацию. Достаточно объявить вот такой декоратор:
public class ValidationCommandHandlerDecorator<TCommand> : ICommandHandler<TCommand>
{
private readonly IValidator validator;
private readonly ICommandHandler<TCommand> decoratee;
public ValidationCommandHandlerDecorator(IValidator validator,
ICommandHandler<TCommand> decoratee)
{
this.validator = validator;
this.decoratee = decoratee;
}
void ICommandHandler<TCommand>.Handle(TCommand command)
{
// validate the supplied command (throws when invalid).
this.validator.ValidateObject(command);
// forward the (valid) command to the real command handler.
this.decoratee.Handle(command);
}
}
И зарегистрировать его для всех обработчиков команд:
container.RegisterDecorator(
typeof(ICommandHandler<>),
typeof(ValidationCommandHandlerDecorator<>));
Теперь для всех реализаций интерфейса
ICommandHandler
валидация будет происходить в декораторе, а код обработчиков останется простым.public interface ICommandHandler<in TInput, out TOutput>
{
TOutput Handle(TInput command);
}
public class AddBookCommandHandler: ICommandHandler<BookDto, int>
{
public bool Handle(BookDto dto)
{
var entity = Mapper.Map<Book>(dto);
dbContext.Books.Add(entity);
dbContext.SaveChanges();
return entity.Id;
}
}
Но тогда придется писать по набору декораторов для
ICommandHandler
и IQueryHandler
. Можно конечно обойти эту проблему с помощью делегатов. Но получается не очень красиво и применимо только к CQRS, т.е. только в каком-то отдельном ограниченном контексте (bounded context) приложения, где CQRS оправдан.Различие IHandler
и Application Service
Основная проблема с глобальным применением декораторов для сервисного слоя в том, что интерфейсы сервисов сложнее, чем generic handler'ы. Если все обработчики реализуют вот такой generic-интерфейс:
public interface ICommandHandler<in TInput, out TOutput>
{
TOutput Handle(TInput command);
}
То сервисы обычно реализуют по одному методу на каждый use case
public interface IAppService
{
ResponseType UseCase1(RequestType1 request);
ResponseType UseCase2(RequestType2 request);
ResponseType UseCase3(RequestType3 request);
//...
ResponseType UseCaseN(RequestTypeN request);
}
Абстрактный декоратор с валидацией уже не применишь, придется писать по декоратору на каждый сервис, что убивает саму идею написать один раз код валидации и забыть про него. Более того, проще тогда писать код валидации внутри методов, чем декорировать их.
MediatR
Для CQRS можно решить проблему дублирования декораторов, если ввести интерфейс
IRequestHandler
и использовать его для Command
и Query
. Разделение на подсистемы чтения и записи в этом случае ложится на naming conventions. SomeCommandRequestHandler: IRequestHandler
— очевидно, обработчик команд, а SomeQueryRequestHandler: IRequestHandler
— запросов. такой подход реализован в MediatR. В качестве альтернативы декораторам библиотека предоставляет механизм behaviors.IRequestHandler
→ IUseCaseHandler
Почему бы не переименовать интерфейс
IRequestHandler
в IUseCaseHandler
. Обработчики запросов и комманд — холистические абстракции, значит каждый из них обрабатывает use case целиком. Тогда можно переписать архитектуру CQRS следующим образом:public interface IUseCaseHandler<in TInput, out TOutput>
{
TOutput Handle(TInput command);
}
public interface IQueryHandler<in TInput, out TOutput>
: IUseCaseHandler<in TInput, out TOutput>
where TInput: IQuery<TOutput>
{
}
public interface ICommandHandler<in TInput, out TOutput>
: IUseCaseHandler<in TInput, out TOutput>
where TInput: ICommand<TOutput>
{
}
Теперь «общие» декораторы можно вешать на
IUseCaseHandler
. При этом отдельно написать декораторы для ICommandHandler
и IQueryHandler
, например для независимого управления транзакциями.Декораторы для Application Service
Интерфейс
IUseCaseHandler
мы сможем использовать и в Application Services, если воспользуемся явной реализацией.public class AppService
: IAppService
: IUseCaseHandler<RequestType1 , ResponseType1>
: IUseCaseHandler<RequestType2 , ResponseType2>
: IUseCaseHandler<RequestType3, ResponseType3>
//...
: IUseCaseHandler<RequestTypeN, RequestTypeN>
{
public ResponseType1 UseCase1(RequestType1 request)
{
//...
}
IUseCaseHandler<RequestType1 , ResponseType1>.Handle(RequestType1 request)
=> UseCase1(request);
//...
ResponseTypeN UseCaseN(RequestTypeN request)
{
//...
}
IUseCaseHandler<RequestTypeN , ResponseTypeN>.Handle(RequestTypeN request)
=> UseCaseN(request);
//...
}
В прикладном коде необходимо использовать интерфейсы
IUseCaseHandler
, а не IAppService
, потому что декораторы будут применены только к generic-интерфейсу.Обработка ошибок
Вернемся к примеру с валидацией. Валидатор в коде ниже выбрасывает исключение при получении неверной команды. Использовать исключения для обработки пользовательского ввода — вопрос дискуссионный.
void ICommandHandler<TCommand>.Handle(TCommand command)
{
// validate the supplied command (throws when invalid).
this.validator.ValidateObject(command);
// forward the (valid) command to the real command handler.
this.decoratee.Handle(command);
}
Если вы предпочитаете явно указывать в сигнатуре метода, что выполнение может закончиться неудачей, пример выше можно переписать так:
Result ICommandHandler<TCommand>.Handle(TCommand command)
{
return this.validator.ValidateObject(command) && this.decoratee.Handle(command);
}
Таким образом можно будет дополнительно разделить декораторы по типу возвращаемого значения. Например, логировать методы, возвращающие
Result
не так, как методы, возвращающие необернутые значения.