From 2fded2809f23940240a9a4e1971d7c6b8425f7d5 Mon Sep 17 00:00:00 2001 From: JustFixMe Date: Tue, 4 Feb 2025 20:49:05 +0400 Subject: [PATCH] pipeline cache and dispatch optimizations --- .editorconfig | 9 +++ src/Just.Cqrs.Abstractions/ICommandHandler.cs | 10 ++- src/Just.Cqrs.Abstractions/IQueryHandler.cs | 10 ++- .../Internal/ICommandHandlerImpl.cs | 3 + .../Internal/IGenericHandler.cs | 10 +++ .../Internal/IQueryHandlerImpl.cs | 3 + src/Just.Cqrs/CqrsServicesExtensions.cs | 22 +++--- .../Internal/CommandDispatcherImpl.cs | 38 +++++------ src/Just.Cqrs/Internal/DispatcherBase.cs | 67 +++++++++++++++++++ src/Just.Cqrs/Internal/IMethodsCache.cs | 11 +-- src/Just.Cqrs/Internal/QueryDispatcherImpl.cs | 38 +++++------ .../CommandDispatcherImplTests/Dispatch.cs | 59 +++++++++++++++- .../QueryDispatcherImplTests/Dispatch.cs | 59 +++++++++++++++- 13 files changed, 265 insertions(+), 74 deletions(-) create mode 100644 src/Just.Cqrs.Abstractions/Internal/IGenericHandler.cs create mode 100644 src/Just.Cqrs/Internal/DispatcherBase.cs diff --git a/.editorconfig b/.editorconfig index a3863d3..44f1942 100644 --- a/.editorconfig +++ b/.editorconfig @@ -25,6 +25,15 @@ tab_width = 4 end_of_line = lf insert_final_newline = true +#### .NET diagnostic severity +[*.{cs,vb}] + +# CS9124: Parameter is captured into the state of the enclosing type and its value is also used to initialize a field, property, or event. +dotnet_diagnostic.CS9124.severity = error + +# CS9107: Parameter is captured into the state of the enclosing type and its value is also passed to the base constructor. The value might be captured by the base class as well. +dotnet_diagnostic.CS9107.severity = error + #### .NET Coding Conventions #### [*.{cs,vb}] diff --git a/src/Just.Cqrs.Abstractions/ICommandHandler.cs b/src/Just.Cqrs.Abstractions/ICommandHandler.cs index 6dda11c..c245fb9 100644 --- a/src/Just.Cqrs.Abstractions/ICommandHandler.cs +++ b/src/Just.Cqrs.Abstractions/ICommandHandler.cs @@ -1,9 +1,15 @@ +using System.Diagnostics.CodeAnalysis; using Just.Cqrs.Internal; namespace Just.Cqrs; -public interface ICommandHandler : ICommandHandlerImpl +public interface ICommandHandler : ICommandHandlerImpl, IGenericHandler where TCommand : notnull { - ValueTask Handle(TCommand command, CancellationToken cancellation); + new ValueTask Handle(TCommand command, CancellationToken cancellation); + + [ExcludeFromCodeCoverage] + ValueTask IGenericHandler.Handle( + TCommand request, + CancellationToken cancellationToken) => Handle(request, cancellationToken); } diff --git a/src/Just.Cqrs.Abstractions/IQueryHandler.cs b/src/Just.Cqrs.Abstractions/IQueryHandler.cs index 2d581f4..dff6215 100644 --- a/src/Just.Cqrs.Abstractions/IQueryHandler.cs +++ b/src/Just.Cqrs.Abstractions/IQueryHandler.cs @@ -1,9 +1,15 @@ +using System.Diagnostics.CodeAnalysis; using Just.Cqrs.Internal; namespace Just.Cqrs; -public interface IQueryHandler : IQueryHandlerImpl +public interface IQueryHandler : IQueryHandlerImpl, IGenericHandler where TQuery : notnull { - ValueTask Handle(TQuery query, CancellationToken cancellation); + new ValueTask Handle(TQuery query, CancellationToken cancellation); + + [ExcludeFromCodeCoverage] + ValueTask IGenericHandler.Handle( + TQuery request, + CancellationToken cancellationToken) => Handle(request, cancellationToken); } diff --git a/src/Just.Cqrs.Abstractions/Internal/ICommandHandlerImpl.cs b/src/Just.Cqrs.Abstractions/Internal/ICommandHandlerImpl.cs index d620b06..6a8bfde 100644 --- a/src/Just.Cqrs.Abstractions/Internal/ICommandHandlerImpl.cs +++ b/src/Just.Cqrs.Abstractions/Internal/ICommandHandlerImpl.cs @@ -1,3 +1,6 @@ namespace Just.Cqrs.Internal; +/// +/// Marker interface for static type checking. Should not be used directly. +/// public interface ICommandHandlerImpl { } diff --git a/src/Just.Cqrs.Abstractions/Internal/IGenericHandler.cs b/src/Just.Cqrs.Abstractions/Internal/IGenericHandler.cs new file mode 100644 index 0000000..4b2f06b --- /dev/null +++ b/src/Just.Cqrs.Abstractions/Internal/IGenericHandler.cs @@ -0,0 +1,10 @@ +namespace Just.Cqrs.Internal; + +/// +/// Marker interface for static type checking. Should not be used directly. +/// +public interface IGenericHandler + where TRequest : notnull +{ + ValueTask Handle(TRequest request, CancellationToken cancellation); +} diff --git a/src/Just.Cqrs.Abstractions/Internal/IQueryHandlerImpl.cs b/src/Just.Cqrs.Abstractions/Internal/IQueryHandlerImpl.cs index e5a226b..f7cf43e 100644 --- a/src/Just.Cqrs.Abstractions/Internal/IQueryHandlerImpl.cs +++ b/src/Just.Cqrs.Abstractions/Internal/IQueryHandlerImpl.cs @@ -1,3 +1,6 @@ namespace Just.Cqrs.Internal; +/// +/// Marker interface for static type checking. Should not be used directly. +/// public interface IQueryHandlerImpl { } diff --git a/src/Just.Cqrs/CqrsServicesExtensions.cs b/src/Just.Cqrs/CqrsServicesExtensions.cs index 1c75caa..875bd9e 100644 --- a/src/Just.Cqrs/CqrsServicesExtensions.cs +++ b/src/Just.Cqrs/CqrsServicesExtensions.cs @@ -8,15 +8,19 @@ namespace Microsoft.Extensions.DependencyInjection; public static class CqrsServicesExtensions { + /// + /// Adds all configured Command and Query handlers, behaviors and default implementations of and . + /// + /// + /// If called multiple times and will still be added once + /// public static IServiceCollection AddCqrs(this IServiceCollection services, Action? configure = null) { var options = new CqrsServicesOptions(services); configure?.Invoke(options); - services.TryAddKeyedSingleton(MethodsCacheServiceKey.DispatchCommand); + services.TryAddSingleton(); services.TryAddTransient(); - - services.TryAddKeyedSingleton(MethodsCacheServiceKey.DispatchQuery); services.TryAddTransient(); foreach (var (service, impl, lifetime) in options.CommandHandlers) @@ -75,23 +79,23 @@ public static class CqrsServicesExtensions return options; } - public static CqrsServicesOptions AddOpenBehavior(this CqrsServicesOptions options, Type Behavior, ServiceLifetime lifetime = ServiceLifetime.Singleton) + public static CqrsServicesOptions AddOpenBehavior(this CqrsServicesOptions options, Type behavior, ServiceLifetime lifetime = ServiceLifetime.Singleton) { - var interfaces = Behavior.FindInterfaces( + var interfaces = behavior.FindInterfaces( static (x, t) => x.IsGenericType && x.GetGenericTypeDefinition() == (Type)t!, typeof(IDispatchBehavior<,>)); if (interfaces.Length == 0) { - throw new ArgumentException("Supplied type does not implement IDispatchBehavior<,> interface.", nameof(Behavior)); + throw new ArgumentException("Supplied type does not implement IDispatchBehavior<,> interface.", nameof(behavior)); } - if (!Behavior.ContainsGenericParameters) + if (!behavior.ContainsGenericParameters) { - throw new ArgumentException("Supplied type is not sutable for open Behavior.", nameof(Behavior)); + throw new ArgumentException("Supplied type is not suitable for open Behavior.", nameof(behavior)); } - options.Behaviors.Add((typeof(IDispatchBehavior<,>), Behavior, lifetime)); + options.Behaviors.Add((typeof(IDispatchBehavior<,>), behavior, lifetime)); return options; } diff --git a/src/Just.Cqrs/Internal/CommandDispatcherImpl.cs b/src/Just.Cqrs/Internal/CommandDispatcherImpl.cs index 510d996..ffea746 100644 --- a/src/Just.Cqrs/Internal/CommandDispatcherImpl.cs +++ b/src/Just.Cqrs/Internal/CommandDispatcherImpl.cs @@ -6,27 +6,25 @@ namespace Just.Cqrs.Internal; internal sealed class CommandDispatcherImpl( IServiceProvider services, - [FromKeyedServices(MethodsCacheServiceKey.DispatchCommand)]IMethodsCache methodsCache -) : ICommandDispatcher + IMethodsCache methodsCache +) : DispatcherBase(methodsCache), ICommandDispatcher { + private static readonly Func<(Type RequestType, Type ResponseType), Delegate> CreateDispatchCommandDelegate; + static CommandDispatcherImpl() + { + var dispatcherType = typeof(CommandDispatcherImpl); + var genericDispatchImplMethod = dispatcherType + .GetMethod(nameof(DispatchCommandImpl), BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException($"{nameof(DispatchCommandImpl)} method not found."); + CreateDispatchCommandDelegate = methodsCacheKey => CreateDispatchDelegate(methodsCacheKey, dispatcherType, genericDispatchImplMethod); + } + [ExcludeFromCodeCoverage] public ValueTask Dispatch(object command, CancellationToken cancellationToken) - => DispatchCommand(command, cancellationToken); + => DispatchInternal(CreateDispatchCommandDelegate, command, cancellationToken); public ValueTask Dispatch(IKnownCommand command, CancellationToken cancellationToken) - => DispatchCommand(command, cancellationToken); - - private ValueTask DispatchCommand(object command, CancellationToken cancellationToken) - { - var commandType = command.GetType(); - - var dispatchCommandMethod = methodsCache.GetOrAdd(commandType, static t => typeof(CommandDispatcherImpl) - .GetMethod(nameof(DispatchCommandImpl), BindingFlags.Instance | BindingFlags.NonPublic)! - .MakeGenericMethod(t, typeof(TCommandResult))); - - return (ValueTask)dispatchCommandMethod - .Invoke(this, [command, cancellationToken])!; - } + => DispatchInternal(CreateDispatchCommandDelegate, command, cancellationToken); private ValueTask DispatchCommandImpl( TCommand command, @@ -35,13 +33,7 @@ internal sealed class CommandDispatcherImpl( { var handler = services.GetRequiredService>(); var pipeline = services.GetServices>(); - using var pipelineEnumerator = pipeline.GetEnumerator(); - return DispatchDelegateFactory(pipelineEnumerator).Invoke(); - - DispatchFurtherDelegate DispatchDelegateFactory(IEnumerator> enumerator) => - enumerator.MoveNext() - ? (() => enumerator.Current.Handle(command, DispatchDelegateFactory(enumerator), cancellationToken)) - : (() => handler.Handle(command, cancellationToken)); + return DispatchDelegateFactory(command, handler, pipeline, cancellationToken).Invoke(); } } diff --git a/src/Just.Cqrs/Internal/DispatcherBase.cs b/src/Just.Cqrs/Internal/DispatcherBase.cs new file mode 100644 index 0000000..f47e1e7 --- /dev/null +++ b/src/Just.Cqrs/Internal/DispatcherBase.cs @@ -0,0 +1,67 @@ +using System.Linq.Expressions; +using System.Reflection; + +namespace Just.Cqrs.Internal; + +internal abstract class DispatcherBase(IMethodsCache methodsCache) +{ + protected IMethodsCache MethodsCache { get; } = methodsCache; + protected ValueTask DispatchInternal( + Func<(Type, Type), Delegate> delegateFactory, + object request, + CancellationToken cancellationToken) + { + var cacheKey = (request.GetType(), typeof(TResult)); + var dispatchDelegate = (Func>) + MethodsCache.GetOrAdd(cacheKey, delegateFactory); + return dispatchDelegate(this, request, cancellationToken); + } + + protected DispatchFurtherDelegate DispatchDelegateFactory( + TRequest request, + THandler handler, + IEnumerable> behaviors, + CancellationToken cancellationToken) + where TRequest : notnull + where THandler : IGenericHandler + { + DispatchFurtherDelegate pipeline = behaviors.Reverse() + .Aggregate, DispatchFurtherDelegate>( + () => handler.Handle(request, cancellationToken), + (next, behavior) => () => behavior.Handle(request, next, cancellationToken) + ); + + return pipeline; + } + + internal static Delegate CreateDispatchDelegate((Type RequestType, Type ResponseType) methodsCacheKey, Type dispatcherType, MethodInfo genericDispatchImplMethod) + { + var dispatcherBaseType = typeof(DispatcherBase); + var (requestType, responseType) = methodsCacheKey; + var dispatchImplMethod = genericDispatchImplMethod.MakeGenericMethod(requestType, responseType); + + ParameterExpression[] lambdaParameters = + [ + Expression.Parameter(dispatcherBaseType), + Expression.Parameter(typeof(object)), + Expression.Parameter(typeof(CancellationToken)), + ]; + Expression[] callParameters = + [ + Expression.Convert(lambdaParameters[1], requestType), + lambdaParameters[2], + ]; + var lambdaExpression = Expression.Lambda( + typeof(Func<,,,>).MakeGenericType( + dispatcherBaseType, + typeof(object), + typeof(CancellationToken), + typeof(ValueTask<>).MakeGenericType(responseType)), + Expression.Call(Expression.Convert(lambdaParameters[0], dispatcherType), dispatchImplMethod, callParameters), + lambdaParameters + ); + var dispatchQueryDelegate = lambdaExpression.Compile(); + + return dispatchQueryDelegate; + } +} diff --git a/src/Just.Cqrs/Internal/IMethodsCache.cs b/src/Just.Cqrs/Internal/IMethodsCache.cs index 3732cc7..0b71ff5 100644 --- a/src/Just.Cqrs/Internal/IMethodsCache.cs +++ b/src/Just.Cqrs/Internal/IMethodsCache.cs @@ -1,17 +1,10 @@ using System.Collections.Concurrent; -using System.Reflection; namespace Just.Cqrs.Internal; internal interface IMethodsCache { - MethodInfo GetOrAdd(Type key, Func valueFactory); + Delegate GetOrAdd((Type RequestType, Type ResponseType) key, Func<(Type RequestType, Type ResponseType), Delegate> valueFactory); } -internal static class MethodsCacheServiceKey -{ - internal const string DispatchQuery = "q"; - internal const string DispatchCommand = "c"; -} - -internal sealed class ConcurrentMethodsCache : ConcurrentDictionary, IMethodsCache; +internal sealed class ConcurrentMethodsCache : ConcurrentDictionary<(Type RequestType, Type ResponseType), Delegate>, IMethodsCache; diff --git a/src/Just.Cqrs/Internal/QueryDispatcherImpl.cs b/src/Just.Cqrs/Internal/QueryDispatcherImpl.cs index 1d2a889..12ebc76 100644 --- a/src/Just.Cqrs/Internal/QueryDispatcherImpl.cs +++ b/src/Just.Cqrs/Internal/QueryDispatcherImpl.cs @@ -6,27 +6,25 @@ namespace Just.Cqrs.Internal; internal sealed class QueryDispatcherImpl( IServiceProvider services, - [FromKeyedServices(MethodsCacheServiceKey.DispatchQuery)]IMethodsCache methodsCache -) : IQueryDispatcher + IMethodsCache methodsCache +) : DispatcherBase(methodsCache), IQueryDispatcher { + private static readonly Func<(Type RequestType, Type ResponseType), Delegate> CreateDispatchQueryDelegate; + static QueryDispatcherImpl() + { + var dispatcherType = typeof(QueryDispatcherImpl); + var genericDispatchImplMethod = dispatcherType + .GetMethod(nameof(DispatchQueryImpl), BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException($"{nameof(DispatchQueryImpl)} method not found."); + CreateDispatchQueryDelegate = methodsCacheKey => CreateDispatchDelegate(methodsCacheKey, dispatcherType, genericDispatchImplMethod); + } + [ExcludeFromCodeCoverage] public ValueTask Dispatch(object query, CancellationToken cancellationToken) - => DispatchQuery(query, cancellationToken); + => DispatchInternal(CreateDispatchQueryDelegate, query, cancellationToken); public ValueTask Dispatch(IKnownQuery query, CancellationToken cancellationToken) - => DispatchQuery(query, cancellationToken); - - private ValueTask DispatchQuery(object query, CancellationToken cancellationToken) - { - var queryType = query.GetType(); - - var dispatchQueryMethod = methodsCache.GetOrAdd(queryType, static t => typeof(QueryDispatcherImpl) - .GetMethod(nameof(DispatchQueryImpl), BindingFlags.Instance | BindingFlags.NonPublic)! - .MakeGenericMethod(t, typeof(TQueryResult))); - - return (ValueTask)dispatchQueryMethod - .Invoke(this, [query, cancellationToken])!; - } + => DispatchInternal(CreateDispatchQueryDelegate, query, cancellationToken); private ValueTask DispatchQueryImpl( TQuery query, @@ -35,13 +33,7 @@ internal sealed class QueryDispatcherImpl( { var handler = services.GetRequiredService>(); var pipeline = services.GetServices>(); - using var pipelineEnumerator = pipeline.GetEnumerator(); - return DispatchDelegateFactory(pipelineEnumerator).Invoke(); - - DispatchFurtherDelegate DispatchDelegateFactory(IEnumerator> enumerator) => - enumerator.MoveNext() - ? (() => enumerator.Current.Handle(query, DispatchDelegateFactory(enumerator), cancellationToken)) - : (() => handler.Handle(query, cancellationToken)); + return DispatchDelegateFactory(query, handler, pipeline, cancellationToken).Invoke(); } } diff --git a/tests/Cqrs.Tests/CommandDispatcherImplTests/Dispatch.cs b/tests/Cqrs.Tests/CommandDispatcherImplTests/Dispatch.cs index 4b1df85..03cc780 100644 --- a/tests/Cqrs.Tests/CommandDispatcherImplTests/Dispatch.cs +++ b/tests/Cqrs.Tests/CommandDispatcherImplTests/Dispatch.cs @@ -6,6 +6,10 @@ namespace Cqrs.Tests.CommandDispatcherImplTests; public class Dispatch { + public abstract class TestCommandHandler : ICommandHandler + { + public abstract ValueTask Handle(TestCommand command, CancellationToken cancellation); + } public class TestCommand : IKnownCommand {} public class TestCommandResult {} @@ -19,7 +23,7 @@ public class Dispatch var testCommand = new TestCommand(); var testCommandResult = new TestCommandResult(); - var commandHandler = Substitute.For>(); + var commandHandler = Substitute.For(); commandHandler.Handle(testCommand, CancellationToken.None).Returns(testCommandResult); ServiceCollection serviceCollection = @@ -67,7 +71,7 @@ public class Dispatch var testCommandResult = new TestCommandResult(); List calls = []; - var commandHandler = Substitute.For>(); + var commandHandler = Substitute.For(); commandHandler.Handle(testCommand, CancellationToken.None) .Returns(testCommandResult) .AndDoes(_ => calls.Add("commandHandler")); @@ -131,7 +135,7 @@ public class Dispatch var testCommandResultAborted = new TestCommandResult(); List calls = []; - var commandHandler = Substitute.For>(); + var commandHandler = Substitute.For(); commandHandler.Handle(testCommand, CancellationToken.None) .Returns(testCommandResult) .AndDoes(_ => calls.Add("commandHandler")); @@ -185,4 +189,53 @@ public class Dispatch calls.ShouldBe(["firstBehavior", "secondBehavior"]); } + + public abstract class AnotherTestCommandHandler : ICommandHandler + { + public abstract ValueTask Handle(TestCommand command, CancellationToken cancellation); + } + public class AnotherTestCommandResult {} + + [Fact] + public async Task WhenTwoHandlersWithDifferentResultTypesRegisteredForOneCommandType_ShouldCorrectlyDispatchToBoth() // Fix to Cache Key Collision + { + // Given + var testCommand = new TestCommand(); + var testCommandResult = new TestCommandResult(); + var anotherTestCommandResult = new AnotherTestCommandResult(); + + var commandHandler = Substitute.For(); + commandHandler.Handle(testCommand, CancellationToken.None).Returns(testCommandResult); + + var anotherCommandHandler = Substitute.For(); + anotherCommandHandler.Handle(testCommand, CancellationToken.None).Returns(anotherTestCommandResult); + + ServiceCollection serviceCollection = + [ + new ServiceDescriptor( + typeof(ICommandHandler), + (IServiceProvider _) => commandHandler, + ServiceLifetime.Transient + ), + new ServiceDescriptor( + typeof(ICommandHandler), + (IServiceProvider _) => anotherCommandHandler, + ServiceLifetime.Transient + ), + ]; + var services = serviceCollection.BuildServiceProvider(); + + var sut = new CommandDispatcherImpl(services, new ConcurrentMethodsCache()); + + // When + var result = await sut.Dispatch(testCommand, CancellationToken.None); + var anotherResult = await sut.Dispatch(testCommand, CancellationToken.None); + + // Then + result.ShouldBeSameAs(testCommandResult); + await commandHandler.Received(1).Handle(testCommand, CancellationToken.None); + + anotherResult.ShouldBeSameAs(anotherTestCommandResult); + await anotherCommandHandler.Received(1).Handle(testCommand, CancellationToken.None); + } } diff --git a/tests/Cqrs.Tests/QueryDispatcherImplTests/Dispatch.cs b/tests/Cqrs.Tests/QueryDispatcherImplTests/Dispatch.cs index e0eb5b2..8eb5725 100644 --- a/tests/Cqrs.Tests/QueryDispatcherImplTests/Dispatch.cs +++ b/tests/Cqrs.Tests/QueryDispatcherImplTests/Dispatch.cs @@ -6,6 +6,10 @@ namespace Cqrs.Tests.QueryDispatcherImplTests; public class Dispatch { + public abstract class TestQueryHandler : IQueryHandler + { + public abstract ValueTask Handle(TestQuery query, CancellationToken cancellation); + } public class TestQuery : IKnownQuery {} public class TestQueryResult {} @@ -19,7 +23,7 @@ public class Dispatch var testQuery = new TestQuery(); var testQueryResult = new TestQueryResult(); - var queryHandler = Substitute.For>(); + var queryHandler = Substitute.For(); queryHandler.Handle(testQuery, CancellationToken.None).Returns(testQueryResult); ServiceCollection serviceCollection = @@ -67,7 +71,7 @@ public class Dispatch var testQueryResult = new TestQueryResult(); List calls = []; - var queryHandler = Substitute.For>(); + var queryHandler = Substitute.For(); queryHandler.Handle(testQuery, CancellationToken.None) .Returns(testQueryResult) .AndDoes(_ => calls.Add("queryHandler")); @@ -131,7 +135,7 @@ public class Dispatch var testQueryResultAborted = new TestQueryResult(); List calls = []; - var queryHandler = Substitute.For>(); + var queryHandler = Substitute.For(); queryHandler.Handle(testQuery, CancellationToken.None) .Returns(testQueryResult) .AndDoes(_ => calls.Add("queryHandler")); @@ -185,4 +189,53 @@ public class Dispatch calls.ShouldBe(["firstBehavior", "secondBehavior"]); } + + public abstract class AnotherTestQueryHandler : IQueryHandler + { + public abstract ValueTask Handle(TestQuery query, CancellationToken cancellation); + } + public class AnotherTestQueryResult {} + + [Fact] + public async Task WhenTwoHandlersWithDifferentResultTypesRegisteredForOneQueryType_ShouldCorrectlyDispatchToBoth() // Fix to Cache Key Collision + { + // Given + var testQuery = new TestQuery(); + var testQueryResult = new TestQueryResult(); + var anotherTestQueryResult = new AnotherTestQueryResult(); + + var queryHandler = Substitute.For(); + queryHandler.Handle(testQuery, CancellationToken.None).Returns(testQueryResult); + + var anotherQueryHandler = Substitute.For(); + anotherQueryHandler.Handle(testQuery, CancellationToken.None).Returns(anotherTestQueryResult); + + ServiceCollection serviceCollection = + [ + new ServiceDescriptor( + typeof(IQueryHandler), + (IServiceProvider _) => queryHandler, + ServiceLifetime.Transient + ), + new ServiceDescriptor( + typeof(IQueryHandler), + (IServiceProvider _) => anotherQueryHandler, + ServiceLifetime.Transient + ), + ]; + var services = serviceCollection.BuildServiceProvider(); + + var sut = new QueryDispatcherImpl(services, new ConcurrentMethodsCache()); + + // When + var result = await sut.Dispatch(testQuery, CancellationToken.None); + var anotherResult = await sut.Dispatch(testQuery, CancellationToken.None); + + // Then + result.ShouldBeSameAs(testQueryResult); + await queryHandler.Received(1).Handle(testQuery, CancellationToken.None); + + anotherResult.ShouldBeSameAs(anotherTestQueryResult); + await anotherQueryHandler.Received(1).Handle(testQuery, CancellationToken.None); + } }