diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json
new file mode 100644
index 0000000..1903d86
--- /dev/null
+++ b/.config/dotnet-tools.json
@@ -0,0 +1,13 @@
+{
+ "version": 1,
+ "isRoot": true,
+ "tools": {
+ "dotnet-reportgenerator-globaltool": {
+ "version": "5.4.3",
+ "commands": [
+ "reportgenerator"
+ ],
+ "rollForward": false
+ }
+ }
+}
\ No newline at end of file
diff --git a/.gitea/workflows/test-dotnet.yaml b/.gitea/workflows/test-dotnet.yaml
new file mode 100644
index 0000000..1ce7bee
--- /dev/null
+++ b/.gitea/workflows/test-dotnet.yaml
@@ -0,0 +1,54 @@
+name: .NET Test
+
+on:
+ push:
+ branches: [ main ]
+ tags-ignore:
+ - '**'
+ paths-ignore:
+ - '.vscode'
+ - 'README.md'
+ - 'LICENSE'
+ - '.gitea/workflows/publish-*.yaml'
+ pull_request:
+ branches: [ main ]
+ paths-ignore:
+ - '.vscode'
+ - 'README.md'
+ - 'LICENSE'
+ - '.gitea/workflows/publish-*.yaml'
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup .NET
+ uses: https://github.com/actions/setup-dotnet@v4
+ with:
+ dotnet-version: '9.x'
+
+ - name: Restore dependencies
+ run: dotnet restore --nologo
+
+ - name: Build
+ run: dotnet build --nologo --configuration Release --no-restore
+
+ - name: Test
+ run: dotnet test --nologo --configuration Release --no-build --verbosity normal --logger trx --collect:"XPlat Code Coverage"
+
+ - name: Restore local tools
+ run: dotnet tool restore
+
+ - name: Generate coverage report
+ run: dotnet reportgenerator -reports:**/coverage.cobertura.xml -targetdir:./coverage -reporttypes:MarkdownSummary
+
+ - name: Upload dotnet test results
+ #uses: actions/upload-artifact@v4
+ uses: christopherhx/gitea-upload-artifact@v4
+ with:
+ name: coverage-results
+ path: coverage
+ if: ${{ always() }}
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000..e2bb9bd
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,4 @@
+{
+ "dotnet.defaultSolution": "Just.Cqrs.sln",
+ "dotnetAcquisitionExtension.enableTelemetry": false
+}
diff --git a/Directory.Build.props b/Directory.Build.props
new file mode 100644
index 0000000..b656c05
--- /dev/null
+++ b/Directory.Build.props
@@ -0,0 +1,17 @@
+
+
+
+ enable
+ enable
+
+ Lightweight, easy-to-use C# library designed to simplify the implementation of the Command Query Responsibility Segregation (CQRS) pattern.
+ JustFixMe
+ Copyright (c) 2025 JustFixMe
+ https://github.com/JustFixMe/Just.Core/
+
+ c#;CQRS
+ LICENSE
+ readme.md
+
+
+
diff --git a/Just.Cqrs.sln b/Just.Cqrs.sln
new file mode 100644
index 0000000..2b7b003
--- /dev/null
+++ b/Just.Cqrs.sln
@@ -0,0 +1,43 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.0.31903.59
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Cqrs.Tests", "tests\Cqrs.Tests\Cqrs.Tests.csproj", "{D0C26007-4570-4B6D-9DE2-4DDF8ECE9290}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Just.Cqrs.Abstractions", "src\Just.Cqrs.Abstractions\Just.Cqrs.Abstractions.csproj", "{7CE7979E-9B98-4532-A172-6FC2BE8897F5}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Just.Cqrs", "src\Just.Cqrs\Just.Cqrs.csproj", "{C474A3F6-8BF3-48A3-A542-C50024AD292E}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {D0C26007-4570-4B6D-9DE2-4DDF8ECE9290}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {D0C26007-4570-4B6D-9DE2-4DDF8ECE9290}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {D0C26007-4570-4B6D-9DE2-4DDF8ECE9290}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {D0C26007-4570-4B6D-9DE2-4DDF8ECE9290}.Release|Any CPU.Build.0 = Release|Any CPU
+ {7CE7979E-9B98-4532-A172-6FC2BE8897F5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {7CE7979E-9B98-4532-A172-6FC2BE8897F5}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {7CE7979E-9B98-4532-A172-6FC2BE8897F5}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {7CE7979E-9B98-4532-A172-6FC2BE8897F5}.Release|Any CPU.Build.0 = Release|Any CPU
+ {C474A3F6-8BF3-48A3-A542-C50024AD292E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {C474A3F6-8BF3-48A3-A542-C50024AD292E}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {C474A3F6-8BF3-48A3-A542-C50024AD292E}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {C474A3F6-8BF3-48A3-A542-C50024AD292E}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(NestedProjects) = preSolution
+ {D0C26007-4570-4B6D-9DE2-4DDF8ECE9290} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
+ {7CE7979E-9B98-4532-A172-6FC2BE8897F5} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
+ {C474A3F6-8BF3-48A3-A542-C50024AD292E} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
+ EndGlobalSection
+EndGlobal
diff --git a/src/Just.Cqrs.Abstractions/ICommandDispatcher.cs b/src/Just.Cqrs.Abstractions/ICommandDispatcher.cs
new file mode 100644
index 0000000..45f8ac8
--- /dev/null
+++ b/src/Just.Cqrs.Abstractions/ICommandDispatcher.cs
@@ -0,0 +1,11 @@
+namespace Just.Cqrs;
+
+public interface ICommandDispatcher
+{
+ ValueTask Dispatch(
+ object command,
+ CancellationToken cancellationToken);
+ ValueTask Dispatch(
+ IKnownCommand command,
+ CancellationToken cancellationToken);
+}
diff --git a/src/Just.Cqrs.Abstractions/ICommandHandler.cs b/src/Just.Cqrs.Abstractions/ICommandHandler.cs
new file mode 100644
index 0000000..6dda11c
--- /dev/null
+++ b/src/Just.Cqrs.Abstractions/ICommandHandler.cs
@@ -0,0 +1,9 @@
+using Just.Cqrs.Internal;
+
+namespace Just.Cqrs;
+
+public interface ICommandHandler : ICommandHandlerImpl
+ where TCommand : notnull
+{
+ ValueTask Handle(TCommand command, CancellationToken cancellation);
+}
diff --git a/src/Just.Cqrs.Abstractions/IDispatchBehaviour.cs b/src/Just.Cqrs.Abstractions/IDispatchBehaviour.cs
new file mode 100644
index 0000000..99bfb34
--- /dev/null
+++ b/src/Just.Cqrs.Abstractions/IDispatchBehaviour.cs
@@ -0,0 +1,38 @@
+using System.Diagnostics.CodeAnalysis;
+
+namespace Just.Cqrs;
+
+///
+/// Delegate representing the rest of the pipeline
+///
+/// Result type of dispatching command/query
+/// Result of executing the rest of the pipeline
+public delegate ValueTask DispatchFurtherDelegate();
+
+///
+/// Marker interface for static type checking. Should not be used directly.
+///
+public interface IDispatchBehaviour
+{
+ Type RequestType { get; }
+ Type ResponseType { get; }
+}
+
+///
+/// Middleware analog for dispatching commands/queries
+///
+/// Request type
+/// Result type of dispatching command/query
+public interface IDispatchBehaviour : IDispatchBehaviour
+ where TRequest : notnull
+{
+ ValueTask Handle(
+ TRequest request,
+ DispatchFurtherDelegate next,
+ CancellationToken cancellationToken);
+
+ [ExcludeFromCodeCoverage]
+ Type IDispatchBehaviour.RequestType => typeof(TRequest);
+ [ExcludeFromCodeCoverage]
+ Type IDispatchBehaviour.ResponseType => typeof(TResponse);
+}
diff --git a/src/Just.Cqrs.Abstractions/IKnownCommand.cs b/src/Just.Cqrs.Abstractions/IKnownCommand.cs
new file mode 100644
index 0000000..a1a61a0
--- /dev/null
+++ b/src/Just.Cqrs.Abstractions/IKnownCommand.cs
@@ -0,0 +1,7 @@
+namespace Just.Cqrs;
+
+///
+/// Marker interface for Command type
+///
+/// Result of dispatching this command
+public interface IKnownCommand{}
diff --git a/src/Just.Cqrs.Abstractions/IKnownQuery.cs b/src/Just.Cqrs.Abstractions/IKnownQuery.cs
new file mode 100644
index 0000000..f1450b5
--- /dev/null
+++ b/src/Just.Cqrs.Abstractions/IKnownQuery.cs
@@ -0,0 +1,7 @@
+namespace Just.Cqrs;
+
+///
+/// Marker interface for Query type
+///
+/// Result of dispatching this query
+public interface IKnownQuery{}
diff --git a/src/Just.Cqrs.Abstractions/IQueryDispatcher.cs b/src/Just.Cqrs.Abstractions/IQueryDispatcher.cs
new file mode 100644
index 0000000..30e1c73
--- /dev/null
+++ b/src/Just.Cqrs.Abstractions/IQueryDispatcher.cs
@@ -0,0 +1,12 @@
+namespace Just.Cqrs;
+
+public interface IQueryDispatcher
+{
+ ValueTask Dispatch(
+ object query,
+ CancellationToken cancellationToken);
+
+ ValueTask Dispatch(
+ IKnownQuery query,
+ CancellationToken cancellationToken);
+}
diff --git a/src/Just.Cqrs.Abstractions/IQueryHandler.cs b/src/Just.Cqrs.Abstractions/IQueryHandler.cs
new file mode 100644
index 0000000..2d581f4
--- /dev/null
+++ b/src/Just.Cqrs.Abstractions/IQueryHandler.cs
@@ -0,0 +1,9 @@
+using Just.Cqrs.Internal;
+
+namespace Just.Cqrs;
+
+public interface IQueryHandler : IQueryHandlerImpl
+ where TQuery : notnull
+{
+ ValueTask Handle(TQuery query, CancellationToken cancellation);
+}
diff --git a/src/Just.Cqrs.Abstractions/Internal/ICommandHandlerImpl.cs b/src/Just.Cqrs.Abstractions/Internal/ICommandHandlerImpl.cs
new file mode 100644
index 0000000..d620b06
--- /dev/null
+++ b/src/Just.Cqrs.Abstractions/Internal/ICommandHandlerImpl.cs
@@ -0,0 +1,3 @@
+namespace Just.Cqrs.Internal;
+
+public interface ICommandHandlerImpl { }
diff --git a/src/Just.Cqrs.Abstractions/Internal/IQueryHandlerImpl.cs b/src/Just.Cqrs.Abstractions/Internal/IQueryHandlerImpl.cs
new file mode 100644
index 0000000..e5a226b
--- /dev/null
+++ b/src/Just.Cqrs.Abstractions/Internal/IQueryHandlerImpl.cs
@@ -0,0 +1,3 @@
+namespace Just.Cqrs.Internal;
+
+public interface IQueryHandlerImpl { }
diff --git a/src/Just.Cqrs.Abstractions/Just.Cqrs.Abstractions.csproj b/src/Just.Cqrs.Abstractions/Just.Cqrs.Abstractions.csproj
new file mode 100644
index 0000000..38450e1
--- /dev/null
+++ b/src/Just.Cqrs.Abstractions/Just.Cqrs.Abstractions.csproj
@@ -0,0 +1,10 @@
+
+
+
+ netstandard2.1
+ latest
+
+ Just.Cqrs
+
+
+
diff --git a/src/Just.Cqrs/CqrsServicesExtensions.cs b/src/Just.Cqrs/CqrsServicesExtensions.cs
new file mode 100644
index 0000000..78e2d66
--- /dev/null
+++ b/src/Just.Cqrs/CqrsServicesExtensions.cs
@@ -0,0 +1,121 @@
+using Just.Cqrs;
+using Just.Cqrs.Internal;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+
+#pragma warning disable IDE0130 // Namespace does not match folder structure
+namespace Microsoft.Extensions.DependencyInjection;
+#pragma warning restore IDE0130
+
+public static class CqrsServicesExtensions
+{
+ public static IServiceCollection AddCqrs(this IServiceCollection services, Action? configure = null)
+ {
+ var options = new CqrsServicesOptions(services);
+ configure?.Invoke(options);
+
+ services.TryAddKeyedSingleton(MethodsCacheServiceKey.DispatchCommand);
+ services.TryAddTransient();
+
+ services.TryAddKeyedSingleton(MethodsCacheServiceKey.DispatchQuery);
+ services.TryAddTransient();
+
+ foreach (var (service, impl, lifetime) in options.CommandHandlers)
+ {
+ services.TryAdd(new ServiceDescriptor(service, impl, lifetime));
+ }
+ foreach (var (service, impl, lifetime) in options.QueryHandlers)
+ {
+ services.TryAdd(new ServiceDescriptor(service, impl, lifetime));
+ }
+ foreach (var (service, impl, lifetime) in options.Behaviours)
+ {
+ services.Add(new ServiceDescriptor(service, impl, lifetime));
+ }
+
+ return services;
+ }
+
+ public static CqrsServicesOptions AddCommandHandler(
+ this CqrsServicesOptions options,
+ ServiceLifetime lifetime = ServiceLifetime.Transient)
+ where TCommandHandler : notnull, ICommandHandlerImpl
+ {
+ var type = typeof(TCommandHandler);
+ var handlerInterfaces = type.FindInterfaces(
+ static (x, t) => x.IsGenericType && x.GetGenericTypeDefinition() == (Type)t!,
+ typeof(ICommandHandler<,>));
+
+ foreach (var interfaceType in handlerInterfaces)
+ {
+ options.CommandHandlers.Add((
+ interfaceType,
+ type,
+ lifetime));
+ }
+ return options;
+ }
+
+ public static CqrsServicesOptions AddQueryHandler(
+ this CqrsServicesOptions options,
+ ServiceLifetime lifetime = ServiceLifetime.Transient)
+ where TQueryHandler : notnull, IQueryHandlerImpl
+ {
+ var type = typeof(TQueryHandler);
+ var handlerInterfaces = type.FindInterfaces(
+ static (x, t) => x.IsGenericType && x.GetGenericTypeDefinition() == (Type)t!,
+ typeof(IQueryHandler<,>));
+
+ foreach (var interfaceType in handlerInterfaces)
+ {
+ options.QueryHandlers.Add((
+ interfaceType,
+ type,
+ lifetime));
+ }
+ return options;
+ }
+
+ public static CqrsServicesOptions AddOpenBehaviour(this CqrsServicesOptions options, Type behaviour, ServiceLifetime lifetime = ServiceLifetime.Singleton)
+ {
+ var interfaces = behaviour.FindInterfaces(
+ static (x, t) => x.IsGenericType && x.GetGenericTypeDefinition() == (Type)t!,
+ typeof(IDispatchBehaviour<,>));
+
+ if (interfaces.Length == 0)
+ {
+ throw new ArgumentException("Supplied type does not implement IDispatchBehaviour<,> interface.", nameof(behaviour));
+ }
+
+ if (!behaviour.ContainsGenericParameters)
+ {
+ throw new ArgumentException("Supplied type is not sutable for open behaviour.", nameof(behaviour));
+ }
+
+ options.Behaviours.Add((typeof(IDispatchBehaviour<,>), behaviour, lifetime));
+ return options;
+ }
+
+ public static CqrsServicesOptions AddBehaviour(this CqrsServicesOptions options, ServiceLifetime lifetime = ServiceLifetime.Singleton)
+ where TBehaviour : notnull, IDispatchBehaviour
+ {
+ var type = typeof(TBehaviour);
+
+ var interfaces = type.FindInterfaces(
+ static (x, t) => x.IsGenericType && x.GetGenericTypeDefinition() == (Type)t!,
+ typeof(IDispatchBehaviour<,>));
+
+ if (interfaces.Length == 0)
+ {
+ throw new InvalidOperationException("Supplied type does not implement IDispatchBehaviour<,> interface.");
+ }
+
+ foreach (var interfaceType in interfaces)
+ {
+ options.Behaviours.Add((
+ interfaceType,
+ type,
+ lifetime));
+ }
+ return options;
+ }
+}
diff --git a/src/Just.Cqrs/CqrsServicesOptions.cs b/src/Just.Cqrs/CqrsServicesOptions.cs
new file mode 100644
index 0000000..9b8392e
--- /dev/null
+++ b/src/Just.Cqrs/CqrsServicesOptions.cs
@@ -0,0 +1,12 @@
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Just.Cqrs;
+
+public sealed class CqrsServicesOptions(IServiceCollection services)
+{
+ internal readonly List<(Type Service, Type Impl, ServiceLifetime Lifetime)> Behaviours = [];
+ internal readonly List<(Type Service, Type Impl, ServiceLifetime Lifetime)> CommandHandlers = [];
+ internal readonly List<(Type Service, Type Impl, ServiceLifetime Lifetime)> QueryHandlers = [];
+
+ public IServiceCollection Services { get; } = services;
+}
diff --git a/src/Just.Cqrs/Internal/CommandDispatcherImpl.cs b/src/Just.Cqrs/Internal/CommandDispatcherImpl.cs
new file mode 100644
index 0000000..e8258bb
--- /dev/null
+++ b/src/Just.Cqrs/Internal/CommandDispatcherImpl.cs
@@ -0,0 +1,47 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Reflection;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Just.Cqrs.Internal;
+
+internal sealed class CommandDispatcherImpl(
+ IServiceProvider services,
+ [FromKeyedServices(MethodsCacheServiceKey.DispatchCommand)]IMethodsCache methodsCache
+) : ICommandDispatcher
+{
+ [ExcludeFromCodeCoverage]
+ public ValueTask Dispatch(object command, CancellationToken cancellationToken)
+ => DispatchCommand(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])!;
+ }
+
+ private ValueTask DispatchCommandImpl(
+ TCommand command,
+ CancellationToken cancellationToken)
+ where TCommand : notnull
+ {
+ 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));
+ }
+}
diff --git a/src/Just.Cqrs/Internal/IMethodsCache.cs b/src/Just.Cqrs/Internal/IMethodsCache.cs
new file mode 100644
index 0000000..3732cc7
--- /dev/null
+++ b/src/Just.Cqrs/Internal/IMethodsCache.cs
@@ -0,0 +1,17 @@
+using System.Collections.Concurrent;
+using System.Reflection;
+
+namespace Just.Cqrs.Internal;
+
+internal interface IMethodsCache
+{
+ MethodInfo GetOrAdd(Type key, Func valueFactory);
+}
+
+internal static class MethodsCacheServiceKey
+{
+ internal const string DispatchQuery = "q";
+ internal const string DispatchCommand = "c";
+}
+
+internal sealed class ConcurrentMethodsCache : ConcurrentDictionary, IMethodsCache;
diff --git a/src/Just.Cqrs/Internal/QueryDispatcherImpl.cs b/src/Just.Cqrs/Internal/QueryDispatcherImpl.cs
new file mode 100644
index 0000000..7ba0d09
--- /dev/null
+++ b/src/Just.Cqrs/Internal/QueryDispatcherImpl.cs
@@ -0,0 +1,47 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Reflection;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Just.Cqrs.Internal;
+
+internal sealed class QueryDispatcherImpl(
+ IServiceProvider services,
+ [FromKeyedServices(MethodsCacheServiceKey.DispatchQuery)]IMethodsCache methodsCache
+) : IQueryDispatcher
+{
+ [ExcludeFromCodeCoverage]
+ public ValueTask Dispatch(object query, CancellationToken cancellationToken)
+ => DispatchQuery(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])!;
+ }
+
+ private ValueTask DispatchQueryImpl(
+ TQuery query,
+ CancellationToken cancellationToken)
+ where TQuery : notnull
+ {
+ 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));
+ }
+}
diff --git a/src/Just.Cqrs/Just.Cqrs.csproj b/src/Just.Cqrs/Just.Cqrs.csproj
new file mode 100644
index 0000000..872a7b3
--- /dev/null
+++ b/src/Just.Cqrs/Just.Cqrs.csproj
@@ -0,0 +1,23 @@
+
+
+
+ netstandard2.1;net8.0;net9.0
+ latest
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/Cqrs.Tests/CommandDispatcherImplTests/Dispatch.cs b/tests/Cqrs.Tests/CommandDispatcherImplTests/Dispatch.cs
new file mode 100644
index 0000000..bd0d793
--- /dev/null
+++ b/tests/Cqrs.Tests/CommandDispatcherImplTests/Dispatch.cs
@@ -0,0 +1,188 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using NSubstitute;
+
+namespace Cqrs.Tests.CommandDispatcherImplTests;
+
+public class Dispatch
+{
+ public class TestCommand : IKnownCommand {}
+ public class TestCommandResult {}
+
+ [Theory]
+ [InlineData(ServiceLifetime.Transient)]
+ [InlineData(ServiceLifetime.Scoped)]
+ [InlineData(ServiceLifetime.Singleton)]
+ public async Task WhenCalled_ShouldExecuteHandler(ServiceLifetime lifetime)
+ {
+ // Given
+ var testCommand = new TestCommand();
+ var testCommandResult = new TestCommandResult();
+
+ var commandHandler = Substitute.For>();
+ commandHandler.Handle(testCommand, CancellationToken.None).Returns(testCommandResult);
+
+ ServiceCollection serviceCollection =
+ [
+ new ServiceDescriptor(
+ typeof(ICommandHandler),
+ (IServiceProvider _) => commandHandler,
+ lifetime
+ ),
+ ];
+ var services = serviceCollection.BuildServiceProvider();
+
+ var sut = new CommandDispatcherImpl(services, new ConcurrentMethodsCache());
+
+ // When
+ var result = await sut.Dispatch(testCommand, CancellationToken.None);
+
+ // Then
+ result.ShouldBeSameAs(testCommandResult);
+ await commandHandler.Received(1).Handle(testCommand, CancellationToken.None);
+ }
+
+ public class TestOpenBehaviour : IDispatchBehaviour
+ where TRequest : notnull
+ {
+ private readonly Action _callback;
+
+ public TestOpenBehaviour(Action callback)
+ {
+ _callback = callback;
+ }
+
+ public ValueTask Handle(TRequest request, DispatchFurtherDelegate next, CancellationToken cancellationToken)
+ {
+ _callback.Invoke(request);
+ return next();
+ }
+ }
+
+ [Fact]
+ public async Task WhenPipelineConfigured_ShouldCallAllBehavioursInOrder()
+ {
+ // Given
+ var testCommand = new TestCommand();
+ var testCommandResult = new TestCommandResult();
+ List calls = [];
+
+ var commandHandler = Substitute.For>();
+ commandHandler.Handle(testCommand, CancellationToken.None)
+ .Returns(testCommandResult)
+ .AndDoes(_ => calls.Add("commandHandler"));
+
+ var firstBehaviour = Substitute.For>();
+ firstBehaviour.Handle(testCommand, Arg.Any>(), Arg.Any())
+ .Returns(args => ((DispatchFurtherDelegate)args[1]).Invoke())
+ .AndDoes(_ => calls.Add("firstBehaviour"));
+
+ var secondBehaviour = Substitute.For>();
+ secondBehaviour.Handle(testCommand, Arg.Any>(), Arg.Any())
+ .Returns(args => ((DispatchFurtherDelegate)args[1]).Invoke())
+ .AndDoes(_ => calls.Add("secondBehaviour"));
+
+ ServiceCollection serviceCollection =
+ [
+ new ServiceDescriptor(
+ typeof(ICommandHandler),
+ (IServiceProvider _) => commandHandler,
+ ServiceLifetime.Transient
+ ),
+ new ServiceDescriptor(
+ typeof(IDispatchBehaviour),
+ (IServiceProvider _) => firstBehaviour,
+ ServiceLifetime.Transient
+ ),
+ new ServiceDescriptor(
+ typeof(IDispatchBehaviour),
+ (IServiceProvider _) => secondBehaviour,
+ ServiceLifetime.Transient
+ ),
+ new ServiceDescriptor(
+ typeof(IDispatchBehaviour<,>),
+ typeof(TestOpenBehaviour<,>),
+ ServiceLifetime.Transient
+ ),
+ ];
+ serviceCollection.AddTransient>(_ => (TestCommand _) => calls.Add("thirdBehaviour"));
+ var services = serviceCollection.BuildServiceProvider();
+
+ var sut = new CommandDispatcherImpl(services, new ConcurrentMethodsCache());
+
+ // When
+ var result = await sut.Dispatch(testCommand, CancellationToken.None);
+
+ // Then
+ result.ShouldBeSameAs(testCommandResult);
+ await firstBehaviour.Received(1).Handle(testCommand, Arg.Any>(), Arg.Any());
+ await secondBehaviour.Received(1).Handle(testCommand, Arg.Any>(), Arg.Any());
+ await commandHandler.Received(1).Handle(testCommand, CancellationToken.None);
+
+ calls.ShouldBe(["firstBehaviour", "secondBehaviour", "thirdBehaviour", "commandHandler"]);
+ }
+
+ [Fact]
+ public async Task WhenNextIsNotCalled_ShouldStopExecutingPipeline()
+ {
+ // Given
+ var testCommand = new TestCommand();
+ var testCommandResult = new TestCommandResult();
+ var testCommandResultAborted = new TestCommandResult();
+ List calls = [];
+
+ var commandHandler = Substitute.For>();
+ commandHandler.Handle(testCommand, CancellationToken.None)
+ .Returns(testCommandResult)
+ .AndDoes(_ => calls.Add("commandHandler"));
+
+ var firstBehaviour = Substitute.For>();
+ firstBehaviour.Handle(testCommand, Arg.Any>(), Arg.Any())
+ .Returns(args => ((DispatchFurtherDelegate)args[1]).Invoke())
+ .AndDoes(_ => calls.Add("firstBehaviour"));
+
+ var secondBehaviour = Substitute.For>();
+ secondBehaviour.Handle(testCommand, Arg.Any>(), Arg.Any())
+ .Returns(args => ValueTask.FromResult(testCommandResultAborted))
+ .AndDoes(_ => calls.Add("secondBehaviour"));
+
+ ServiceCollection serviceCollection =
+ [
+ new ServiceDescriptor(
+ typeof(ICommandHandler),
+ (IServiceProvider _) => commandHandler,
+ ServiceLifetime.Transient
+ ),
+ new ServiceDescriptor(
+ typeof(IDispatchBehaviour),
+ (IServiceProvider _) => firstBehaviour,
+ ServiceLifetime.Transient
+ ),
+ new ServiceDescriptor(
+ typeof(IDispatchBehaviour),
+ (IServiceProvider _) => secondBehaviour,
+ ServiceLifetime.Transient
+ ),
+ new ServiceDescriptor(
+ typeof(IDispatchBehaviour<,>),
+ typeof(TestOpenBehaviour<,>),
+ ServiceLifetime.Transient
+ ),
+ ];
+ serviceCollection.AddTransient>(_ => (TestCommand _) => calls.Add("thirdBehaviour"));
+ var services = serviceCollection.BuildServiceProvider();
+
+ var sut = new CommandDispatcherImpl(services, new ConcurrentMethodsCache());
+
+ // When
+ var result = await sut.Dispatch(testCommand, CancellationToken.None);
+
+ // Then
+ result.ShouldBeSameAs(testCommandResultAborted);
+ await firstBehaviour.Received(1).Handle(testCommand, Arg.Any>(), Arg.Any());
+ await secondBehaviour.Received(1).Handle(testCommand, Arg.Any>(), Arg.Any());
+ await commandHandler.Received(0).Handle(testCommand, CancellationToken.None);
+
+ calls.ShouldBe(["firstBehaviour", "secondBehaviour"]);
+ }
+}
diff --git a/tests/Cqrs.Tests/Cqrs.Tests.csproj b/tests/Cqrs.Tests/Cqrs.Tests.csproj
new file mode 100644
index 0000000..3fc76ee
--- /dev/null
+++ b/tests/Cqrs.Tests/Cqrs.Tests.csproj
@@ -0,0 +1,39 @@
+
+
+
+ net8.0;net9.0
+ latest
+
+ false
+
+
+
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/Cqrs.Tests/CqrsServicesExtensionsTests/AddBehaviour.cs b/tests/Cqrs.Tests/CqrsServicesExtensionsTests/AddBehaviour.cs
new file mode 100644
index 0000000..ba6bc88
--- /dev/null
+++ b/tests/Cqrs.Tests/CqrsServicesExtensionsTests/AddBehaviour.cs
@@ -0,0 +1,63 @@
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Cqrs.Tests.CqrsServicesExtensionsTests;
+
+public class AddBehaviour
+{
+ public class TestCommand {}
+ public class TestCommandResult {}
+ [ExcludeFromCodeCoverage]
+ public class NonGenericTestOpenBehaviour : IDispatchBehaviour
+ {
+ public ValueTask Handle(TestCommand request, DispatchFurtherDelegate next, CancellationToken cancellationToken)
+ {
+ throw new NotImplementedException();
+ }
+ }
+
+ [Theory]
+ [InlineData(ServiceLifetime.Transient)]
+ [InlineData(ServiceLifetime.Scoped)]
+ [InlineData(ServiceLifetime.Singleton)]
+ public void WhenCalled_ShouldRegisterDispatchBehaviour(ServiceLifetime lifetime)
+ {
+ // Given
+ ServiceCollection services = new();
+
+ // When
+ services.AddCqrs(opt => opt
+ .AddBehaviour(lifetime));
+
+ // Then
+ services.ShouldContain(
+ elementPredicate: descriptor =>
+ descriptor.ServiceType == typeof(IDispatchBehaviour)
+ && descriptor.ImplementationType == typeof(NonGenericTestOpenBehaviour)
+ && descriptor.Lifetime == lifetime,
+ expectedCount: 1
+ );
+ }
+
+ [ExcludeFromCodeCoverage]
+ public class InvalidTestBehaviour : IDispatchBehaviour
+ {
+ public Type RequestType => throw new NotImplementedException();
+
+ public Type ResponseType => throw new NotImplementedException();
+ }
+
+ [Fact]
+ public void WhenCalledWithInvalidType_ShouldThrow()
+ {
+ // Given
+ ServiceCollection services = new();
+
+ // When
+
+ // Then
+ Should.Throw(() => services.AddCqrs(opt => opt
+ .AddBehaviour())
+ );
+ }
+}
diff --git a/tests/Cqrs.Tests/CqrsServicesExtensionsTests/AddCommandHandler.cs b/tests/Cqrs.Tests/CqrsServicesExtensionsTests/AddCommandHandler.cs
new file mode 100644
index 0000000..942a894
--- /dev/null
+++ b/tests/Cqrs.Tests/CqrsServicesExtensionsTests/AddCommandHandler.cs
@@ -0,0 +1,124 @@
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Cqrs.Tests.CqrsServicesExtensionsTests;
+
+public class AddCommandHandler
+{
+ public class TestCommand {}
+ public class TestCommandResult {}
+ [ExcludeFromCodeCoverage]
+ public class TestCommandHandler : ICommandHandler
+ {
+ public ValueTask Handle(TestCommand command, CancellationToken cancellation)
+ {
+ throw new NotImplementedException();
+ }
+ }
+
+ [Theory]
+ [InlineData(ServiceLifetime.Transient)]
+ [InlineData(ServiceLifetime.Scoped)]
+ [InlineData(ServiceLifetime.Singleton)]
+ public void WhenCalled_ShouldRegisterCommandHandler(ServiceLifetime lifetime)
+ {
+ // Given
+ ServiceCollection services = new();
+
+ // When
+ services.AddCqrs(opt => opt.AddCommandHandler(lifetime));
+
+ // Then
+ services.ShouldContain(
+ elementPredicate: descriptor =>
+ descriptor.ServiceType == typeof(ICommandHandler)
+ && descriptor.ImplementationType == typeof(TestCommandHandler)
+ && descriptor.Lifetime == lifetime,
+ expectedCount: 1
+ );
+ }
+
+ [Fact]
+ public void WhenCalledMultipleTimes_ShouldRegisterCommandHandlerOnce()
+ {
+ // Given
+ ServiceCollection services = new();
+
+ // When
+ services.AddCqrs(opt => opt
+ .AddCommandHandler()
+ .AddCommandHandler()
+ .AddCommandHandler());
+
+ services.AddCqrs(opt => opt
+ .AddCommandHandler());
+
+ // Then
+ services.ShouldContain(
+ elementPredicate: descriptor =>
+ descriptor.ServiceType == typeof(ICommandHandler)
+ && descriptor.ImplementationType == typeof(TestCommandHandler)
+ && descriptor.Lifetime == ServiceLifetime.Transient,
+ expectedCount: 1
+ );
+ }
+
+ public class SecondTestCommand {}
+ public class SecondTestCommandResult {}
+ [ExcludeFromCodeCoverage]
+ public class SecondTestCommandHandler : ICommandHandler
+ {
+ public ValueTask Handle(SecondTestCommand command, CancellationToken cancellation)
+ {
+ throw new NotImplementedException();
+ }
+ }
+
+ public class ThirdTestCommand {}
+ public class ThirdTestCommandResult {}
+ [ExcludeFromCodeCoverage]
+ public class ThirdTestCommandHandler : ICommandHandler
+ {
+ public ValueTask Handle(ThirdTestCommand command, CancellationToken cancellation)
+ {
+ throw new NotImplementedException();
+ }
+ }
+
+ [Fact]
+ public void WhenCalledMultipleTimes_ShouldRegisterAllCommandHandlers()
+ {
+ // Given
+ ServiceCollection services = new();
+
+ // When
+ services.AddCqrs(opt => opt
+ .AddCommandHandler()
+ .AddCommandHandler());
+ services.AddCqrs(opt => opt
+ .AddCommandHandler());
+
+ // Then
+ services.ShouldContain(
+ elementPredicate: descriptor =>
+ descriptor.ServiceType == typeof(ICommandHandler)
+ && descriptor.ImplementationType == typeof(TestCommandHandler)
+ && descriptor.Lifetime == ServiceLifetime.Transient,
+ expectedCount: 1
+ );
+ services.ShouldContain(
+ elementPredicate: descriptor =>
+ descriptor.ServiceType == typeof(ICommandHandler)
+ && descriptor.ImplementationType == typeof(SecondTestCommandHandler)
+ && descriptor.Lifetime == ServiceLifetime.Transient,
+ expectedCount: 1
+ );
+ services.ShouldContain(
+ elementPredicate: descriptor =>
+ descriptor.ServiceType == typeof(ICommandHandler)
+ && descriptor.ImplementationType == typeof(ThirdTestCommandHandler)
+ && descriptor.Lifetime == ServiceLifetime.Transient,
+ expectedCount: 1
+ );
+ }
+}
diff --git a/tests/Cqrs.Tests/CqrsServicesExtensionsTests/AddOpenBehaviour.cs b/tests/Cqrs.Tests/CqrsServicesExtensionsTests/AddOpenBehaviour.cs
new file mode 100644
index 0000000..c994a4a
--- /dev/null
+++ b/tests/Cqrs.Tests/CqrsServicesExtensionsTests/AddOpenBehaviour.cs
@@ -0,0 +1,89 @@
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Cqrs.Tests.CqrsServicesExtensionsTests;
+
+public class AddOpenBehaviour
+{
+ [ExcludeFromCodeCoverage]
+ public class TestOpenBehaviour : IDispatchBehaviour
+ where TRequest: notnull
+ {
+ public ValueTask Handle(TRequest request, DispatchFurtherDelegate next, CancellationToken cancellationToken)
+ {
+ throw new NotImplementedException();
+ }
+ }
+
+ [Theory]
+ [InlineData(ServiceLifetime.Transient)]
+ [InlineData(ServiceLifetime.Scoped)]
+ [InlineData(ServiceLifetime.Singleton)]
+ public void WhenCalled_ShouldRegisterOpenDispatchBehaviour(ServiceLifetime lifetime)
+ {
+ // Given
+ ServiceCollection services = new();
+
+ // When
+ services.AddCqrs(opt => opt
+ .AddOpenBehaviour(typeof(TestOpenBehaviour<,>), lifetime));
+
+ // Then
+ services.ShouldContain(
+ elementPredicate: descriptor =>
+ descriptor.ServiceType == typeof(IDispatchBehaviour<,>)
+ && descriptor.ImplementationType == typeof(TestOpenBehaviour<,>)
+ && descriptor.Lifetime == lifetime,
+ expectedCount: 1
+ );
+ }
+
+ [ExcludeFromCodeCoverage]
+ public class InvalidOpenBehaviour : IDispatchBehaviour
+ {
+ public Type RequestType => throw new NotImplementedException();
+
+ public Type ResponseType => throw new NotImplementedException();
+ }
+
+ [Fact]
+ public void WhenCalledWithInvalidType_ShouldThrow()
+ {
+ // Given
+ ServiceCollection services = new();
+
+ // When
+ var invalidOpenDispatchBehaviourType = typeof(InvalidOpenBehaviour);
+
+ // Then
+ Should.Throw(() => services.AddCqrs(opt => opt
+ .AddOpenBehaviour(invalidOpenDispatchBehaviourType))
+ );
+ }
+
+ public class TestCommand {}
+ public class TestCommandResult {}
+ [ExcludeFromCodeCoverage]
+ public class NonGenericTestOpenBehaviour : IDispatchBehaviour
+ {
+ public ValueTask Handle(TestCommand request, DispatchFurtherDelegate next, CancellationToken cancellationToken)
+ {
+ throw new NotImplementedException();
+ }
+ }
+
+ [Fact]
+ public void WhenCalledWithNonGenericType_ShouldThrow()
+ {
+ // Given
+ ServiceCollection services = new();
+
+ // When
+ var nonGenericOpenDispatchBehaviourType = typeof(NonGenericTestOpenBehaviour);
+
+ // Then
+ Should.Throw(() => services.AddCqrs(opt => opt
+ .AddOpenBehaviour(nonGenericOpenDispatchBehaviourType))
+ );
+ }
+}
diff --git a/tests/Cqrs.Tests/CqrsServicesExtensionsTests/AddQueryHandler.cs b/tests/Cqrs.Tests/CqrsServicesExtensionsTests/AddQueryHandler.cs
new file mode 100644
index 0000000..37a797d
--- /dev/null
+++ b/tests/Cqrs.Tests/CqrsServicesExtensionsTests/AddQueryHandler.cs
@@ -0,0 +1,124 @@
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Cqrs.Tests.CqrsServicesExtensionsTests;
+
+public class AddQueryHandler
+{
+ public class TestQuery {}
+ public class TestQueryResult {}
+ [ExcludeFromCodeCoverage]
+ public class TestQueryHandler : IQueryHandler
+ {
+ public ValueTask Handle(TestQuery Query, CancellationToken cancellation)
+ {
+ throw new NotImplementedException();
+ }
+ }
+
+ [Theory]
+ [InlineData(ServiceLifetime.Transient)]
+ [InlineData(ServiceLifetime.Scoped)]
+ [InlineData(ServiceLifetime.Singleton)]
+ public void WhenCalled_ShouldRegisterQueryHandler(ServiceLifetime lifetime)
+ {
+ // Given
+ ServiceCollection services = new();
+
+ // When
+ services.AddCqrs(opt => opt.AddQueryHandler(lifetime));
+
+ // Then
+ services.ShouldContain(
+ elementPredicate: descriptor =>
+ descriptor.ServiceType == typeof(IQueryHandler)
+ && descriptor.ImplementationType == typeof(TestQueryHandler)
+ && descriptor.Lifetime == lifetime,
+ expectedCount: 1
+ );
+ }
+
+ [Fact]
+ public void WhenCalledMultipleTimes_ShouldRegisterQueryHandlerOnce()
+ {
+ // Given
+ ServiceCollection services = new();
+
+ // When
+ services.AddCqrs(opt => opt
+ .AddQueryHandler()
+ .AddQueryHandler()
+ .AddQueryHandler());
+
+ services.AddCqrs(opt => opt
+ .AddQueryHandler());
+
+ // Then
+ services.ShouldContain(
+ elementPredicate: descriptor =>
+ descriptor.ServiceType == typeof(IQueryHandler)
+ && descriptor.ImplementationType == typeof(TestQueryHandler)
+ && descriptor.Lifetime == ServiceLifetime.Transient,
+ expectedCount: 1
+ );
+ }
+
+ public class SecondTestQuery {}
+ public class SecondTestQueryResult {}
+ [ExcludeFromCodeCoverage]
+ public class SecondTestQueryHandler : IQueryHandler
+ {
+ public ValueTask Handle(SecondTestQuery Query, CancellationToken cancellation)
+ {
+ throw new NotImplementedException();
+ }
+ }
+
+ public class ThirdTestQuery {}
+ public class ThirdTestQueryResult {}
+ [ExcludeFromCodeCoverage]
+ public class ThirdTestQueryHandler : IQueryHandler
+ {
+ public ValueTask Handle(ThirdTestQuery Query, CancellationToken cancellation)
+ {
+ throw new NotImplementedException();
+ }
+ }
+
+ [Fact]
+ public void WhenCalledMultipleTimes_ShouldRegisterAllQueryHandlers()
+ {
+ // Given
+ ServiceCollection services = new();
+
+ // When
+ services.AddCqrs(opt => opt
+ .AddQueryHandler()
+ .AddQueryHandler());
+ services.AddCqrs(opt => opt
+ .AddQueryHandler());
+
+ // Then
+ services.ShouldContain(
+ elementPredicate: descriptor =>
+ descriptor.ServiceType == typeof(IQueryHandler)
+ && descriptor.ImplementationType == typeof(TestQueryHandler)
+ && descriptor.Lifetime == ServiceLifetime.Transient,
+ expectedCount: 1
+ );
+ services.ShouldContain(
+ elementPredicate: descriptor =>
+ descriptor.ServiceType == typeof(IQueryHandler)
+ && descriptor.ImplementationType == typeof(SecondTestQueryHandler)
+ && descriptor.Lifetime == ServiceLifetime.Transient,
+ expectedCount: 1
+ );
+ services.ShouldContain(
+ elementPredicate: descriptor =>
+ descriptor.ServiceType == typeof(IQueryHandler)
+ && descriptor.ImplementationType == typeof(ThirdTestQueryHandler)
+ && descriptor.Lifetime == ServiceLifetime.Transient,
+ expectedCount: 1
+ );
+ }
+}
diff --git a/tests/Cqrs.Tests/CqrsServicesExtensionsTests/AddSqrs.cs b/tests/Cqrs.Tests/CqrsServicesExtensionsTests/AddSqrs.cs
new file mode 100644
index 0000000..ce1a7f3
--- /dev/null
+++ b/tests/Cqrs.Tests/CqrsServicesExtensionsTests/AddSqrs.cs
@@ -0,0 +1,63 @@
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Cqrs.Tests.CqrsServicesExtensionsTests;
+
+public class AddCqrs
+{
+ [Fact]
+ public void WhenCalled_ShouldRegisterDispatcherClasses()
+ {
+ // Given
+ ServiceCollection services = new();
+
+ // When
+ services.AddCqrs();
+
+ // Then
+ services.ShouldContain(
+ elementPredicate: descriptor =>
+ descriptor.ServiceType == typeof(ICommandDispatcher)
+ && descriptor.ImplementationType == typeof(CommandDispatcherImpl)
+ && descriptor.Lifetime == ServiceLifetime.Transient,
+ expectedCount: 1
+ );
+
+ services.ShouldContain(
+ elementPredicate: descriptor =>
+ descriptor.ServiceType == typeof(IQueryDispatcher)
+ && descriptor.ImplementationType == typeof(QueryDispatcherImpl)
+ && descriptor.Lifetime == ServiceLifetime.Transient,
+ expectedCount: 1
+ );
+ }
+
+ [Fact]
+ public void WhenCalledMultipleTimes_ShouldRegisterDispatcherClassesOnce()
+ {
+ // Given
+ ServiceCollection services = new();
+
+ // When
+ services.AddCqrs();
+ services.AddCqrs();
+ services.AddCqrs();
+ services.AddCqrs();
+
+ // Then
+ services.ShouldContain(
+ elementPredicate: descriptor =>
+ descriptor.ServiceType == typeof(ICommandDispatcher)
+ && descriptor.ImplementationType == typeof(CommandDispatcherImpl)
+ && descriptor.Lifetime == ServiceLifetime.Transient,
+ expectedCount: 1
+ );
+
+ services.ShouldContain(
+ elementPredicate: descriptor =>
+ descriptor.ServiceType == typeof(IQueryDispatcher)
+ && descriptor.ImplementationType == typeof(QueryDispatcherImpl)
+ && descriptor.Lifetime == ServiceLifetime.Transient,
+ expectedCount: 1
+ );
+ }
+}
diff --git a/tests/Cqrs.Tests/QueryDispatcherImplTests/Dispatch.cs b/tests/Cqrs.Tests/QueryDispatcherImplTests/Dispatch.cs
new file mode 100644
index 0000000..5a74a90
--- /dev/null
+++ b/tests/Cqrs.Tests/QueryDispatcherImplTests/Dispatch.cs
@@ -0,0 +1,188 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using NSubstitute;
+
+namespace Cqrs.Tests.QueryDispatcherImplTests;
+
+public class Dispatch
+{
+ public class TestQuery : IKnownQuery {}
+ public class TestQueryResult {}
+
+ [Theory]
+ [InlineData(ServiceLifetime.Transient)]
+ [InlineData(ServiceLifetime.Scoped)]
+ [InlineData(ServiceLifetime.Singleton)]
+ public async Task WhenCalled_ShouldExecuteHandler(ServiceLifetime lifetime)
+ {
+ // Given
+ var testQuery = new TestQuery();
+ var testQueryResult = new TestQueryResult();
+
+ var queryHandler = Substitute.For>();
+ queryHandler.Handle(testQuery, CancellationToken.None).Returns(testQueryResult);
+
+ ServiceCollection serviceCollection =
+ [
+ new ServiceDescriptor(
+ typeof(IQueryHandler),
+ (IServiceProvider _) => queryHandler,
+ lifetime
+ ),
+ ];
+ var services = serviceCollection.BuildServiceProvider();
+
+ var sut = new QueryDispatcherImpl(services, new ConcurrentMethodsCache());
+
+ // When
+ var result = await sut.Dispatch(testQuery, CancellationToken.None);
+
+ // Then
+ result.ShouldBeSameAs(testQueryResult);
+ await queryHandler.Received(1).Handle(testQuery, CancellationToken.None);
+ }
+
+ public class TestOpenBehaviour : IDispatchBehaviour
+ where TRequest : notnull
+ {
+ private readonly Action _callback;
+
+ public TestOpenBehaviour(Action callback)
+ {
+ _callback = callback;
+ }
+
+ public ValueTask Handle(TRequest request, DispatchFurtherDelegate next, CancellationToken cancellationToken)
+ {
+ _callback.Invoke(request);
+ return next();
+ }
+ }
+
+ [Fact]
+ public async Task WhenPipelineConfigured_ShouldCallAllBehavioursInOrder()
+ {
+ // Given
+ var testQuery = new TestQuery();
+ var testQueryResult = new TestQueryResult();
+ List calls = [];
+
+ var queryHandler = Substitute.For>();
+ queryHandler.Handle(testQuery, CancellationToken.None)
+ .Returns(testQueryResult)
+ .AndDoes(_ => calls.Add("queryHandler"));
+
+ var firstBehaviour = Substitute.For>();
+ firstBehaviour.Handle(testQuery, Arg.Any>(), Arg.Any())
+ .Returns(args => ((DispatchFurtherDelegate)args[1]).Invoke())
+ .AndDoes(_ => calls.Add("firstBehaviour"));
+
+ var secondBehaviour = Substitute.For>();
+ secondBehaviour.Handle(testQuery, Arg.Any>(), Arg.Any())
+ .Returns(args => ((DispatchFurtherDelegate)args[1]).Invoke())
+ .AndDoes(_ => calls.Add("secondBehaviour"));
+
+ ServiceCollection serviceCollection =
+ [
+ new ServiceDescriptor(
+ typeof(IQueryHandler),
+ (IServiceProvider _) => queryHandler,
+ ServiceLifetime.Transient
+ ),
+ new ServiceDescriptor(
+ typeof(IDispatchBehaviour),
+ (IServiceProvider _) => firstBehaviour,
+ ServiceLifetime.Transient
+ ),
+ new ServiceDescriptor(
+ typeof(IDispatchBehaviour),
+ (IServiceProvider _) => secondBehaviour,
+ ServiceLifetime.Transient
+ ),
+ new ServiceDescriptor(
+ typeof(IDispatchBehaviour<,>),
+ typeof(TestOpenBehaviour<,>),
+ ServiceLifetime.Transient
+ ),
+ ];
+ serviceCollection.AddTransient>(_ => (TestQuery _) => calls.Add("thirdBehaviour"));
+ var services = serviceCollection.BuildServiceProvider();
+
+ var sut = new QueryDispatcherImpl(services, new ConcurrentMethodsCache());
+
+ // When
+ var result = await sut.Dispatch(testQuery, CancellationToken.None);
+
+ // Then
+ result.ShouldBeSameAs(testQueryResult);
+ await firstBehaviour.Received(1).Handle(testQuery, Arg.Any>(), Arg.Any());
+ await secondBehaviour.Received(1).Handle(testQuery, Arg.Any>(), Arg.Any());
+ await queryHandler.Received(1).Handle(testQuery, CancellationToken.None);
+
+ calls.ShouldBe(["firstBehaviour", "secondBehaviour", "thirdBehaviour", "queryHandler"]);
+ }
+
+ [Fact]
+ public async Task WhenNextIsNotCalled_ShouldStopExecutingPipeline()
+ {
+ // Given
+ var testQuery = new TestQuery();
+ var testQueryResult = new TestQueryResult();
+ var testQueryResultAborted = new TestQueryResult();
+ List calls = [];
+
+ var queryHandler = Substitute.For>();
+ queryHandler.Handle(testQuery, CancellationToken.None)
+ .Returns(testQueryResult)
+ .AndDoes(_ => calls.Add("queryHandler"));
+
+ var firstBehaviour = Substitute.For>();
+ firstBehaviour.Handle(testQuery, Arg.Any>(), Arg.Any())
+ .Returns(args => ((DispatchFurtherDelegate)args[1]).Invoke())
+ .AndDoes(_ => calls.Add("firstBehaviour"));
+
+ var secondBehaviour = Substitute.For>();
+ secondBehaviour.Handle(testQuery, Arg.Any>(), Arg.Any())
+ .Returns(args => ValueTask.FromResult(testQueryResultAborted))
+ .AndDoes(_ => calls.Add("secondBehaviour"));
+
+ ServiceCollection serviceCollection =
+ [
+ new ServiceDescriptor(
+ typeof(IQueryHandler),
+ (IServiceProvider _) => queryHandler,
+ ServiceLifetime.Transient
+ ),
+ new ServiceDescriptor(
+ typeof(IDispatchBehaviour),
+ (IServiceProvider _) => firstBehaviour,
+ ServiceLifetime.Transient
+ ),
+ new ServiceDescriptor(
+ typeof(IDispatchBehaviour),
+ (IServiceProvider _) => secondBehaviour,
+ ServiceLifetime.Transient
+ ),
+ new ServiceDescriptor(
+ typeof(IDispatchBehaviour<,>),
+ typeof(TestOpenBehaviour<,>),
+ ServiceLifetime.Transient
+ ),
+ ];
+ serviceCollection.AddTransient>(_ => (TestQuery _) => calls.Add("thirdBehaviour"));
+ var services = serviceCollection.BuildServiceProvider();
+
+ var sut = new QueryDispatcherImpl(services, new ConcurrentMethodsCache());
+
+ // When
+ var result = await sut.Dispatch(testQuery, CancellationToken.None);
+
+ // Then
+ result.ShouldBeSameAs(testQueryResultAborted);
+ await firstBehaviour.Received(1).Handle(testQuery, Arg.Any>(), Arg.Any());
+ await secondBehaviour.Received(1).Handle(testQuery, Arg.Any>(), Arg.Any());
+ await queryHandler.Received(0).Handle(testQuery, CancellationToken.None);
+
+ calls.ShouldBe(["firstBehaviour", "secondBehaviour"]);
+ }
+}
diff --git a/tests/Cqrs.Tests/usings.cs b/tests/Cqrs.Tests/usings.cs
new file mode 100644
index 0000000..37d6c2d
--- /dev/null
+++ b/tests/Cqrs.Tests/usings.cs
@@ -0,0 +1,3 @@
+global using Shouldly;
+global using Just.Cqrs;
+global using Just.Cqrs.Internal;