Compare commits

2 Commits

Author SHA1 Message Date
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
18 changed files with 322 additions and 102 deletions

View File

@@ -25,6 +25,15 @@ tab_width = 4
end_of_line = lf end_of_line = lf
insert_final_newline = true 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 #### #### .NET Coding Conventions ####
[*.{cs,vb}] [*.{cs,vb}]

View File

@@ -10,13 +10,16 @@ jobs:
publish: publish:
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
DOTNET_CLI_TELEMETRY_OPTOUT: true
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
- name: Setup .NET - name: Setup .NET
uses: https://github.com/actions/setup-dotnet@v4 uses: https://github.com/actions/setup-dotnet@v4
with: with:
dotnet-version: 9.x dotnet-version: 10.x
- name: Restore dependencies - name: Restore dependencies
run: dotnet restore --nologo run: dotnet restore --nologo

View File

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

View File

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

View File

@@ -1,9 +1,15 @@
using System.Diagnostics.CodeAnalysis;
using Just.Cqrs.Internal; using Just.Cqrs.Internal;
namespace Just.Cqrs; namespace Just.Cqrs;
public interface ICommandHandler<TCommand, TCommandResult> : ICommandHandlerImpl public interface ICommandHandler<TCommand, TCommandResult> : ICommandHandlerImpl, IGenericHandler<TCommand, TCommandResult>
where TCommand : notnull 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

@@ -1,9 +1,15 @@
using System.Diagnostics.CodeAnalysis;
using Just.Cqrs.Internal; using Just.Cqrs.Internal;
namespace Just.Cqrs; namespace Just.Cqrs;
public interface IQueryHandler<TQuery, TQueryResult> : IQueryHandlerImpl public interface IQueryHandler<TQuery, TQueryResult> : IQueryHandlerImpl, IGenericHandler<TQuery, TQueryResult>
where TQuery : notnull 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; namespace Just.Cqrs.Internal;
/// <summary>
/// Marker interface for static type checking. Should not be used directly.
/// </summary>
public interface ICommandHandlerImpl { } 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; namespace Just.Cqrs.Internal;
/// <summary>
/// Marker interface for static type checking. Should not be used directly.
/// </summary>
public interface IQueryHandlerImpl { } public interface IQueryHandlerImpl { }

View File

@@ -8,15 +8,19 @@ namespace Microsoft.Extensions.DependencyInjection;
public static class CqrsServicesExtensions 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) public static IServiceCollection AddCqrs(this IServiceCollection services, Action<CqrsServicesOptions>? configure = null)
{ {
var options = new CqrsServicesOptions(services); var options = new CqrsServicesOptions(services);
configure?.Invoke(options); configure?.Invoke(options);
services.TryAddKeyedSingleton<IMethodsCache, ConcurrentMethodsCache>(MethodsCacheServiceKey.DispatchCommand); services.TryAddSingleton<IMethodsCache, ConcurrentMethodsCache>();
services.TryAddTransient<ICommandDispatcher, CommandDispatcherImpl>(); services.TryAddTransient<ICommandDispatcher, CommandDispatcherImpl>();
services.TryAddKeyedSingleton<IMethodsCache, ConcurrentMethodsCache>(MethodsCacheServiceKey.DispatchQuery);
services.TryAddTransient<IQueryDispatcher, QueryDispatcherImpl>(); services.TryAddTransient<IQueryDispatcher, QueryDispatcherImpl>();
foreach (var (service, impl, lifetime) in options.CommandHandlers) foreach (var (service, impl, lifetime) in options.CommandHandlers)
@@ -75,23 +79,23 @@ public static class CqrsServicesExtensions
return options; return options;
} }
public static CqrsServicesOptions AddOpenBehavior(this CqrsServicesOptions options, Type Behavior, ServiceLifetime lifetime = ServiceLifetime.Singleton) public static CqrsServicesOptions AddOpenBehavior(this CqrsServicesOptions options, Type behavior, ServiceLifetime lifetime = ServiceLifetime.Singleton)
{ {
var interfaces = Behavior.FindInterfaces( var interfaces = behavior.FindInterfaces(
static (x, t) => x.IsGenericType && x.GetGenericTypeDefinition() == (Type)t!, static (x, t) => x.IsGenericType && x.GetGenericTypeDefinition() == (Type)t!,
typeof(IDispatchBehavior<,>)); typeof(IDispatchBehavior<,>));
if (interfaces.Length == 0) if (interfaces.Length == 0)
{ {
throw new ArgumentException("Supplied type does not implement IDispatchBehavior<,> interface.", nameof(Behavior)); throw new ArgumentException("Supplied type does not implement IDispatchBehavior<,> interface.", nameof(behavior));
} }
if (!Behavior.ContainsGenericParameters) if (!behavior.ContainsGenericParameters)
{ {
throw new ArgumentException("Supplied type is not sutable for open Behavior.", nameof(Behavior)); throw new ArgumentException("Supplied type is not suitable for open Behavior.", nameof(behavior));
} }
options.Behaviors.Add((typeof(IDispatchBehavior<,>), Behavior, lifetime)); options.Behaviors.Add((typeof(IDispatchBehavior<,>), behavior, lifetime));
return options; return options;
} }

View File

@@ -6,27 +6,25 @@ namespace Just.Cqrs.Internal;
internal sealed class CommandDispatcherImpl( internal sealed class CommandDispatcherImpl(
IServiceProvider services, IServiceProvider services,
[FromKeyedServices(MethodsCacheServiceKey.DispatchCommand)]IMethodsCache methodsCache IMethodsCache methodsCache
) : ICommandDispatcher ) : 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] [ExcludeFromCodeCoverage]
public ValueTask<TCommandResult> Dispatch<TCommandResult>(object command, CancellationToken cancellationToken) 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) public ValueTask<TCommandResult> Dispatch<TCommandResult>(IKnownCommand<TCommandResult> command, CancellationToken cancellationToken)
=> DispatchCommand<TCommandResult>(command, cancellationToken); => DispatchInternal<TCommandResult>(CreateDispatchCommandDelegate, 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])!;
}
private ValueTask<TCommandResult> DispatchCommandImpl<TCommand, TCommandResult>( private ValueTask<TCommandResult> DispatchCommandImpl<TCommand, TCommandResult>(
TCommand command, TCommand command,
@@ -35,13 +33,7 @@ internal sealed class CommandDispatcherImpl(
{ {
var handler = services.GetRequiredService<ICommandHandler<TCommand, TCommandResult>>(); var handler = services.GetRequiredService<ICommandHandler<TCommand, TCommandResult>>();
var pipeline = services.GetServices<IDispatchBehavior<TCommand, TCommandResult>>(); var pipeline = services.GetServices<IDispatchBehavior<TCommand, TCommandResult>>();
using var pipelineEnumerator = pipeline.GetEnumerator();
return DispatchDelegateFactory(pipelineEnumerator).Invoke(); return DispatchDelegateFactory(command, handler, pipeline, cancellationToken).Invoke();
DispatchFurtherDelegate<TCommandResult> DispatchDelegateFactory(IEnumerator<IDispatchBehavior<TCommand, TCommandResult>> enumerator) =>
enumerator.MoveNext()
? (() => enumerator.Current.Handle(command, DispatchDelegateFactory(enumerator), cancellationToken))
: (() => handler.Handle(command, cancellationToken));
} }
} }

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.Collections.Concurrent;
using System.Reflection;
namespace Just.Cqrs.Internal; namespace Just.Cqrs.Internal;
internal interface IMethodsCache 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 sealed class ConcurrentMethodsCache : ConcurrentDictionary<(Type RequestType, Type ResponseType), Delegate>, IMethodsCache;
{
internal const string DispatchQuery = "q";
internal const string DispatchCommand = "c";
}
internal sealed class ConcurrentMethodsCache : ConcurrentDictionary<Type, MethodInfo>, IMethodsCache;

View File

@@ -6,27 +6,25 @@ namespace Just.Cqrs.Internal;
internal sealed class QueryDispatcherImpl( internal sealed class QueryDispatcherImpl(
IServiceProvider services, IServiceProvider services,
[FromKeyedServices(MethodsCacheServiceKey.DispatchQuery)]IMethodsCache methodsCache IMethodsCache methodsCache
) : IQueryDispatcher ) : 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] [ExcludeFromCodeCoverage]
public ValueTask<TQueryResult> Dispatch<TQueryResult>(object query, CancellationToken cancellationToken) 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) public ValueTask<TQueryResult> Dispatch<TQueryResult>(IKnownQuery<TQueryResult> query, CancellationToken cancellationToken)
=> DispatchQuery<TQueryResult>(query, cancellationToken); => DispatchInternal<TQueryResult>(CreateDispatchQueryDelegate, 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])!;
}
private ValueTask<TQueryResult> DispatchQueryImpl<TQuery, TQueryResult>( private ValueTask<TQueryResult> DispatchQueryImpl<TQuery, TQueryResult>(
TQuery query, TQuery query,
@@ -35,13 +33,7 @@ internal sealed class QueryDispatcherImpl(
{ {
var handler = services.GetRequiredService<IQueryHandler<TQuery, TQueryResult>>(); var handler = services.GetRequiredService<IQueryHandler<TQuery, TQueryResult>>();
var pipeline = services.GetServices<IDispatchBehavior<TQuery, TQueryResult>>(); var pipeline = services.GetServices<IDispatchBehavior<TQuery, TQueryResult>>();
using var pipelineEnumerator = pipeline.GetEnumerator();
return DispatchDelegateFactory(pipelineEnumerator).Invoke(); return DispatchDelegateFactory(query, handler, pipeline, cancellationToken).Invoke();
DispatchFurtherDelegate<TQueryResult> DispatchDelegateFactory(IEnumerator<IDispatchBehavior<TQuery, TQueryResult>> enumerator) =>
enumerator.MoveNext()
? (() => enumerator.Current.Handle(query, DispatchDelegateFactory(enumerator), cancellationToken))
: (() => handler.Handle(query, cancellationToken));
} }
} }

View File

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

View File

@@ -6,6 +6,10 @@ namespace Cqrs.Tests.CommandDispatcherImplTests;
public class Dispatch 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 TestCommand : IKnownCommand<TestCommandResult> {}
public class TestCommandResult {} public class TestCommandResult {}
@@ -19,7 +23,7 @@ public class Dispatch
var testCommand = new TestCommand(); var testCommand = new TestCommand();
var testCommandResult = new TestCommandResult(); var testCommandResult = new TestCommandResult();
var commandHandler = Substitute.For<ICommandHandler<TestCommand, TestCommandResult>>(); var commandHandler = Substitute.For<TestCommandHandler>();
commandHandler.Handle(testCommand, CancellationToken.None).Returns(testCommandResult); commandHandler.Handle(testCommand, CancellationToken.None).Returns(testCommandResult);
ServiceCollection serviceCollection = ServiceCollection serviceCollection =
@@ -67,7 +71,7 @@ public class Dispatch
var testCommandResult = new TestCommandResult(); var testCommandResult = new TestCommandResult();
List<string> calls = []; List<string> calls = [];
var commandHandler = Substitute.For<ICommandHandler<TestCommand, TestCommandResult>>(); var commandHandler = Substitute.For<TestCommandHandler>();
commandHandler.Handle(testCommand, CancellationToken.None) commandHandler.Handle(testCommand, CancellationToken.None)
.Returns(testCommandResult) .Returns(testCommandResult)
.AndDoes(_ => calls.Add("commandHandler")); .AndDoes(_ => calls.Add("commandHandler"));
@@ -131,7 +135,7 @@ public class Dispatch
var testCommandResultAborted = new TestCommandResult(); var testCommandResultAborted = new TestCommandResult();
List<string> calls = []; List<string> calls = [];
var commandHandler = Substitute.For<ICommandHandler<TestCommand, TestCommandResult>>(); var commandHandler = Substitute.For<TestCommandHandler>();
commandHandler.Handle(testCommand, CancellationToken.None) commandHandler.Handle(testCommand, CancellationToken.None)
.Returns(testCommandResult) .Returns(testCommandResult)
.AndDoes(_ => calls.Add("commandHandler")); .AndDoes(_ => calls.Add("commandHandler"));
@@ -185,4 +189,53 @@ public class Dispatch
calls.ShouldBe(["firstBehavior", "secondBehavior"]); 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"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFrameworks>net8.0;net9.0</TargetFrameworks> <TargetFrameworks>net8.0;net9.0;net10.0</TargetFrameworks>
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
<OutputType>Exe</OutputType>
<IsPackable>false</IsPackable> <IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.2" /> <PackageReference Include="xunit.v3" Version="3.2.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" /> <PackageReference Include="coverlet.collector" Version="6.0.4">
<PackageReference Include="xunit" Version="2.9.2" /> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" /> <PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Shouldly" Version="4.3.0" /> <PackageReference Include="Shouldly" Version="4.3.0" />
<PackageReference Include="NSubstitute" Version="5.3.0" /> <PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="NSubstitute.Analyzers.CSharp" Version="1.0.17"> <PackageReference Include="NSubstitute.Analyzers.CSharp" Version="1.0.17">
@@ -20,8 +23,12 @@
</PackageReference> </PackageReference>
</ItemGroup> </ItemGroup>
<ItemGroup Condition="$(TargetFramework) == 'net9.0' Or $(TargetFramework) == 'netstandard2.1'"> <ItemGroup Condition="$(TargetFramework) == 'net10.0' Or $(TargetFramework) == 'netstandard2.1'">
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.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>
<ItemGroup Condition="$(TargetFramework) == 'net8.0'"> <ItemGroup Condition="$(TargetFramework) == 'net8.0'">

View File

@@ -6,6 +6,10 @@ namespace Cqrs.Tests.QueryDispatcherImplTests;
public class Dispatch 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 TestQuery : IKnownQuery<TestQueryResult> {}
public class TestQueryResult {} public class TestQueryResult {}
@@ -19,7 +23,7 @@ public class Dispatch
var testQuery = new TestQuery(); var testQuery = new TestQuery();
var testQueryResult = new TestQueryResult(); var testQueryResult = new TestQueryResult();
var queryHandler = Substitute.For<IQueryHandler<TestQuery, TestQueryResult>>(); var queryHandler = Substitute.For<TestQueryHandler>();
queryHandler.Handle(testQuery, CancellationToken.None).Returns(testQueryResult); queryHandler.Handle(testQuery, CancellationToken.None).Returns(testQueryResult);
ServiceCollection serviceCollection = ServiceCollection serviceCollection =
@@ -67,7 +71,7 @@ public class Dispatch
var testQueryResult = new TestQueryResult(); var testQueryResult = new TestQueryResult();
List<string> calls = []; List<string> calls = [];
var queryHandler = Substitute.For<IQueryHandler<TestQuery, TestQueryResult>>(); var queryHandler = Substitute.For<TestQueryHandler>();
queryHandler.Handle(testQuery, CancellationToken.None) queryHandler.Handle(testQuery, CancellationToken.None)
.Returns(testQueryResult) .Returns(testQueryResult)
.AndDoes(_ => calls.Add("queryHandler")); .AndDoes(_ => calls.Add("queryHandler"));
@@ -131,7 +135,7 @@ public class Dispatch
var testQueryResultAborted = new TestQueryResult(); var testQueryResultAborted = new TestQueryResult();
List<string> calls = []; List<string> calls = [];
var queryHandler = Substitute.For<IQueryHandler<TestQuery, TestQueryResult>>(); var queryHandler = Substitute.For<TestQueryHandler>();
queryHandler.Handle(testQuery, CancellationToken.None) queryHandler.Handle(testQuery, CancellationToken.None)
.Returns(testQueryResult) .Returns(testQueryResult)
.AndDoes(_ => calls.Add("queryHandler")); .AndDoes(_ => calls.Add("queryHandler"));
@@ -185,4 +189,53 @@ public class Dispatch
calls.ShouldBe(["firstBehavior", "secondBehavior"]); 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);
}
} }