feat(dispatcher): add registration-time pipeline behavior ordering via AddBehavior<T>(order:)

Pipeline behavior ordering previously depended on discovery/registration
order. Add an AddBehavior<T>(order:) API that captures the order at
registration time; the BehaviorTypeIndex now orders matching behaviors by
that explicit order (lower runs first), falling back to registration order
for ties. Removed the runtime Order property approach.
This commit is contained in:
Louis Seubert 2026-07-12 19:30:59 +02:00
commit f9de3a99de
Signed by: louis9902
GPG key ID: 4B9DB28F826553BD
7 changed files with 143 additions and 17 deletions

View file

@ -56,6 +56,7 @@ To have a consistent experience across all packages, some public interfaces have
- **request.validation:** Make `Severity` meaningful — `Validation.IsValid` now only fails on `Error` problems; `Warning`/`Info` no longer invalidate. Added `Validation.IsValidFor(Severity)` to set the failing-severity threshold - **request.validation:** Make `Severity` meaningful — `Validation.IsValid` now only fails on `Error` problems; `Warning`/`Info` no longer invalidate. Added `Validation.IsValidFor(Severity)` to set the failing-severity threshold
- **request.dispatcher:** Throw `InvalidOperationException` listing candidates when multiple handlers match a request instead of silently resolving via `.First()`. Also throws when no handler is registered - **request.dispatcher:** Throw `InvalidOperationException` listing candidates when multiple handlers match a request instead of silently resolving via `.First()`. Also throws when no handler is registered
- **request.dispatcher:** Resolve handlers/behaviors in registration order across closed and open generics instead of the fixed closed-before-open reflection order - **request.dispatcher:** Resolve handlers/behaviors in registration order across closed and open generics instead of the fixed closed-before-open reflection order
- **request.dispatcher:** Add `AddBehavior<T>(order:)` registration-time API so pipeline behavior ordering is explicit and deterministic (lower order runs first, registration order as tie-break) instead of relying on reflection/discovery order
### Removed ### Removed

View file

@ -87,6 +87,26 @@ internal sealed class ScalarBehaviourTests
await Assert.That(tracker.Executed).IsTrue(); await Assert.That(tracker.Executed).IsTrue();
} }
[Test]
public async Task I_can_order_behaviours_explicitly_by_order_property()
{
var sc = new ServiceCollection();
sc.AddSingleton<ScalarTestTracker>();
sc.AddRequestDispatcher(builder => builder
.Add(typeof(ScalarTestHandler))
.AddBehavior<OrderedScalarBehaviorA>(2)
.AddBehavior<OrderedScalarBehaviorB>(1));
var provider = sc.BuildServiceProvider();
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
var tracker = provider.GetRequiredService<ScalarTestTracker>();
var request = new ScalarTestRequest { Value = "Hello" };
await dispatcher.DispatchAsync(request);
// Registered A (order 2) then B (order 1) via AddBehavior; explicit order overrides discovery order.
await Assert.That(tracker.Log).IsEquivalentTo(["OrderedB", "OrderedA"]);
}
[Test] [Test]
public async Task I_can_maintain_the_ordering_between_open_and_closed_behaviours() public async Task I_can_maintain_the_ordering_between_open_and_closed_behaviours()
{ {

View file

@ -66,6 +66,26 @@ internal sealed class StreamBehaviourTests
await Assert.That(tracker.Log[1]).IsEquivalentTo("Behaviour2"); await Assert.That(tracker.Log[1]).IsEquivalentTo("Behaviour2");
} }
[Test]
public async Task I_can_order_behaviours_explicitly_by_order_property()
{
var sc = new ServiceCollection();
sc.AddSingleton<StreamTestTracker>();
sc.AddRequestDispatcher(builder => builder
.Add(typeof(StreamTestHandler))
.AddBehavior<OrderedStreamBehaviorA>(2)
.AddBehavior<OrderedStreamBehaviorB>(1));
var provider = sc.BuildServiceProvider();
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
var tracker = provider.GetRequiredService<StreamTestTracker>();
var request = new StreamTestRequest { Value = "Hello" };
await dispatcher.DispatchAsync(request).ToListAsync();
// Registered A (order 2) then B (order 1) via AddBehavior; explicit order overrides discovery order.
await Assert.That(tracker.Log).IsEquivalentTo(["OrderedB", "OrderedA"]);
}
[Test] [Test]
public async Task I_can_work_with_a_generic_wrapper_request_and_the_open_behaviour() public async Task I_can_work_with_a_generic_wrapper_request_and_the_open_behaviour()
{ {

View file

@ -0,0 +1,22 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Dispatcher.Tests;
public class OrderedScalarBehaviorA(ScalarTestTracker tracker) : IScalarRequestBehavior<ScalarTestRequest, string>
{
public async Task<string> HandleAsync(ScalarTestRequest request, ScalarHandlerDelegate<string> next, CancellationToken cancellationToken)
{
tracker.Log.Add("OrderedA");
return await next(request, cancellationToken);
}
}
public class OrderedScalarBehaviorB(ScalarTestTracker tracker) : IScalarRequestBehavior<ScalarTestRequest, string>
{
public async Task<string> HandleAsync(ScalarTestRequest request, ScalarHandlerDelegate<string> next, CancellationToken cancellationToken)
{
tracker.Log.Add("OrderedB");
return await next(request, cancellationToken);
}
}

View file

@ -0,0 +1,28 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Dispatcher.Tests;
public class OrderedStreamBehaviorA(StreamTestTracker tracker) : IStreamRequestBehavior<StreamTestRequest, string>
{
public async IAsyncEnumerable<string> HandleAsync(StreamTestRequest request, StreamHandlerDelegate<string> next, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
tracker.Log.Add("OrderedA");
await foreach (var item in next(request, cancellationToken))
{
yield return item;
}
}
}
public class OrderedStreamBehaviorB(StreamTestTracker tracker) : IStreamRequestBehavior<StreamTestRequest, string>
{
public async IAsyncEnumerable<string> HandleAsync(StreamTestRequest request, StreamHandlerDelegate<string> next, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
tracker.Log.Add("OrderedB");
await foreach (var item in next(request, cancellationToken))
{
yield return item;
}
}
}

View file

@ -70,6 +70,27 @@ public static class RequestDispatcherBuilderExtensions
return builder; return builder;
} }
/// <summary>
/// Adds the specified pipeline behavior to the request dispatcher configuration, with an explicit
/// pipeline <paramref name="order"/> used to determine its position in the pipeline. Lower values run
/// first (outermost); behaviors with equal order run in registration order.
/// </summary>
/// <typeparam name="TBehavior">The behavior type to add. Must implement a request behavior interface.</typeparam>
/// <param name="builder">The <see cref="IRequestDispatcherBuilder"/> to configure.</param>
/// <param name="order">The pipeline order for this behavior. Defaults to <c>0</c>.</param>
/// <returns>The <see cref="IRequestDispatcherBuilder"/> instance for further configuration.</returns>
public static IRequestDispatcherBuilder AddBehavior<TBehavior>(this IRequestDispatcherBuilder builder, int order = 0)
where TBehavior : class
{
ArgumentNullException.ThrowIfNull(builder);
ValidateNoNestedRequestHandlers([typeof(TBehavior)]);
builder.Services.AddOptions<RequestDispatcherOptions>()
.Configure(options => options.Inspect([typeof(TBehavior)], order));
return builder;
}
/// <summary> /// <summary>
/// Adds the specified type to the request dispatcher configuration for inspection. /// Adds the specified type to the request dispatcher configuration for inspection.
/// </summary> /// </summary>

View file

@ -10,24 +10,27 @@ namespace Geekeey.Request.Dispatcher;
internal sealed class RequestDispatcherOptions internal sealed class RequestDispatcherOptions
{ {
private readonly List<Type> _search = []; private readonly List<(Type Type, int? Order)> _search = [];
private readonly Lazy<TypeIndex> _behaviorsTypeIndex; private readonly Lazy<TypeIndex> _behaviorsTypeIndex;
private readonly Lazy<TypeIndex> _handlersTypeIndex; private readonly Lazy<TypeIndex> _handlersTypeIndex;
public RequestDispatcherOptions() public RequestDispatcherOptions()
{ {
_behaviorsTypeIndex = new Lazy<TypeIndex>(() => new BehaviorTypeIndex(_search.Distinct())); _behaviorsTypeIndex = new Lazy<TypeIndex>(() => new BehaviorTypeIndex(_search.DistinctBy(x => x.Type)));
_handlersTypeIndex = new Lazy<TypeIndex>(() => new HandlerTypeIndex(_search.Distinct())); _handlersTypeIndex = new Lazy<TypeIndex>(() => new HandlerTypeIndex(_search.DistinctBy(x => x.Type)));
} }
public void Inspect(IEnumerable<Type> assembly) public void Inspect(IEnumerable<Type> assembly, int? order = null)
{ {
if (_behaviorsTypeIndex.IsValueCreated || _handlersTypeIndex.IsValueCreated) if (_behaviorsTypeIndex.IsValueCreated || _handlersTypeIndex.IsValueCreated)
{ {
throw new InvalidOperationException("The type index has already been created. Cannot inspect new assemblies."); throw new InvalidOperationException("The type index has already been created. Cannot inspect new assemblies.");
} }
_search.AddRange(assembly); foreach (var type in assembly)
{
_search.Add((type, order));
}
} }
public IEnumerable<T> GetRequestBehaviors<T>(IServiceProvider services) public IEnumerable<T> GetRequestBehaviors<T>(IServiceProvider services)
@ -47,15 +50,21 @@ internal sealed class RequestDispatcherOptions
protected readonly Dictionary<Type, List<Type>> _closedTypeInfo = []; protected readonly Dictionary<Type, List<Type>> _closedTypeInfo = [];
protected readonly List<Type> _openTypeInfo = []; protected readonly List<Type> _openTypeInfo = [];
protected readonly Dictionary<Type, int> _order = []; protected readonly Dictionary<Type, int> _order = [];
protected readonly Dictionary<Type, int> _explicitOrder = [];
protected TypeIndex(IEnumerable<Type> collection, Func<Type, bool> predicate) protected TypeIndex(IEnumerable<(Type Type, int? Order)> collection, Func<Type, bool> predicate)
{ {
var index = 0; var index = 0;
foreach (var type in collection) foreach (var (type, order) in collection)
{ {
_order[type] = index++; _order[type] = index++;
if (order is { } explicitOrder)
{
_explicitOrder[type] = explicitOrder;
}
if (type.IsGenericTypeDefinition) if (type.IsGenericTypeDefinition)
{ {
if (type.GetInterfaces().Any(predicate)) if (type.GetInterfaces().Any(predicate))
@ -73,6 +82,11 @@ internal sealed class RequestDispatcherOptions
} }
} }
protected int EffectiveOrder(Type type)
{
return _explicitOrder.TryGetValue(type, out var order) ? order : _order[type];
}
public IEnumerable<T> Resolve<T>(IServiceProvider services) public IEnumerable<T> Resolve<T>(IServiceProvider services)
{ {
return (IEnumerable<T>)_cache.GetOrAdd(typeof(T), CreateResolverFactory<T>)(services); return (IEnumerable<T>)_cache.GetOrAdd(typeof(T), CreateResolverFactory<T>)(services);
@ -102,7 +116,7 @@ internal sealed class RequestDispatcherOptions
type.GetGenericTypeDefinition() == typeof(IStreamRequestHandler<,>)); type.GetGenericTypeDefinition() == typeof(IStreamRequestHandler<,>));
} }
private sealed class HandlerTypeIndex(IEnumerable<Type> collection) private sealed class HandlerTypeIndex(IEnumerable<(Type Type, int? Order)> collection)
: TypeIndex(collection, IsRequestHandlerType) : TypeIndex(collection, IsRequestHandlerType)
{ {
protected override IReadOnlyList<Type> IsAssignableTo(Type @interface) protected override IReadOnlyList<Type> IsAssignableTo(Type @interface)
@ -113,7 +127,7 @@ internal sealed class RequestDispatcherOptions
{ {
foreach (var type in list) foreach (var type in list)
{ {
result.Add((_order[type], type)); result.Add((EffectiveOrder(type), type));
} }
} }
@ -129,7 +143,7 @@ internal sealed class RequestDispatcherOptions
var impl = type.MakeGenericType(requestType); var impl = type.MakeGenericType(requestType);
if (impl.IsAssignableTo(@interface)) if (impl.IsAssignableTo(@interface))
{ {
result.Add((_order[type], impl)); result.Add((EffectiveOrder(type), impl));
} }
} }
catch (ArgumentException) catch (ArgumentException)
@ -145,7 +159,7 @@ internal sealed class RequestDispatcherOptions
var impl = type.MakeGenericType(requestType.GetGenericArguments()); var impl = type.MakeGenericType(requestType.GetGenericArguments());
if (impl.IsAssignableTo(@interface)) if (impl.IsAssignableTo(@interface))
{ {
result.Add((_order[type], impl)); result.Add((EffectiveOrder(type), impl));
} }
} }
} }
@ -154,7 +168,7 @@ internal sealed class RequestDispatcherOptions
} }
} }
return result.OrderBy(x => x.Order).Select(x => x.Type).ToList(); return [.. result.OrderBy(static item => item.Order).Select(static item => item.Type)];
} }
} }
@ -165,7 +179,7 @@ internal sealed class RequestDispatcherOptions
type.GetGenericTypeDefinition() == typeof(IStreamRequestBehavior<,>)); type.GetGenericTypeDefinition() == typeof(IStreamRequestBehavior<,>));
} }
private sealed class BehaviorTypeIndex(IEnumerable<Type> collection) private sealed class BehaviorTypeIndex(IEnumerable<(Type Type, int? Order)> collection)
: TypeIndex(collection, IsRequestBehaviorType) : TypeIndex(collection, IsRequestBehaviorType)
{ {
protected override IReadOnlyList<Type> IsAssignableTo(Type @interface) protected override IReadOnlyList<Type> IsAssignableTo(Type @interface)
@ -176,7 +190,7 @@ internal sealed class RequestDispatcherOptions
{ {
foreach (var type in list) foreach (var type in list)
{ {
result.Add((_order[type], type)); result.Add((EffectiveOrder(type), type));
} }
} }
@ -191,7 +205,7 @@ internal sealed class RequestDispatcherOptions
var impl = behaviour.MakeGenericType(requestType, responseType); var impl = behaviour.MakeGenericType(requestType, responseType);
if (impl.IsAssignableTo(@interface)) if (impl.IsAssignableTo(@interface))
{ {
result.Add((_order[behaviour], impl)); result.Add((EffectiveOrder(behaviour), impl));
} }
} }
catch (ArgumentException) catch (ArgumentException)
@ -204,7 +218,7 @@ internal sealed class RequestDispatcherOptions
var impl = behaviour.MakeGenericType(requestType.GetGenericArguments()); var impl = behaviour.MakeGenericType(requestType.GetGenericArguments());
if (impl.IsAssignableTo(@interface)) if (impl.IsAssignableTo(@interface))
{ {
result.Add((_order[behaviour], impl)); result.Add((EffectiveOrder(behaviour), impl));
} }
} }
catch (ArgumentException) catch (ArgumentException)
@ -212,7 +226,7 @@ internal sealed class RequestDispatcherOptions
} }
} }
return result.OrderBy(x => x.Order).Select(x => x.Type).ToList(); return [.. result.OrderBy(static item => item.Order).Select(static item => item.Type)];
} }
} }
} }