Skip to content

Commit 48934c7

Browse files
committed
Adding open generic handler support
1 parent f84aaaf commit 48934c7

9 files changed

Lines changed: 270 additions & 28 deletions

File tree

docs/guide/handler-conventions.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,52 @@ public class UserHandler
173173

174174
**Note:** Handlers are singleton by default. Constructor dependencies are resolved once and shared across all invocations.
175175

176+
### Open Generic Handlers
177+
178+
Handlers can be declared as open generic classes and will be automatically closed for the concrete message type at runtime. This lets you build reusable handler logic that applies to many message types.
179+
180+
```csharp
181+
// Generic command definitions
182+
public record UpdateEntity<T>(T Entity);
183+
public record UpdateRelation<TLeft, TRight>(TLeft Left, TRight Right);
184+
185+
// Open generic handler (single generic parameter)
186+
public class EntityHandler<T>
187+
{
188+
public Task HandleAsync(UpdateEntity<T> command, CancellationToken ct)
189+
{
190+
// process update for entity of type T
191+
return Task.CompletedTask;
192+
}
193+
}
194+
195+
// Open generic handler (two generic parameters)
196+
public class RelationHandler<TLeft, TRight>
197+
{
198+
public Task HandleAsync(UpdateRelation<TLeft, TRight> command, CancellationToken ct)
199+
{
200+
return Task.CompletedTask;
201+
}
202+
}
203+
204+
// Usage
205+
await mediator.InvokeAsync(new UpdateEntity<Order>(order));
206+
await mediator.InvokeAsync(new UpdateRelation<User, Role>(user, role));
207+
```
208+
209+
Guidelines:
210+
211+
- The handler class, not the method, must be generic (generic handler methods are not currently supported).
212+
- The message type must use the handler's generic parameters (e.g., `UpdateEntity<T>` in `EntityHandler<T>`).
213+
- Open generic handlers participate in normal invocation rules: exactly one match required for `Invoke / InvokeAsync`; multiple open generics for the same message generic definition will cause an error when invoking (publish supports multiple).
214+
215+
Performance Notes:
216+
217+
- First invocation of a new closed generic combination incurs a small reflection cost; subsequent calls are cached.
218+
- Static middleware resolution still applies and middleware can itself be generic.
219+
220+
If you need a custom behavior per entity type later, you can still add a concrete handler; the more specific (closed) handler will coexist.
221+
176222
## Multiple Handlers in One Class
177223

178224
A single class can handle multiple message types:

src/Foundatio.Mediator.Abstractions/Mediator.cs

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,32 @@ private PublishAsyncDelegate[] GetAllApplicableHandlers(object message)
194194
[DebuggerStepThrough]
195195
private IEnumerable<HandlerRegistration> GetHandlersForType(Type type)
196196
{
197-
return _serviceProvider.GetKeyedServices<HandlerRegistration>(MessageTypeKey.Get(type)).ToArray();
197+
var list = _serviceProvider.GetKeyedServices<HandlerRegistration>(MessageTypeKey.Get(type)).ToList();
198+
if (list.Count > 0)
199+
return list;
200+
201+
// attempt open generic resolution
202+
if (type.IsGenericType)
203+
{
204+
var genericDefinition = type.GetGenericTypeDefinition();
205+
var registration = _openGenericClosedCache.GetOrAdd(type, t =>
206+
{
207+
var descriptors = _serviceProvider.GetServices<OpenGenericHandlerDescriptor>();
208+
foreach (var descriptor in descriptors)
209+
{
210+
if (descriptor.MessageTypeGenericDefinition == genericDefinition)
211+
{
212+
return ConstructClosedRegistration(t, descriptor);
213+
}
214+
}
215+
return null;
216+
});
217+
218+
if (registration != null)
219+
return new[] { registration };
220+
}
221+
222+
return list;
198223
}
199224

200225
private static readonly ConcurrentDictionary<Type, object> _middlewareCache = new();
@@ -229,5 +254,43 @@ public static T GetOrCreateMiddleware<T>(IServiceProvider serviceProvider) where
229254
private static readonly ConcurrentDictionary<(Type MessageType, Type ResponseType), InvokeResponseDelegate> _invokeWithResponseCache = new();
230255

231256
private static readonly ConcurrentDictionary<Type, PublishAsyncDelegate[]> _publishCache = new();
257+
private static readonly ConcurrentDictionary<Type, HandlerRegistration?> _openGenericClosedCache = new();
258+
259+
private HandlerRegistration? ConstructClosedRegistration(Type closedMessageType, OpenGenericHandlerDescriptor descriptor)
260+
{
261+
try
262+
{
263+
var typeArgs = closedMessageType.GetGenericArguments();
264+
var wrapperClosed = descriptor.WrapperGenericTypeDefinition.MakeGenericType(typeArgs);
265+
var asyncMethod = wrapperClosed.GetMethod("UntypedHandleAsync", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static);
266+
if (asyncMethod == null)
267+
return null;
268+
269+
HandleAsyncDelegate asyncDelegate = (IMediator mediator, object message, CancellationToken ct, Type? returnType) =>
270+
{
271+
var taskObj = asyncMethod.Invoke(null, new object?[] { mediator, message, ct, returnType });
272+
return taskObj is ValueTask<object?> vt ? vt : (ValueTask<object?>)taskObj!;
273+
};
274+
275+
HandleDelegate? syncDelegate = null;
276+
if (!descriptor.IsAsync)
277+
{
278+
var syncMethod = wrapperClosed.GetMethod("UntypedHandle", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static);
279+
if (syncMethod != null)
280+
{
281+
syncDelegate = (IMediator mediator, object message, CancellationToken ct, Type? returnType) =>
282+
{
283+
return syncMethod.Invoke(null, new object?[] { mediator, message, ct, returnType });
284+
};
285+
}
286+
}
287+
288+
return new HandlerRegistration(MessageTypeKey.Get(closedMessageType), wrapperClosed.FullName ?? wrapperClosed.Name, asyncDelegate, syncDelegate, descriptor.IsAsync);
289+
}
290+
catch
291+
{
292+
return null;
293+
}
294+
}
232295

233296
}

src/Foundatio.Mediator.Abstractions/MediatorExtensions.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,16 @@ private static bool IsAssemblyMarkedWithFoundatioHandlerModule(Assembly assembly
6363
}
6464
}
6565

66+
public static class MediatorServiceCollectionExtensions
67+
{
68+
public static IServiceCollection AddHandler(this IServiceCollection services, HandlerRegistration registration)
69+
{
70+
services.AddKeyedSingleton(registration.MessageTypeName, registration);
71+
services.AddSingleton(registration);
72+
return services;
73+
}
74+
}
75+
6676
public class MediatorConfigurationBuilder
6777
{
6878
private readonly MediatorConfiguration _configuration = new MediatorConfiguration();
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
namespace Foundatio.Mediator;
2+
3+
/// <summary>
4+
/// Descriptor for an open generic handler method. Used to construct concrete <see cref="HandlerRegistration"/> instances at runtime
5+
/// when a closed generic message type is invoked or published.
6+
/// </summary>
7+
public sealed class OpenGenericHandlerDescriptor
8+
{
9+
public OpenGenericHandlerDescriptor(Type messageTypeGenericDefinition, Type wrapperGenericTypeDefinition, bool isAsync)
10+
{
11+
MessageTypeGenericDefinition = messageTypeGenericDefinition ?? throw new ArgumentNullException(nameof(messageTypeGenericDefinition));
12+
WrapperGenericTypeDefinition = wrapperGenericTypeDefinition ?? throw new ArgumentNullException(nameof(wrapperGenericTypeDefinition));
13+
IsAsync = isAsync;
14+
}
15+
16+
/// <summary>
17+
/// The open generic (generic type definition) of the message type (e.g. typeof(UpdateEntity&lt;&gt;)).
18+
/// </summary>
19+
public Type MessageTypeGenericDefinition { get; }
20+
21+
/// <summary>
22+
/// The open generic (generic type definition) of the generated handler wrapper static class.
23+
/// </summary>
24+
public Type WrapperGenericTypeDefinition { get; }
25+
26+
/// <summary>
27+
/// Indicates if the handler method should be treated as async.
28+
/// </summary>
29+
public bool IsAsync { get; }
30+
}

src/Foundatio.Mediator/DIRegistrationGenerator.cs

Lines changed: 34 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -58,39 +58,50 @@ public static void Execute(SourceProductionContext context, List<HandlerInfo> ha
5858
source.AppendLine($"services.{lifetimeMethod}<{handler.FullName}>();");
5959
}
6060

61-
source.AppendLine($"AddMediatorHandler(services, new HandlerRegistration(");
62-
source.AppendLine($" MessageTypeKey.Get(typeof({handler.MessageType.FullName})),");
63-
source.AppendLine($" \"{handlerClassName}\",");
64-
65-
if (handler.IsAsync)
61+
if (handler.IsGenericHandlerClass)
6662
{
67-
source.AppendLine($" {handlerClassName}.UntypedHandleAsync,");
68-
source.AppendLine($" null,");
63+
// open generic registration
64+
if (handler.MessageGenericTypeDefinitionFullName != null && handler.GenericArity > 0)
65+
{
66+
// Build unbound generic typeof expressions
67+
var wrapperTypeOf = handler.GenericArity switch
68+
{
69+
1 => $"typeof({handlerClassName}<>)",
70+
2 => $"typeof({handlerClassName}<,>)",
71+
3 => $"typeof({handlerClassName}<,,>)",
72+
4 => $"typeof({handlerClassName}<,,,>)",
73+
_ => $"typeof({handlerClassName}<>)" // fallback
74+
};
75+
var msgTypeOf = $"typeof({handler.MessageGenericTypeDefinitionFullName})";
76+
source.AppendLine($"// Open generic handler registration for {handler.MessageGenericTypeDefinitionFullName}");
77+
source.AppendLine($"services.AddSingleton(new OpenGenericHandlerDescriptor({msgTypeOf}, {wrapperTypeOf}, {handler.IsAsync.ToString().ToLower()}));");
78+
}
6979
}
7080
else
7181
{
72-
source.AppendLine($" (mediator, message, cancellationToken, responseType) => new ValueTask<object?>({handlerClassName}.UntypedHandle(mediator, message, cancellationToken, responseType)),");
73-
source.AppendLine($" {handlerClassName}.UntypedHandle,");
82+
source.AppendLine($"services.AddHandler(new HandlerRegistration(");
83+
source.AppendLine($" MessageTypeKey.Get(typeof({handler.MessageType.FullName})),");
84+
source.AppendLine($" \"{handlerClassName}\",");
85+
86+
if (handler.IsAsync)
87+
{
88+
source.AppendLine($" {handlerClassName}.UntypedHandleAsync,");
89+
source.AppendLine($" null,");
90+
}
91+
else
92+
{
93+
source.AppendLine($" (mediator, message, cancellationToken, responseType) => new ValueTask<object?>({handlerClassName}.UntypedHandle(mediator, message, cancellationToken, responseType)),");
94+
source.AppendLine($" {handlerClassName}.UntypedHandle,");
95+
}
96+
97+
source.AppendLine($" {handler.IsAsync.ToString().ToLower()}));");
98+
source.AppendLine();
7499
}
75-
76-
source.AppendLine($" {handler.IsAsync.ToString().ToLower()}));");
77-
78-
source.AppendLine();
79100
}
80101

81102
source.DecrementIndent();
82103
source.AppendLine("}");
83104

84-
source.AppendLine()
85-
.AppendLines($$"""
86-
[DebuggerStepThrough]
87-
private static void AddMediatorHandler(IServiceCollection services, HandlerRegistration registration)
88-
{
89-
services.AddKeyedSingleton(registration.MessageTypeName, registration);
90-
services.AddSingleton(registration);
91-
}
92-
""");
93-
94105
source.DecrementIndent();
95106
source.AppendLine("}");
96107

src/Foundatio.Mediator/HandlerAnalyzer.cs

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,7 @@ public static List<HandlerInfo> GetHandlers(GeneratorSyntaxContext context)
6969
var semanticModel = context.SemanticModel;
7070

7171
if (semanticModel.GetDeclaredSymbol(classDeclaration) is not { } classSymbol
72-
|| classSymbol.HasIgnoreAttribute(context.SemanticModel.Compilation)
73-
|| classSymbol.IsGenericType)
72+
|| classSymbol.HasIgnoreAttribute(context.SemanticModel.Compilation))
7473
return [];
7574

7675
// Exclude generated handler classes in Foundatio.Mediator namespace with names ending in "_Handler"
@@ -101,7 +100,7 @@ public static List<HandlerInfo> GetHandlers(GeneratorSyntaxContext context)
101100
continue;
102101

103102
if (handlerMethod.IsGenericMethod)
104-
continue;
103+
continue; // do not support generic handler methods, only generic classes
105104

106105
var messageParameter = handlerMethod.Parameters[0];
107106
var messageType = messageParameter.Type;
@@ -120,6 +119,18 @@ public static List<HandlerInfo> GetHandlers(GeneratorSyntaxContext context)
120119
});
121120
}
122121

122+
string? messageGenericDefinition = null;
123+
int messageGenericArity = 0;
124+
if (messageType is INamedTypeSymbol namedMsg && namedMsg.IsGenericType)
125+
{
126+
messageGenericDefinition = namedMsg.ConstructUnboundGenericType().ToDisplayString();
127+
messageGenericArity = namedMsg.TypeArguments.Length;
128+
}
129+
130+
var genericParamNames = classSymbol.IsGenericType
131+
? classSymbol.TypeParameters.Select(tp => tp.Name).ToArray()
132+
: Array.Empty<string>();
133+
123134
handlers.Add(new HandlerInfo
124135
{
125136
Identifier = classSymbol.Name.ToIdentifier(),
@@ -128,6 +139,11 @@ public static List<HandlerInfo> GetHandlers(GeneratorSyntaxContext context)
128139
MessageType = TypeSymbolInfo.From(messageType, context.SemanticModel.Compilation),
129140
ReturnType = TypeSymbolInfo.From(handlerMethod.ReturnType, context.SemanticModel.Compilation),
130141
IsStatic = handlerMethod.IsStatic,
142+
IsGenericHandlerClass = classSymbol.IsGenericType,
143+
GenericArity = classSymbol.IsGenericType ? classSymbol.TypeParameters.Length : 0,
144+
GenericTypeParameters = new(genericParamNames),
145+
MessageGenericTypeDefinitionFullName = messageGenericDefinition,
146+
MessageGenericArity = messageGenericArity,
131147
Parameters = new(parameterInfos.ToArray()),
132148
CallSites = [],
133149
Middleware = [],

src/Foundatio.Mediator/HandlerGenerator.cs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,15 @@ namespace Foundatio.Mediator;
6565
source.AppendLine();
6666
source.AddGeneratedCodeAttribute();
6767
source.AppendLine("[ExcludeFromCodeCoverage]");
68-
source.AppendLine($"internal static class {wrapperClassName}");
68+
if (handler.IsGenericHandlerClass && handler.GenericArity > 0 && handler.GenericTypeParameters.Length == handler.GenericArity)
69+
{
70+
var genericParams = string.Join(", ", handler.GenericTypeParameters);
71+
source.AppendLine($"internal static class {wrapperClassName}<{genericParams}>");
72+
}
73+
else
74+
{
75+
source.AppendLine($"internal static class {wrapperClassName}");
76+
}
6977
source.AppendLine("{");
7078

7179
source.IncrementIndent();

src/Foundatio.Mediator/Models/HandlerInfo.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ internal readonly record struct HandlerInfo
1515
public EquatableArray<ParameterInfo> Parameters { get; init; }
1616
public EquatableArray<CallSiteInfo> CallSites { get; init; }
1717
public EquatableArray<MiddlewareInfo> Middleware { get; init; }
18+
public bool IsGenericHandlerClass { get; init; }
19+
public int GenericArity { get; init; }
20+
public EquatableArray<string> GenericTypeParameters { get; init; }
21+
public string? MessageGenericTypeDefinitionFullName { get; init; }
22+
public int MessageGenericArity { get; init; }
1823
}
1924

2025
internal readonly record struct ParameterInfo
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
using System.Threading;
2+
using System.Threading.Tasks;
3+
using Microsoft.Extensions.DependencyInjection;
4+
using Xunit;
5+
6+
namespace Foundatio.Mediator.Tests.OpenGeneric;
7+
8+
public record UpdateEntity<T>(T Entity) : ICommand;
9+
public record UpdateEntityPair<T1, T2>(T1 First, T2 Second) : ICommand;
10+
11+
public class EntityHandler<T>
12+
{
13+
public Task HandleAsync(UpdateEntity<T> command, CancellationToken cancellationToken)
14+
{
15+
// no-op
16+
return Task.CompletedTask;
17+
}
18+
}
19+
20+
public class EntityPairHandler<T1, T2>
21+
{
22+
public Task HandleAsync(UpdateEntityPair<T1, T2> command, CancellationToken cancellationToken)
23+
{
24+
return Task.CompletedTask;
25+
}
26+
}
27+
28+
public class OpenGenericHandlerTests
29+
{
30+
[Fact]
31+
public async Task CanInvokeSingleGenericParameterHandler()
32+
{
33+
var services = new ServiceCollection();
34+
services.AddLogging();
35+
services.AddMediator();
36+
var provider = services.BuildServiceProvider();
37+
var mediator = provider.GetRequiredService<IMediator>();
38+
39+
await mediator.InvokeAsync(new UpdateEntity<int>(5));
40+
}
41+
42+
[Fact]
43+
public async Task CanInvokeTwoGenericParameterHandler()
44+
{
45+
var services = new ServiceCollection();
46+
services.AddLogging();
47+
services.AddMediator();
48+
var provider = services.BuildServiceProvider();
49+
var mediator = provider.GetRequiredService<IMediator>();
50+
51+
await mediator.InvokeAsync(new UpdateEntityPair<int, string>(5, "test"));
52+
}
53+
}

0 commit comments

Comments
 (0)