Compare commits

5 Commits

Author SHA1 Message Date
c0c2c9f057 change publish script
All checks were successful
.NET Publish / publish (push) Successful in 1m0s
2025-11-11 23:38:15 +04:00
fc1f5ca7d7 added net10 support
Some checks failed
.NET Test / .NET tests (push) Successful in 2m12s
.NET Publish / publish (push) Failing after 2m52s
2025-11-11 23:23:14 +04:00
2fded2809f pipeline cache and dispatch optimizations
All checks were successful
.NET Test / test (push) Successful in 13m14s
.NET Publish / publish (push) Successful in 11m1s
2025-02-04 20:49:05 +04:00
7c3dd84971 readme and publish workflow
All checks were successful
.NET Test / test (push) Successful in 1m0s
.NET Publish / publish (push) Successful in 1m1s
2025-02-02 11:51:55 +04:00
d437d06c09 renamed Behavior 2025-02-02 11:51:34 +04:00
24 changed files with 544 additions and 212 deletions

View File

@@ -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}]

View File

@@ -0,0 +1,38 @@
name: .NET Publish
on:
push:
tags:
- 'v[0-9]+.[0-9]+.[0-9]+'
- 'v[0-9]+.[0-9]+.[0-9]+-rc[0-9]+'
jobs:
publish:
runs-on: ubuntu-latest
env:
DOTNET_CLI_TELEMETRY_OPTOUT: true
steps:
- uses: actions/checkout@v5
- name: Setup .NET
uses: https://github.com/actions/setup-dotnet@v4
with:
dotnet-version: 10.x
- name: Restore dependencies
run: dotnet restore --nologo
- name: Create the package
env:
RELEASE_VERSION: ${{ gitea.ref_name }}
run: >
dotnet pack --no-restore --configuration Release --output nupkgs
`echo $RELEASE_VERSION | sed -E 's|^(v([0-9]+(\.[0-9]+){2}))(-([a-z0-9]+)){1}|/p:ReleaseVersion=\2 /p:VersionSuffix=\5|; s|^(v([0-9]+(\.[0-9]+){2}))$|/p:ReleaseVersion=\2|'`
- name: Publish the package to Gitea
run: dotnet nuget push --source ${{ vars.OUTPUT_NUGET_REGISTRY }} --api-key ${{ secrets.LOCAL_NUGET_PACKAGE_TOKEN }} nupkgs/*.nupkg
- name: Publish the package to NuGet.org
run: dotnet nuget push --source https://api.nuget.org/v3/index.json --api-key ${{ secrets.NUGET_PACKAGE_TOKEN }} nupkgs/*.nupkg

View File

@@ -21,34 +21,48 @@ on:
jobs:
test:
runs-on: ubuntu-latest
name: .NET tests
env:
DOTNET_CLI_TELEMETRY_OPTOUT: true
TEST_PROJECT: ./tests/Cqrs.Tests/Cqrs.Tests.csproj
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Setup .NET
uses: https://github.com/actions/setup-dotnet@v4
with:
dotnet-version: '9.x'
dotnet-version: |
8.0.x
9.0.x
10.0.x
- name: Restore dependencies
run: dotnet restore --nologo
run: dotnet restore
- name: Build
run: dotnet build --nologo --configuration Release --no-restore
- name: Build .NET 10.0
run: dotnet build --no-restore --framework net10.0 --configuration Release ${{ env.TEST_PROJECT }}
- name: Test
run: dotnet test --nologo --configuration Release --no-build --verbosity normal --logger trx --collect:"XPlat Code Coverage"
- name: Build .NET 9.0
run: dotnet build --no-restore --framework net9.0 --configuration Release ${{ env.TEST_PROJECT }}
- name: Restore local tools
run: dotnet tool restore
- name: Build .NET 8.0
run: dotnet build --no-restore --framework net8.0 --configuration Release ${{ env.TEST_PROJECT }}
- name: Generate coverage report
run: dotnet reportgenerator -reports:**/coverage.cobertura.xml -targetdir:./coverage -reporttypes:MarkdownSummary
- name: Test .NET 10.0
run: dotnet run --no-build --framework net10.0 --configuration Release --project ${{ env.TEST_PROJECT }} -- -trx TestResults/results-net10.trx
- name: Test .NET 9.0
run: dotnet run --no-build --framework net9.0 --configuration Release --project ${{ env.TEST_PROJECT }} -- -trx TestResults/results-net9.trx
- name: Test .NET 8.0
run: dotnet run --no-build --framework net8.0 --configuration Release --project ${{ env.TEST_PROJECT }} -- -trx TestResults/results-net8.trx
- name: Upload dotnet test results
#uses: actions/upload-artifact@v4
uses: christopherhx/gitea-upload-artifact@v4
uses: actions/upload-artifact@v3
with:
name: coverage-results
path: coverage
name: test-results
path: TestResults
if: ${{ always() }}
retention-days: 30

View File

@@ -1,4 +1,5 @@
{
"dotnet.defaultSolution": "Just.Cqrs.sln",
"dotnetAcquisitionExtension.enableTelemetry": false
"dotnetAcquisitionExtension.enableTelemetry": false,
"dotnet.testWindow.useTestingPlatformProtocol": true
}

View File

@@ -7,11 +7,22 @@
<Description>Lightweight, easy-to-use C# library designed to simplify the implementation of the Command Query Responsibility Segregation (CQRS) pattern.</Description>
<Authors>JustFixMe</Authors>
<Copyright>Copyright (c) 2025 JustFixMe</Copyright>
<RepositoryUrl>https://github.com/JustFixMe/Just.Core/</RepositoryUrl>
<RepositoryUrl>https://github.com/JustFixMe/Just.Cqrs/</RepositoryUrl>
<PackageTags>c#;CQRS</PackageTags>
<PackageTags>csharp;cqrs;cqrs-pattern;</PackageTags>
<PackageLicenseFile>LICENSE</PackageLicenseFile>
<PackageReadmeFile>readme.md</PackageReadmeFile>
<ReleaseVersion Condition=" '$(ReleaseVersion)' == '' ">1.0.0</ReleaseVersion>
<VersionSuffix Condition=" '$(VersionSuffix)' != '' ">$(VersionSuffix)</VersionSuffix>
<VersionPrefix Condition=" '$(VersionSuffix)' != '' ">$(ReleaseVersion)</VersionPrefix>
<Version Condition=" '$(VersionSuffix)' == '' ">$(ReleaseVersion)</Version>
<AssemblyVersion>$(ReleaseVersion)</AssemblyVersion>
</PropertyGroup>
<ItemGroup>
<None Include="..\..\readme.md" Pack="true" PackagePath=""/>
<None Include="..\..\LICENSE" Pack="true" PackagePath=""/>
</ItemGroup>
</Project>

View File

@@ -7,24 +7,90 @@ Inspired by [MediatR](https://github.com/jbogard/MediatR)
## Features
* Separate dispatching of Commands/Queries
* Middleware-like behaviours
* Middleware-like Behaviors
## Compatibility
**Just.Cqrs** is built for .Net Standard 2.1 and .NET 8.0 and 9.0.
## Getting Started
### Install from NuGet.org
```
```bash
# install the package using NuGet
dotnet add package Just.Cqrs
```
### Register in DI with ```IServiceCollection```
```cs
```csharp
services.AddCqrs(opt => opt
.AddQueryHandler<SomeQueryHandler>()
.AddCommandHandler<SomeCommandHandler>()
.AddBehaviour<SomeBehaviour>()
.AddOpenBehaviour(typeof(SomeOpenBehaviour<,>))
.AddBehavior<SomeQueryBehavior>()
.AddOpenBehavior(typeof(LoggingBehavior<,>))
);
```
## Example Usage
### Define a Query and Handler
```csharp
record GetUserByIdQuery(int UserId) : IKnownQuery<User>;
class GetUserByIdQueryHandler : IQueryHandler<GetUserByIdQuery, User>
{
public ValueTask<User> Handle(GetUserByIdQuery query, CancellationToken cancellationToken)
{
// Fetch user logic here
}
}
// Use Dispatcher to execute the query
class GetUserByIdUseCase(IQueryDispatcher dispatcher)
{
public async Task<IResult> Execute(int userId, CancellationToken cancellationToken)
{
var user = await dispatcher.Dispatch(new GetUserByIdQuery(userId), cancellationToken);
}
}
```
\* *the same principles apply to commands*
### Define a Behavior
```csharp
class LoggingBehavior<TRequest, TResult>(ILogger logger) : IDispatchBehavior<TRequest, TResult>
where TRequest: notnull
{
public async ValueTask<TResult> Handle(
TRequest request,
DispatchFurtherDelegate<TResult> next,
CancellationToken cancellationToken)
{
logger.LogInformation("Handling request: {RequestType}", typeof(TRequest).Name);
var result = await next();
logger.LogInformation("Request handled: {RequestType}", typeof(TRequest).Name);
return result;
}
}
class SomeQueryBehavior : IDispatchBehavior<SomeQuery, SomeQueryResult>
{
public async ValueTask<SomeQueryResult> Handle(
SomeQuery request,
DispatchFurtherDelegate<SomeQueryResult> next,
CancellationToken cancellationToken)
{
// do something
return await next();
}
}
```
## License
**Just.Cqrs** is licensed under the [MIT License](LICENSE).

View File

@@ -1,9 +1,15 @@
using System.Diagnostics.CodeAnalysis;
using Just.Cqrs.Internal;
namespace Just.Cqrs;
public interface ICommandHandler<TCommand, TCommandResult> : ICommandHandlerImpl
public interface ICommandHandler<TCommand, TCommandResult> : ICommandHandlerImpl, IGenericHandler<TCommand, TCommandResult>
where TCommand : notnull
{
ValueTask<TCommandResult> Handle(TCommand command, CancellationToken cancellation);
new ValueTask<TCommandResult> Handle(TCommand command, CancellationToken cancellation);
[ExcludeFromCodeCoverage]
ValueTask<TCommandResult> IGenericHandler<TCommand, TCommandResult>.Handle(
TCommand request,
CancellationToken cancellationToken) => Handle(request, cancellationToken);
}

View File

@@ -12,7 +12,7 @@ public delegate ValueTask<TResponse> DispatchFurtherDelegate<TResponse>();
/// <summary>
/// Marker interface for static type checking. Should not be used directly.
/// </summary>
public interface IDispatchBehaviour
public interface IDispatchBehavior
{
Type RequestType { get; }
Type ResponseType { get; }
@@ -23,7 +23,7 @@ public interface IDispatchBehaviour
/// </summary>
/// <typeparam name="TRequest">Request type</typeparam>
/// <typeparam name="TResponse">Result type of dispatching command/query</typeparam>
public interface IDispatchBehaviour<in TRequest, TResponse> : IDispatchBehaviour
public interface IDispatchBehavior<in TRequest, TResponse> : IDispatchBehavior
where TRequest : notnull
{
ValueTask<TResponse> Handle(
@@ -32,7 +32,7 @@ public interface IDispatchBehaviour<in TRequest, TResponse> : IDispatchBehaviour
CancellationToken cancellationToken);
[ExcludeFromCodeCoverage]
Type IDispatchBehaviour.RequestType => typeof(TRequest);
Type IDispatchBehavior.RequestType => typeof(TRequest);
[ExcludeFromCodeCoverage]
Type IDispatchBehaviour.ResponseType => typeof(TResponse);
Type IDispatchBehavior.ResponseType => typeof(TResponse);
}

View File

@@ -1,9 +1,15 @@
using System.Diagnostics.CodeAnalysis;
using Just.Cqrs.Internal;
namespace Just.Cqrs;
public interface IQueryHandler<TQuery, TQueryResult> : IQueryHandlerImpl
public interface IQueryHandler<TQuery, TQueryResult> : IQueryHandlerImpl, IGenericHandler<TQuery, TQueryResult>
where TQuery : notnull
{
ValueTask<TQueryResult> Handle(TQuery query, CancellationToken cancellation);
new ValueTask<TQueryResult> Handle(TQuery query, CancellationToken cancellation);
[ExcludeFromCodeCoverage]
ValueTask<TQueryResult> IGenericHandler<TQuery, TQueryResult>.Handle(
TQuery request,
CancellationToken cancellationToken) => Handle(request, cancellationToken);
}

View File

@@ -1,3 +1,6 @@
namespace Just.Cqrs.Internal;
/// <summary>
/// Marker interface for static type checking. Should not be used directly.
/// </summary>
public interface ICommandHandlerImpl { }

View File

@@ -0,0 +1,10 @@
namespace Just.Cqrs.Internal;
/// <summary>
/// Marker interface for static type checking. Should not be used directly.
/// </summary>
public interface IGenericHandler<TRequest, TResponse>
where TRequest : notnull
{
ValueTask<TResponse> Handle(TRequest request, CancellationToken cancellation);
}

View File

@@ -1,3 +1,6 @@
namespace Just.Cqrs.Internal;
/// <summary>
/// Marker interface for static type checking. Should not be used directly.
/// </summary>
public interface IQueryHandlerImpl { }

View File

@@ -8,15 +8,19 @@ namespace Microsoft.Extensions.DependencyInjection;
public static class CqrsServicesExtensions
{
/// <summary>
/// Adds all configured Command and Query handlers, behaviors and default implementations of <see cref="ICommandDispatcher"/> and <see cref="IQueryDispatcher"/>.
/// </summary>
/// <remarks>
/// If called multiple times <see cref="ICommandDispatcher"/> and <see cref="IQueryDispatcher"/> will still be added once
/// </remarks>
public static IServiceCollection AddCqrs(this IServiceCollection services, Action<CqrsServicesOptions>? configure = null)
{
var options = new CqrsServicesOptions(services);
configure?.Invoke(options);
services.TryAddKeyedSingleton<IMethodsCache, ConcurrentMethodsCache>(MethodsCacheServiceKey.DispatchCommand);
services.TryAddSingleton<IMethodsCache, ConcurrentMethodsCache>();
services.TryAddTransient<ICommandDispatcher, CommandDispatcherImpl>();
services.TryAddKeyedSingleton<IMethodsCache, ConcurrentMethodsCache>(MethodsCacheServiceKey.DispatchQuery);
services.TryAddTransient<IQueryDispatcher, QueryDispatcherImpl>();
foreach (var (service, impl, lifetime) in options.CommandHandlers)
@@ -27,7 +31,7 @@ public static class CqrsServicesExtensions
{
services.TryAdd(new ServiceDescriptor(service, impl, lifetime));
}
foreach (var (service, impl, lifetime) in options.Behaviours)
foreach (var (service, impl, lifetime) in options.Behaviors)
{
services.Add(new ServiceDescriptor(service, impl, lifetime));
}
@@ -75,43 +79,43 @@ public static class CqrsServicesExtensions
return options;
}
public static CqrsServicesOptions AddOpenBehaviour(this CqrsServicesOptions options, Type behaviour, ServiceLifetime lifetime = ServiceLifetime.Singleton)
public static CqrsServicesOptions AddOpenBehavior(this CqrsServicesOptions options, Type behavior, ServiceLifetime lifetime = ServiceLifetime.Singleton)
{
var interfaces = behaviour.FindInterfaces(
var interfaces = behavior.FindInterfaces(
static (x, t) => x.IsGenericType && x.GetGenericTypeDefinition() == (Type)t!,
typeof(IDispatchBehaviour<,>));
typeof(IDispatchBehavior<,>));
if (interfaces.Length == 0)
{
throw new ArgumentException("Supplied type does not implement IDispatchBehaviour<,> interface.", nameof(behaviour));
throw new ArgumentException("Supplied type does not implement IDispatchBehavior<,> interface.", nameof(behavior));
}
if (!behaviour.ContainsGenericParameters)
if (!behavior.ContainsGenericParameters)
{
throw new ArgumentException("Supplied type is not sutable for open behaviour.", nameof(behaviour));
throw new ArgumentException("Supplied type is not suitable for open Behavior.", nameof(behavior));
}
options.Behaviours.Add((typeof(IDispatchBehaviour<,>), behaviour, lifetime));
options.Behaviors.Add((typeof(IDispatchBehavior<,>), behavior, lifetime));
return options;
}
public static CqrsServicesOptions AddBehaviour<TBehaviour>(this CqrsServicesOptions options, ServiceLifetime lifetime = ServiceLifetime.Singleton)
where TBehaviour : notnull, IDispatchBehaviour
public static CqrsServicesOptions AddBehavior<TBehavior>(this CqrsServicesOptions options, ServiceLifetime lifetime = ServiceLifetime.Singleton)
where TBehavior : notnull, IDispatchBehavior
{
var type = typeof(TBehaviour);
var type = typeof(TBehavior);
var interfaces = type.FindInterfaces(
static (x, t) => x.IsGenericType && x.GetGenericTypeDefinition() == (Type)t!,
typeof(IDispatchBehaviour<,>));
typeof(IDispatchBehavior<,>));
if (interfaces.Length == 0)
{
throw new InvalidOperationException("Supplied type does not implement IDispatchBehaviour<,> interface.");
throw new InvalidOperationException("Supplied type does not implement IDispatchBehavior<,> interface.");
}
foreach (var interfaceType in interfaces)
{
options.Behaviours.Add((
options.Behaviors.Add((
interfaceType,
type,
lifetime));

View File

@@ -4,7 +4,7 @@ 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)> Behaviors = [];
internal readonly List<(Type Service, Type Impl, ServiceLifetime Lifetime)> CommandHandlers = [];
internal readonly List<(Type Service, Type Impl, ServiceLifetime Lifetime)> QueryHandlers = [];

View File

@@ -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<TCommandResult> Dispatch<TCommandResult>(object command, CancellationToken cancellationToken)
=> DispatchCommand<TCommandResult>(command, cancellationToken);
=> DispatchInternal<TCommandResult>(CreateDispatchCommandDelegate, command, cancellationToken);
public ValueTask<TCommandResult> Dispatch<TCommandResult>(IKnownCommand<TCommandResult> command, CancellationToken cancellationToken)
=> DispatchCommand<TCommandResult>(command, cancellationToken);
private ValueTask<TCommandResult> DispatchCommand<TCommandResult>(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<TCommandResult>)dispatchCommandMethod
.Invoke(this, [command, cancellationToken])!;
}
=> DispatchInternal<TCommandResult>(CreateDispatchCommandDelegate, command, cancellationToken);
private ValueTask<TCommandResult> DispatchCommandImpl<TCommand, TCommandResult>(
TCommand command,
@@ -34,14 +32,8 @@ internal sealed class CommandDispatcherImpl(
where TCommand : notnull
{
var handler = services.GetRequiredService<ICommandHandler<TCommand, TCommandResult>>();
var pipeline = services.GetServices<IDispatchBehaviour<TCommand, TCommandResult>>();
using var pipelineEnumerator = pipeline.GetEnumerator();
var pipeline = services.GetServices<IDispatchBehavior<TCommand, TCommandResult>>();
return DispatchDelegateFactory(pipelineEnumerator).Invoke();
DispatchFurtherDelegate<TCommandResult> DispatchDelegateFactory(IEnumerator<IDispatchBehaviour<TCommand, TCommandResult>> enumerator) =>
enumerator.MoveNext()
? (() => enumerator.Current.Handle(command, DispatchDelegateFactory(enumerator), cancellationToken))
: (() => handler.Handle(command, cancellationToken));
return DispatchDelegateFactory(command, handler, pipeline, cancellationToken).Invoke();
}
}

View File

@@ -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<TResult> DispatchInternal<TResult>(
Func<(Type, Type), Delegate> delegateFactory,
object request,
CancellationToken cancellationToken)
{
var cacheKey = (request.GetType(), typeof(TResult));
var dispatchDelegate = (Func<DispatcherBase, object, CancellationToken, ValueTask<TResult>>)
MethodsCache.GetOrAdd(cacheKey, delegateFactory);
return dispatchDelegate(this, request, cancellationToken);
}
protected DispatchFurtherDelegate<TResponse> DispatchDelegateFactory<TRequest, TResponse, THandler>(
TRequest request,
THandler handler,
IEnumerable<IDispatchBehavior<TRequest, TResponse>> behaviors,
CancellationToken cancellationToken)
where TRequest : notnull
where THandler : IGenericHandler<TRequest, TResponse>
{
DispatchFurtherDelegate<TResponse> pipeline = behaviors.Reverse()
.Aggregate<IDispatchBehavior<TRequest, TResponse>, DispatchFurtherDelegate<TResponse>>(
() => 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;
}
}

View File

@@ -1,17 +1,10 @@
using System.Collections.Concurrent;
using System.Reflection;
namespace Just.Cqrs.Internal;
internal interface IMethodsCache
{
MethodInfo GetOrAdd(Type key, Func<Type, MethodInfo> 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<Type, MethodInfo>, IMethodsCache;
internal sealed class ConcurrentMethodsCache : ConcurrentDictionary<(Type RequestType, Type ResponseType), Delegate>, IMethodsCache;

View File

@@ -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<TQueryResult> Dispatch<TQueryResult>(object query, CancellationToken cancellationToken)
=> DispatchQuery<TQueryResult>(query, cancellationToken);
=> DispatchInternal<TQueryResult>(CreateDispatchQueryDelegate, query, cancellationToken);
public ValueTask<TQueryResult> Dispatch<TQueryResult>(IKnownQuery<TQueryResult> query, CancellationToken cancellationToken)
=> DispatchQuery<TQueryResult>(query, cancellationToken);
private ValueTask<TQueryResult> DispatchQuery<TQueryResult>(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<TQueryResult>)dispatchQueryMethod
.Invoke(this, [query, cancellationToken])!;
}
=> DispatchInternal<TQueryResult>(CreateDispatchQueryDelegate, query, cancellationToken);
private ValueTask<TQueryResult> DispatchQueryImpl<TQuery, TQueryResult>(
TQuery query,
@@ -34,14 +32,8 @@ internal sealed class QueryDispatcherImpl(
where TQuery : notnull
{
var handler = services.GetRequiredService<IQueryHandler<TQuery, TQueryResult>>();
var pipeline = services.GetServices<IDispatchBehaviour<TQuery, TQueryResult>>();
using var pipelineEnumerator = pipeline.GetEnumerator();
var pipeline = services.GetServices<IDispatchBehavior<TQuery, TQueryResult>>();
return DispatchDelegateFactory(pipelineEnumerator).Invoke();
DispatchFurtherDelegate<TQueryResult> DispatchDelegateFactory(IEnumerator<IDispatchBehaviour<TQuery, TQueryResult>> enumerator) =>
enumerator.MoveNext()
? (() => enumerator.Current.Handle(query, DispatchDelegateFactory(enumerator), cancellationToken))
: (() => handler.Handle(query, cancellationToken));
return DispatchDelegateFactory(query, handler, pipeline, cancellationToken).Invoke();
}
}

View File

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>netstandard2.1;net8.0;net9.0</TargetFrameworks>
<TargetFrameworks>netstandard2.1;net8.0;net9.0;net10.0</TargetFrameworks>
<LangVersion>latest</LangVersion>
</PropertyGroup>
@@ -13,8 +13,12 @@
<ProjectReference Include="../Just.Cqrs.Abstractions/Just.Cqrs.Abstractions.csproj" />
</ItemGroup>
<ItemGroup Condition="$(TargetFramework) == 'net9.0' Or $(TargetFramework) == 'netstandard2.1'">
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.1" />
<ItemGroup Condition="$(TargetFramework) == 'net10.0' Or $(TargetFramework) == 'netstandard2.1'">
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
</ItemGroup>
<ItemGroup Condition="$(TargetFramework) == 'net9.0'">
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.11" />
</ItemGroup>
<ItemGroup Condition="$(TargetFramework) == 'net8.0'">

View File

@@ -6,6 +6,10 @@ namespace Cqrs.Tests.CommandDispatcherImplTests;
public class Dispatch
{
public abstract class TestCommandHandler : ICommandHandler<TestCommand, TestCommandResult>
{
public abstract ValueTask<TestCommandResult> Handle(TestCommand command, CancellationToken cancellation);
}
public class TestCommand : IKnownCommand<TestCommandResult> {}
public class TestCommandResult {}
@@ -19,7 +23,7 @@ public class Dispatch
var testCommand = new TestCommand();
var testCommandResult = new TestCommandResult();
var commandHandler = Substitute.For<ICommandHandler<TestCommand, TestCommandResult>>();
var commandHandler = Substitute.For<TestCommandHandler>();
commandHandler.Handle(testCommand, CancellationToken.None).Returns(testCommandResult);
ServiceCollection serviceCollection =
@@ -42,12 +46,12 @@ public class Dispatch
await commandHandler.Received(1).Handle(testCommand, CancellationToken.None);
}
public class TestOpenBehaviour<TRequest, TResponse> : IDispatchBehaviour<TRequest, TResponse>
public class TestOpenBehavior<TRequest, TResponse> : IDispatchBehavior<TRequest, TResponse>
where TRequest : notnull
{
private readonly Action<TRequest> _callback;
public TestOpenBehaviour(Action<TRequest> callback)
public TestOpenBehavior(Action<TRequest> callback)
{
_callback = callback;
}
@@ -60,27 +64,27 @@ public class Dispatch
}
[Fact]
public async Task WhenPipelineConfigured_ShouldCallAllBehavioursInOrder()
public async Task WhenPipelineConfigured_ShouldCallAllBehaviorsInOrder()
{
// Given
var testCommand = new TestCommand();
var testCommandResult = new TestCommandResult();
List<string> calls = [];
var commandHandler = Substitute.For<ICommandHandler<TestCommand, TestCommandResult>>();
var commandHandler = Substitute.For<TestCommandHandler>();
commandHandler.Handle(testCommand, CancellationToken.None)
.Returns(testCommandResult)
.AndDoes(_ => calls.Add("commandHandler"));
var firstBehaviour = Substitute.For<IDispatchBehaviour<TestCommand, TestCommandResult>>();
firstBehaviour.Handle(testCommand, Arg.Any<DispatchFurtherDelegate<TestCommandResult>>(), Arg.Any<CancellationToken>())
var firstBehavior = Substitute.For<IDispatchBehavior<TestCommand, TestCommandResult>>();
firstBehavior.Handle(testCommand, Arg.Any<DispatchFurtherDelegate<TestCommandResult>>(), Arg.Any<CancellationToken>())
.Returns(args => ((DispatchFurtherDelegate<TestCommandResult>)args[1]).Invoke())
.AndDoes(_ => calls.Add("firstBehaviour"));
.AndDoes(_ => calls.Add("firstBehavior"));
var secondBehaviour = Substitute.For<IDispatchBehaviour<TestCommand, TestCommandResult>>();
secondBehaviour.Handle(testCommand, Arg.Any<DispatchFurtherDelegate<TestCommandResult>>(), Arg.Any<CancellationToken>())
var secondBehavior = Substitute.For<IDispatchBehavior<TestCommand, TestCommandResult>>();
secondBehavior.Handle(testCommand, Arg.Any<DispatchFurtherDelegate<TestCommandResult>>(), Arg.Any<CancellationToken>())
.Returns(args => ((DispatchFurtherDelegate<TestCommandResult>)args[1]).Invoke())
.AndDoes(_ => calls.Add("secondBehaviour"));
.AndDoes(_ => calls.Add("secondBehavior"));
ServiceCollection serviceCollection =
[
@@ -90,22 +94,22 @@ public class Dispatch
ServiceLifetime.Transient
),
new ServiceDescriptor(
typeof(IDispatchBehaviour<TestCommand, TestCommandResult>),
(IServiceProvider _) => firstBehaviour,
typeof(IDispatchBehavior<TestCommand, TestCommandResult>),
(IServiceProvider _) => firstBehavior,
ServiceLifetime.Transient
),
new ServiceDescriptor(
typeof(IDispatchBehaviour<TestCommand, TestCommandResult>),
(IServiceProvider _) => secondBehaviour,
typeof(IDispatchBehavior<TestCommand, TestCommandResult>),
(IServiceProvider _) => secondBehavior,
ServiceLifetime.Transient
),
new ServiceDescriptor(
typeof(IDispatchBehaviour<,>),
typeof(TestOpenBehaviour<,>),
typeof(IDispatchBehavior<,>),
typeof(TestOpenBehavior<,>),
ServiceLifetime.Transient
),
];
serviceCollection.AddTransient<Action<TestCommand>>(_ => (TestCommand _) => calls.Add("thirdBehaviour"));
serviceCollection.AddTransient<Action<TestCommand>>(_ => (TestCommand _) => calls.Add("thirdBehavior"));
var services = serviceCollection.BuildServiceProvider();
var sut = new CommandDispatcherImpl(services, new ConcurrentMethodsCache());
@@ -115,11 +119,11 @@ public class Dispatch
// Then
result.ShouldBeSameAs(testCommandResult);
await firstBehaviour.Received(1).Handle(testCommand, Arg.Any<DispatchFurtherDelegate<TestCommandResult>>(), Arg.Any<CancellationToken>());
await secondBehaviour.Received(1).Handle(testCommand, Arg.Any<DispatchFurtherDelegate<TestCommandResult>>(), Arg.Any<CancellationToken>());
await firstBehavior.Received(1).Handle(testCommand, Arg.Any<DispatchFurtherDelegate<TestCommandResult>>(), Arg.Any<CancellationToken>());
await secondBehavior.Received(1).Handle(testCommand, Arg.Any<DispatchFurtherDelegate<TestCommandResult>>(), Arg.Any<CancellationToken>());
await commandHandler.Received(1).Handle(testCommand, CancellationToken.None);
calls.ShouldBe(["firstBehaviour", "secondBehaviour", "thirdBehaviour", "commandHandler"]);
calls.ShouldBe(["firstBehavior", "secondBehavior", "thirdBehavior", "commandHandler"]);
}
[Fact]
@@ -131,20 +135,20 @@ public class Dispatch
var testCommandResultAborted = new TestCommandResult();
List<string> calls = [];
var commandHandler = Substitute.For<ICommandHandler<TestCommand, TestCommandResult>>();
var commandHandler = Substitute.For<TestCommandHandler>();
commandHandler.Handle(testCommand, CancellationToken.None)
.Returns(testCommandResult)
.AndDoes(_ => calls.Add("commandHandler"));
var firstBehaviour = Substitute.For<IDispatchBehaviour<TestCommand, TestCommandResult>>();
firstBehaviour.Handle(testCommand, Arg.Any<DispatchFurtherDelegate<TestCommandResult>>(), Arg.Any<CancellationToken>())
var firstBehavior = Substitute.For<IDispatchBehavior<TestCommand, TestCommandResult>>();
firstBehavior.Handle(testCommand, Arg.Any<DispatchFurtherDelegate<TestCommandResult>>(), Arg.Any<CancellationToken>())
.Returns(args => ((DispatchFurtherDelegate<TestCommandResult>)args[1]).Invoke())
.AndDoes(_ => calls.Add("firstBehaviour"));
.AndDoes(_ => calls.Add("firstBehavior"));
var secondBehaviour = Substitute.For<IDispatchBehaviour<TestCommand, TestCommandResult>>();
secondBehaviour.Handle(testCommand, Arg.Any<DispatchFurtherDelegate<TestCommandResult>>(), Arg.Any<CancellationToken>())
var secondBehavior = Substitute.For<IDispatchBehavior<TestCommand, TestCommandResult>>();
secondBehavior.Handle(testCommand, Arg.Any<DispatchFurtherDelegate<TestCommandResult>>(), Arg.Any<CancellationToken>())
.Returns(args => ValueTask.FromResult(testCommandResultAborted))
.AndDoes(_ => calls.Add("secondBehaviour"));
.AndDoes(_ => calls.Add("secondBehavior"));
ServiceCollection serviceCollection =
[
@@ -154,22 +158,22 @@ public class Dispatch
ServiceLifetime.Transient
),
new ServiceDescriptor(
typeof(IDispatchBehaviour<TestCommand, TestCommandResult>),
(IServiceProvider _) => firstBehaviour,
typeof(IDispatchBehavior<TestCommand, TestCommandResult>),
(IServiceProvider _) => firstBehavior,
ServiceLifetime.Transient
),
new ServiceDescriptor(
typeof(IDispatchBehaviour<TestCommand, TestCommandResult>),
(IServiceProvider _) => secondBehaviour,
typeof(IDispatchBehavior<TestCommand, TestCommandResult>),
(IServiceProvider _) => secondBehavior,
ServiceLifetime.Transient
),
new ServiceDescriptor(
typeof(IDispatchBehaviour<,>),
typeof(TestOpenBehaviour<,>),
typeof(IDispatchBehavior<,>),
typeof(TestOpenBehavior<,>),
ServiceLifetime.Transient
),
];
serviceCollection.AddTransient<Action<TestCommand>>(_ => (TestCommand _) => calls.Add("thirdBehaviour"));
serviceCollection.AddTransient<Action<TestCommand>>(_ => (TestCommand _) => calls.Add("thirdBehavior"));
var services = serviceCollection.BuildServiceProvider();
var sut = new CommandDispatcherImpl(services, new ConcurrentMethodsCache());
@@ -179,10 +183,59 @@ public class Dispatch
// Then
result.ShouldBeSameAs(testCommandResultAborted);
await firstBehaviour.Received(1).Handle(testCommand, Arg.Any<DispatchFurtherDelegate<TestCommandResult>>(), Arg.Any<CancellationToken>());
await secondBehaviour.Received(1).Handle(testCommand, Arg.Any<DispatchFurtherDelegate<TestCommandResult>>(), Arg.Any<CancellationToken>());
await firstBehavior.Received(1).Handle(testCommand, Arg.Any<DispatchFurtherDelegate<TestCommandResult>>(), Arg.Any<CancellationToken>());
await secondBehavior.Received(1).Handle(testCommand, Arg.Any<DispatchFurtherDelegate<TestCommandResult>>(), Arg.Any<CancellationToken>());
await commandHandler.Received(0).Handle(testCommand, CancellationToken.None);
calls.ShouldBe(["firstBehaviour", "secondBehaviour"]);
calls.ShouldBe(["firstBehavior", "secondBehavior"]);
}
public abstract class AnotherTestCommandHandler : ICommandHandler<TestCommand, AnotherTestCommandResult>
{
public abstract ValueTask<AnotherTestCommandResult> 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<TestCommandHandler>();
commandHandler.Handle(testCommand, CancellationToken.None).Returns(testCommandResult);
var anotherCommandHandler = Substitute.For<AnotherTestCommandHandler>();
anotherCommandHandler.Handle(testCommand, CancellationToken.None).Returns(anotherTestCommandResult);
ServiceCollection serviceCollection =
[
new ServiceDescriptor(
typeof(ICommandHandler<TestCommand, TestCommandResult>),
(IServiceProvider _) => commandHandler,
ServiceLifetime.Transient
),
new ServiceDescriptor(
typeof(ICommandHandler<TestCommand, AnotherTestCommandResult>),
(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<AnotherTestCommandResult>(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);
}
}

View File

@@ -1,17 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net8.0;net9.0</TargetFrameworks>
<TargetFrameworks>net8.0;net9.0;net10.0</TargetFrameworks>
<LangVersion>latest</LangVersion>
<OutputType>Exe</OutputType>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.2" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="xunit.v3" Version="3.2.0" />
<PackageReference Include="coverlet.collector" Version="6.0.4">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Shouldly" Version="4.3.0" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="NSubstitute.Analyzers.CSharp" Version="1.0.17">
@@ -20,8 +23,12 @@
</PackageReference>
</ItemGroup>
<ItemGroup Condition="$(TargetFramework) == 'net9.0' Or $(TargetFramework) == 'netstandard2.1'">
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.1" />
<ItemGroup Condition="$(TargetFramework) == 'net10.0' Or $(TargetFramework) == 'netstandard2.1'">
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
</ItemGroup>
<ItemGroup Condition="$(TargetFramework) == 'net9.0'">
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.11" />
</ItemGroup>
<ItemGroup Condition="$(TargetFramework) == 'net8.0'">

View File

@@ -3,12 +3,12 @@ using Microsoft.Extensions.DependencyInjection;
namespace Cqrs.Tests.CqrsServicesExtensionsTests;
public class AddBehaviour
public class AddBehavior
{
public class TestCommand {}
public class TestCommandResult {}
[ExcludeFromCodeCoverage]
public class NonGenericTestOpenBehaviour : IDispatchBehaviour<TestCommand, TestCommandResult>
public class NonGenericTestOpenBehavior : IDispatchBehavior<TestCommand, TestCommandResult>
{
public ValueTask<TestCommandResult> Handle(TestCommand request, DispatchFurtherDelegate<TestCommandResult> next, CancellationToken cancellationToken)
{
@@ -20,27 +20,27 @@ public class AddBehaviour
[InlineData(ServiceLifetime.Transient)]
[InlineData(ServiceLifetime.Scoped)]
[InlineData(ServiceLifetime.Singleton)]
public void WhenCalled_ShouldRegisterDispatchBehaviour(ServiceLifetime lifetime)
public void WhenCalled_ShouldRegisterDispatchBehavior(ServiceLifetime lifetime)
{
// Given
ServiceCollection services = new();
// When
services.AddCqrs(opt => opt
.AddBehaviour<NonGenericTestOpenBehaviour>(lifetime));
.AddBehavior<NonGenericTestOpenBehavior>(lifetime));
// Then
services.ShouldContain(
elementPredicate: descriptor =>
descriptor.ServiceType == typeof(IDispatchBehaviour<TestCommand, TestCommandResult>)
&& descriptor.ImplementationType == typeof(NonGenericTestOpenBehaviour)
descriptor.ServiceType == typeof(IDispatchBehavior<TestCommand, TestCommandResult>)
&& descriptor.ImplementationType == typeof(NonGenericTestOpenBehavior)
&& descriptor.Lifetime == lifetime,
expectedCount: 1
);
}
[ExcludeFromCodeCoverage]
public class InvalidTestBehaviour : IDispatchBehaviour
public class InvalidTestBehavior : IDispatchBehavior
{
public Type RequestType => throw new NotImplementedException();
@@ -57,7 +57,7 @@ public class AddBehaviour
// Then
Should.Throw<InvalidOperationException>(() => services.AddCqrs(opt => opt
.AddBehaviour<InvalidTestBehaviour>())
.AddBehavior<InvalidTestBehavior>())
);
}
}

View File

@@ -3,10 +3,10 @@ using Microsoft.Extensions.DependencyInjection;
namespace Cqrs.Tests.CqrsServicesExtensionsTests;
public class AddOpenBehaviour
public class AddOpenBehavior
{
[ExcludeFromCodeCoverage]
public class TestOpenBehaviour<TRequest, TResponse> : IDispatchBehaviour<TRequest, TResponse>
public class TestOpenBehavior<TRequest, TResponse> : IDispatchBehavior<TRequest, TResponse>
where TRequest: notnull
{
public ValueTask<TResponse> Handle(TRequest request, DispatchFurtherDelegate<TResponse> next, CancellationToken cancellationToken)
@@ -19,27 +19,27 @@ public class AddOpenBehaviour
[InlineData(ServiceLifetime.Transient)]
[InlineData(ServiceLifetime.Scoped)]
[InlineData(ServiceLifetime.Singleton)]
public void WhenCalled_ShouldRegisterOpenDispatchBehaviour(ServiceLifetime lifetime)
public void WhenCalled_ShouldRegisterOpenDispatchBehavior(ServiceLifetime lifetime)
{
// Given
ServiceCollection services = new();
// When
services.AddCqrs(opt => opt
.AddOpenBehaviour(typeof(TestOpenBehaviour<,>), lifetime));
.AddOpenBehavior(typeof(TestOpenBehavior<,>), lifetime));
// Then
services.ShouldContain(
elementPredicate: descriptor =>
descriptor.ServiceType == typeof(IDispatchBehaviour<,>)
&& descriptor.ImplementationType == typeof(TestOpenBehaviour<,>)
descriptor.ServiceType == typeof(IDispatchBehavior<,>)
&& descriptor.ImplementationType == typeof(TestOpenBehavior<,>)
&& descriptor.Lifetime == lifetime,
expectedCount: 1
);
}
[ExcludeFromCodeCoverage]
public class InvalidOpenBehaviour : IDispatchBehaviour
public class InvalidOpenBehavior : IDispatchBehavior
{
public Type RequestType => throw new NotImplementedException();
@@ -53,18 +53,18 @@ public class AddOpenBehaviour
ServiceCollection services = new();
// When
var invalidOpenDispatchBehaviourType = typeof(InvalidOpenBehaviour);
var invalidOpenDispatchBehaviorType = typeof(InvalidOpenBehavior);
// Then
Should.Throw<ArgumentException>(() => services.AddCqrs(opt => opt
.AddOpenBehaviour(invalidOpenDispatchBehaviourType))
.AddOpenBehavior(invalidOpenDispatchBehaviorType))
);
}
public class TestCommand {}
public class TestCommandResult {}
[ExcludeFromCodeCoverage]
public class NonGenericTestOpenBehaviour : IDispatchBehaviour<TestCommand, TestCommandResult>
public class NonGenericTestOpenBehavior : IDispatchBehavior<TestCommand, TestCommandResult>
{
public ValueTask<TestCommandResult> Handle(TestCommand request, DispatchFurtherDelegate<TestCommandResult> next, CancellationToken cancellationToken)
{
@@ -79,11 +79,11 @@ public class AddOpenBehaviour
ServiceCollection services = new();
// When
var nonGenericOpenDispatchBehaviourType = typeof(NonGenericTestOpenBehaviour);
var nonGenericOpenDispatchBehaviorType = typeof(NonGenericTestOpenBehavior);
// Then
Should.Throw<ArgumentException>(() => services.AddCqrs(opt => opt
.AddOpenBehaviour(nonGenericOpenDispatchBehaviourType))
.AddOpenBehavior(nonGenericOpenDispatchBehaviorType))
);
}
}

View File

@@ -6,6 +6,10 @@ namespace Cqrs.Tests.QueryDispatcherImplTests;
public class Dispatch
{
public abstract class TestQueryHandler : IQueryHandler<TestQuery, TestQueryResult>
{
public abstract ValueTask<TestQueryResult> Handle(TestQuery query, CancellationToken cancellation);
}
public class TestQuery : IKnownQuery<TestQueryResult> {}
public class TestQueryResult {}
@@ -19,7 +23,7 @@ public class Dispatch
var testQuery = new TestQuery();
var testQueryResult = new TestQueryResult();
var queryHandler = Substitute.For<IQueryHandler<TestQuery, TestQueryResult>>();
var queryHandler = Substitute.For<TestQueryHandler>();
queryHandler.Handle(testQuery, CancellationToken.None).Returns(testQueryResult);
ServiceCollection serviceCollection =
@@ -42,12 +46,12 @@ public class Dispatch
await queryHandler.Received(1).Handle(testQuery, CancellationToken.None);
}
public class TestOpenBehaviour<TRequest, TResponse> : IDispatchBehaviour<TRequest, TResponse>
public class TestOpenBehavior<TRequest, TResponse> : IDispatchBehavior<TRequest, TResponse>
where TRequest : notnull
{
private readonly Action<TRequest> _callback;
public TestOpenBehaviour(Action<TRequest> callback)
public TestOpenBehavior(Action<TRequest> callback)
{
_callback = callback;
}
@@ -60,27 +64,27 @@ public class Dispatch
}
[Fact]
public async Task WhenPipelineConfigured_ShouldCallAllBehavioursInOrder()
public async Task WhenPipelineConfigured_ShouldCallAllBehaviorsInOrder()
{
// Given
var testQuery = new TestQuery();
var testQueryResult = new TestQueryResult();
List<string> calls = [];
var queryHandler = Substitute.For<IQueryHandler<TestQuery, TestQueryResult>>();
var queryHandler = Substitute.For<TestQueryHandler>();
queryHandler.Handle(testQuery, CancellationToken.None)
.Returns(testQueryResult)
.AndDoes(_ => calls.Add("queryHandler"));
var firstBehaviour = Substitute.For<IDispatchBehaviour<TestQuery, TestQueryResult>>();
firstBehaviour.Handle(testQuery, Arg.Any<DispatchFurtherDelegate<TestQueryResult>>(), Arg.Any<CancellationToken>())
var firstBehavior = Substitute.For<IDispatchBehavior<TestQuery, TestQueryResult>>();
firstBehavior.Handle(testQuery, Arg.Any<DispatchFurtherDelegate<TestQueryResult>>(), Arg.Any<CancellationToken>())
.Returns(args => ((DispatchFurtherDelegate<TestQueryResult>)args[1]).Invoke())
.AndDoes(_ => calls.Add("firstBehaviour"));
.AndDoes(_ => calls.Add("firstBehavior"));
var secondBehaviour = Substitute.For<IDispatchBehaviour<TestQuery, TestQueryResult>>();
secondBehaviour.Handle(testQuery, Arg.Any<DispatchFurtherDelegate<TestQueryResult>>(), Arg.Any<CancellationToken>())
var secondBehavior = Substitute.For<IDispatchBehavior<TestQuery, TestQueryResult>>();
secondBehavior.Handle(testQuery, Arg.Any<DispatchFurtherDelegate<TestQueryResult>>(), Arg.Any<CancellationToken>())
.Returns(args => ((DispatchFurtherDelegate<TestQueryResult>)args[1]).Invoke())
.AndDoes(_ => calls.Add("secondBehaviour"));
.AndDoes(_ => calls.Add("secondBehavior"));
ServiceCollection serviceCollection =
[
@@ -90,22 +94,22 @@ public class Dispatch
ServiceLifetime.Transient
),
new ServiceDescriptor(
typeof(IDispatchBehaviour<TestQuery, TestQueryResult>),
(IServiceProvider _) => firstBehaviour,
typeof(IDispatchBehavior<TestQuery, TestQueryResult>),
(IServiceProvider _) => firstBehavior,
ServiceLifetime.Transient
),
new ServiceDescriptor(
typeof(IDispatchBehaviour<TestQuery, TestQueryResult>),
(IServiceProvider _) => secondBehaviour,
typeof(IDispatchBehavior<TestQuery, TestQueryResult>),
(IServiceProvider _) => secondBehavior,
ServiceLifetime.Transient
),
new ServiceDescriptor(
typeof(IDispatchBehaviour<,>),
typeof(TestOpenBehaviour<,>),
typeof(IDispatchBehavior<,>),
typeof(TestOpenBehavior<,>),
ServiceLifetime.Transient
),
];
serviceCollection.AddTransient<Action<TestQuery>>(_ => (TestQuery _) => calls.Add("thirdBehaviour"));
serviceCollection.AddTransient<Action<TestQuery>>(_ => (TestQuery _) => calls.Add("thirdBehavior"));
var services = serviceCollection.BuildServiceProvider();
var sut = new QueryDispatcherImpl(services, new ConcurrentMethodsCache());
@@ -115,11 +119,11 @@ public class Dispatch
// Then
result.ShouldBeSameAs(testQueryResult);
await firstBehaviour.Received(1).Handle(testQuery, Arg.Any<DispatchFurtherDelegate<TestQueryResult>>(), Arg.Any<CancellationToken>());
await secondBehaviour.Received(1).Handle(testQuery, Arg.Any<DispatchFurtherDelegate<TestQueryResult>>(), Arg.Any<CancellationToken>());
await firstBehavior.Received(1).Handle(testQuery, Arg.Any<DispatchFurtherDelegate<TestQueryResult>>(), Arg.Any<CancellationToken>());
await secondBehavior.Received(1).Handle(testQuery, Arg.Any<DispatchFurtherDelegate<TestQueryResult>>(), Arg.Any<CancellationToken>());
await queryHandler.Received(1).Handle(testQuery, CancellationToken.None);
calls.ShouldBe(["firstBehaviour", "secondBehaviour", "thirdBehaviour", "queryHandler"]);
calls.ShouldBe(["firstBehavior", "secondBehavior", "thirdBehavior", "queryHandler"]);
}
[Fact]
@@ -131,20 +135,20 @@ public class Dispatch
var testQueryResultAborted = new TestQueryResult();
List<string> calls = [];
var queryHandler = Substitute.For<IQueryHandler<TestQuery, TestQueryResult>>();
var queryHandler = Substitute.For<TestQueryHandler>();
queryHandler.Handle(testQuery, CancellationToken.None)
.Returns(testQueryResult)
.AndDoes(_ => calls.Add("queryHandler"));
var firstBehaviour = Substitute.For<IDispatchBehaviour<TestQuery, TestQueryResult>>();
firstBehaviour.Handle(testQuery, Arg.Any<DispatchFurtherDelegate<TestQueryResult>>(), Arg.Any<CancellationToken>())
var firstBehavior = Substitute.For<IDispatchBehavior<TestQuery, TestQueryResult>>();
firstBehavior.Handle(testQuery, Arg.Any<DispatchFurtherDelegate<TestQueryResult>>(), Arg.Any<CancellationToken>())
.Returns(args => ((DispatchFurtherDelegate<TestQueryResult>)args[1]).Invoke())
.AndDoes(_ => calls.Add("firstBehaviour"));
.AndDoes(_ => calls.Add("firstBehavior"));
var secondBehaviour = Substitute.For<IDispatchBehaviour<TestQuery, TestQueryResult>>();
secondBehaviour.Handle(testQuery, Arg.Any<DispatchFurtherDelegate<TestQueryResult>>(), Arg.Any<CancellationToken>())
var secondBehavior = Substitute.For<IDispatchBehavior<TestQuery, TestQueryResult>>();
secondBehavior.Handle(testQuery, Arg.Any<DispatchFurtherDelegate<TestQueryResult>>(), Arg.Any<CancellationToken>())
.Returns(args => ValueTask.FromResult(testQueryResultAborted))
.AndDoes(_ => calls.Add("secondBehaviour"));
.AndDoes(_ => calls.Add("secondBehavior"));
ServiceCollection serviceCollection =
[
@@ -154,22 +158,22 @@ public class Dispatch
ServiceLifetime.Transient
),
new ServiceDescriptor(
typeof(IDispatchBehaviour<TestQuery, TestQueryResult>),
(IServiceProvider _) => firstBehaviour,
typeof(IDispatchBehavior<TestQuery, TestQueryResult>),
(IServiceProvider _) => firstBehavior,
ServiceLifetime.Transient
),
new ServiceDescriptor(
typeof(IDispatchBehaviour<TestQuery, TestQueryResult>),
(IServiceProvider _) => secondBehaviour,
typeof(IDispatchBehavior<TestQuery, TestQueryResult>),
(IServiceProvider _) => secondBehavior,
ServiceLifetime.Transient
),
new ServiceDescriptor(
typeof(IDispatchBehaviour<,>),
typeof(TestOpenBehaviour<,>),
typeof(IDispatchBehavior<,>),
typeof(TestOpenBehavior<,>),
ServiceLifetime.Transient
),
];
serviceCollection.AddTransient<Action<TestQuery>>(_ => (TestQuery _) => calls.Add("thirdBehaviour"));
serviceCollection.AddTransient<Action<TestQuery>>(_ => (TestQuery _) => calls.Add("thirdBehavior"));
var services = serviceCollection.BuildServiceProvider();
var sut = new QueryDispatcherImpl(services, new ConcurrentMethodsCache());
@@ -179,10 +183,59 @@ public class Dispatch
// Then
result.ShouldBeSameAs(testQueryResultAborted);
await firstBehaviour.Received(1).Handle(testQuery, Arg.Any<DispatchFurtherDelegate<TestQueryResult>>(), Arg.Any<CancellationToken>());
await secondBehaviour.Received(1).Handle(testQuery, Arg.Any<DispatchFurtherDelegate<TestQueryResult>>(), Arg.Any<CancellationToken>());
await firstBehavior.Received(1).Handle(testQuery, Arg.Any<DispatchFurtherDelegate<TestQueryResult>>(), Arg.Any<CancellationToken>());
await secondBehavior.Received(1).Handle(testQuery, Arg.Any<DispatchFurtherDelegate<TestQueryResult>>(), Arg.Any<CancellationToken>());
await queryHandler.Received(0).Handle(testQuery, CancellationToken.None);
calls.ShouldBe(["firstBehaviour", "secondBehaviour"]);
calls.ShouldBe(["firstBehavior", "secondBehavior"]);
}
public abstract class AnotherTestQueryHandler : IQueryHandler<TestQuery, AnotherTestQueryResult>
{
public abstract ValueTask<AnotherTestQueryResult> 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<TestQueryHandler>();
queryHandler.Handle(testQuery, CancellationToken.None).Returns(testQueryResult);
var anotherQueryHandler = Substitute.For<AnotherTestQueryHandler>();
anotherQueryHandler.Handle(testQuery, CancellationToken.None).Returns(anotherTestQueryResult);
ServiceCollection serviceCollection =
[
new ServiceDescriptor(
typeof(IQueryHandler<TestQuery, TestQueryResult>),
(IServiceProvider _) => queryHandler,
ServiceLifetime.Transient
),
new ServiceDescriptor(
typeof(IQueryHandler<TestQuery, AnotherTestQueryResult>),
(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<AnotherTestQueryResult>(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);
}
}