Skip to content

Commit fbdf18f

Browse files
committed
Add option to disable conventional handler discover
1 parent 3c05680 commit fbdf18f

10 files changed

Lines changed: 280 additions & 4 deletions

File tree

AGENTS.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,10 @@ Defined in `src/Foundatio.Mediator/Foundatio.Mediator.props`:
299299

300300
<!-- Disable OpenTelemetry tracing (default: false) -->
301301
<MediatorDisableOpenTelemetry>true|false</MediatorDisableOpenTelemetry>
302+
303+
<!-- Disable conventional handler discovery (default: false) -->
304+
<!-- When true, only handlers with IHandler interface or [Handler] attribute are discovered -->
305+
<MediatorDisableConventionalDiscovery>true|false</MediatorDisableConventionalDiscovery>
302306
```
303307

304308
Requirements for interceptors:

docs/guide/configuration.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ These properties control the source generator at compile time and affect code ge
1818

1919
<!-- Disable OpenTelemetry integration (default: false) -->
2020
<MediatorDisableOpenTelemetry>true</MediatorDisableOpenTelemetry>
21+
22+
<!-- Disable conventional handler discovery (default: false) -->
23+
<MediatorDisableConventionalDiscovery>true</MediatorDisableConventionalDiscovery>
2124
</PropertyGroup>
2225
```
2326

@@ -44,6 +47,13 @@ These properties control the source generator at compile time and affect code ge
4447
- **Effect:** When `true`, disables OpenTelemetry integration code generation
4548
- **Use Case:** Reduce generated code size when telemetry is not needed
4649

50+
**`MediatorDisableConventionalDiscovery`**
51+
52+
- **Values:** `true`, `false`
53+
- **Default:** `false`
54+
- **Effect:** When `true`, disables convention-based handler discovery (class names ending with `Handler` or `Consumer`). Only handlers that implement `IHandler` interface or have the `[Handler]` attribute will be discovered.
55+
- **Use Case:** Explicit control over which classes are treated as handlers, avoiding accidental handler discovery
56+
4757
### Example .csproj Configuration
4858

4959
```xml
@@ -56,6 +66,7 @@ These properties control the source generator at compile time and affect code ge
5666
<MediatorHandlerLifetime>Scoped</MediatorHandlerLifetime>
5767
<MediatorDisableInterceptors>false</MediatorDisableInterceptors>
5868
<MediatorDisableOpenTelemetry>true</MediatorDisableOpenTelemetry>
69+
<MediatorDisableConventionalDiscovery>false</MediatorDisableConventionalDiscovery>
5970
</PropertyGroup>
6071

6172
<PackageReference Include="Foundatio.Mediator" Version="1.0.0" />

docs/guide/handler-conventions.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
Foundatio Mediator uses simple naming conventions to automatically discover handlers at compile time. This eliminates the need for interfaces, base classes, or manual registration while providing excellent compile-time validation.
44

5+
Alternatively, you can mark handlers explicitly using the `IHandler` marker interface or the `[Handler]` attribute. See [Explicit Handler Declaration](#explicit-handler-declaration) for details.
6+
57
## Class Naming Conventions
68

79
Handler classes must end with one of these suffixes:
@@ -462,6 +464,47 @@ public class OrderHandler
462464
}
463465
```
464466

467+
## Explicit Handler Declaration
468+
469+
In addition to naming conventions, handlers can be explicitly declared using:
470+
471+
1. **Interface** - Classes implementing the `IHandler` marker interface
472+
2. **Attribute** - Classes or methods decorated with `[Handler]`
473+
474+
```csharp
475+
// Discovered via IHandler interface
476+
public class OrderProcessor : IHandler
477+
{
478+
public Order Handle(CreateOrder command) { }
479+
}
480+
481+
// Discovered via [Handler] attribute on class
482+
[Handler]
483+
public class EmailService
484+
{
485+
public void Handle(SendEmail command) { }
486+
}
487+
488+
// Discovered via [Handler] attribute on method
489+
public class NotificationService
490+
{
491+
[Handler]
492+
public void Process(SendNotification command) { }
493+
}
494+
```
495+
496+
### Disabling Conventional Discovery
497+
498+
If you prefer explicit handler declaration over naming conventions, you can disable conventional discovery entirely:
499+
500+
```xml
501+
<PropertyGroup>
502+
<MediatorDisableConventionalDiscovery>true</MediatorDisableConventionalDiscovery>
503+
</PropertyGroup>
504+
```
505+
506+
When disabled, only handlers that implement `IHandler` or have the `[Handler]` attribute are discovered. Classes with names ending in `Handler` or `Consumer` will not be automatically discovered.
507+
465508
## Next Steps
466509

467510
- [Result Types](./result-types) - Robust error handling patterns

src/Foundatio.Mediator/Foundatio.Mediator.props

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,6 @@
44
<CompilerVisibleProperty Include="MediatorDisableInterceptors" />
55
<CompilerVisibleProperty Include="MediatorHandlerLifetime" />
66
<CompilerVisibleProperty Include="MediatorDisableOpenTelemetry" />
7+
<CompilerVisibleProperty Include="MediatorDisableConventionalDiscovery" />
78
</ItemGroup>
89
</Project>

src/Foundatio.Mediator/HandlerAnalyzer.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,9 @@ public static List<HandlerInfo> GetHandlers(GeneratorSyntaxContext context)
8383
bool implementsMarker = classSymbol.AllInterfaces.Any(i => i.ToDisplayString() == "Foundatio.Mediator.IHandler");
8484
bool hasClassHandlerAttribute = classSymbol.GetAttributes().Any(attr => attr.AttributeClass?.ToDisplayString() == WellKnownTypes.HandlerAttribute);
8585

86+
// Explicit discovery: IHandler interface or [Handler] attribute on class
87+
bool isExplicitlyDeclared = implementsMarker || hasClassHandlerAttribute;
88+
8689
bool treatAsHandlerClass = nameMatches || implementsMarker || hasClassHandlerAttribute;
8790

8891
var handlerMethods = GetMethods(classSymbol)
@@ -158,6 +161,10 @@ public static List<HandlerInfo> GetHandlers(GeneratorSyntaxContext context)
158161
}
159162
}
160163

164+
// Check if this specific method has [Handler] attribute (also counts as explicit declaration)
165+
bool hasMethodHandlerAttribute = handlerMethod.GetAttributes().Any(attr => attr.AttributeClass?.ToDisplayString() == WellKnownTypes.HandlerAttribute);
166+
bool methodIsExplicitlyDeclared = isExplicitlyDeclared || hasMethodHandlerAttribute;
167+
161168
handlers.Add(new HandlerInfo
162169
{
163170
Identifier = classSymbol.Name.ToIdentifier(),
@@ -177,6 +184,7 @@ public static List<HandlerInfo> GetHandlers(GeneratorSyntaxContext context)
177184
Parameters = new(parameterInfos.ToArray()),
178185
CallSites = [],
179186
Middleware = [],
187+
IsExplicitlyDeclared = methodIsExplicitlyDeclared,
180188
});
181189
}
182190

src/Foundatio.Mediator/MediatorGenerator.cs

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,11 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
3232
&& openTelemetrySwitch.Equals("true", StringComparison.OrdinalIgnoreCase);
3333
var openTelemetryEnabled = !openTelemetryDisabled;
3434

35-
return new GeneratorConfiguration(interceptorsEnabled, handlerLifetime, openTelemetryEnabled);
35+
// Read conventional discovery disabled property. Default: false (conventional discovery enabled by default)
36+
var conventionalDiscoveryDisabled = options.GlobalOptions.TryGetValue($"build_property.{Constants.DisableConventionalDiscoveryPropertyName}", out string? conventionalDiscoverySwitch)
37+
&& conventionalDiscoverySwitch.Equals("true", StringComparison.OrdinalIgnoreCase);
38+
39+
return new GeneratorConfiguration(interceptorsEnabled, handlerLifetime, openTelemetryEnabled, conventionalDiscoveryDisabled);
3640
})
3741
.WithTrackingName(TrackingNames.Settings);
3842

@@ -79,6 +83,11 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
7983

8084
private static void Execute(ImmutableArray<HandlerInfo> handlers, ImmutableArray<MiddlewareInfo> middleware, ImmutableArray<CallSiteInfo> callSites, GeneratorConfiguration configuration, Compilation compilation, SourceProductionContext context)
8185
{
86+
// Filter out conventionally-discovered handlers when conventional discovery is disabled
87+
var filteredHandlers = configuration.ConventionalDiscoveryDisabled
88+
? handlers.Where(h => h.IsExplicitlyDeclared).ToImmutableArray()
89+
: handlers;
90+
8291
// Scan referenced assemblies for cross-assembly middleware
8392
var metadataMiddleware = MetadataMiddlewareScanner.ScanReferencedAssemblies(compilation);
8493

@@ -99,7 +108,7 @@ private static void Execute(ImmutableArray<HandlerInfo> handlers, ImmutableArray
99108
var crossAssemblyHandlerMessageTypes = new HashSet<string>(crossAssemblyHandlers.Select(h => h.MessageType.FullName));
100109

101110
var handlersWithInfo = new List<HandlerInfo>();
102-
foreach (var handler in handlers)
111+
foreach (var handler in filteredHandlers)
103112
{
104113
callSitesByMessage.TryGetValue(handler.MessageType, out var handlerCallSites);
105114
var applicableMiddleware = GetApplicableMiddlewares(allMiddleware.ToImmutableArray(), handler, compilation);
@@ -113,7 +122,7 @@ private static void Execute(ImmutableArray<HandlerInfo> handlers, ImmutableArray
113122
continue;
114123

115124
// Check if this message type has a handler in a referenced assembly but NOT in the current assembly
116-
bool hasLocalHandler = handlers.Any(h => h.MessageType.FullName == callSite.MessageType.FullName);
125+
bool hasLocalHandler = filteredHandlers.Any(h => h.MessageType.FullName == callSite.MessageType.FullName);
117126
bool hasCrossAssemblyHandler = crossAssemblyHandlerMessageTypes.Contains(callSite.MessageType.FullName);
118127

119128
if (!hasLocalHandler && hasCrossAssemblyHandler)

src/Foundatio.Mediator/Models/GeneratorConfiguration.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,5 @@ namespace Foundatio.Mediator.Models;
33
internal record GeneratorConfiguration(
44
bool InterceptorsEnabled,
55
string HandlerLifetime,
6-
bool OpenTelemetryEnabled);
6+
bool OpenTelemetryEnabled,
7+
bool ConventionalDiscoveryDisabled);

src/Foundatio.Mediator/Models/HandlerInfo.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@ internal readonly record struct HandlerInfo
2323
public string? MessageGenericTypeDefinitionFullName { get; init; }
2424
public int MessageGenericArity { get; init; }
2525
public EquatableArray<string> GenericConstraints { get; init; }
26+
/// <summary>
27+
/// Whether this handler was discovered via explicit means (IHandler interface or [Handler] attribute)
28+
/// rather than conventional discovery (class name ending with Handler/Consumer).
29+
/// </summary>
30+
public bool IsExplicitlyDeclared { get; init; }
2631
}
2732

2833
internal readonly record struct ParameterInfo

src/Foundatio.Mediator/Utility/Constants.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,5 @@ internal static class Constants
55
public const string DisableInterceptorsPropertyName = "MediatorDisableInterceptors";
66
public const string HandlerLifetimePropertyName = "MediatorHandlerLifetime";
77
public const string DisableOpenTelemetryPropertyName = "MediatorDisableOpenTelemetry";
8+
public const string DisableConventionalDiscoveryPropertyName = "MediatorDisableConventionalDiscovery";
89
}
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
namespace Foundatio.Mediator.Tests;
2+
3+
public class ConventionalDiscoveryToggleTests : GeneratorTestBase
4+
{
5+
[Fact]
6+
public void ConventionalDiscovery_Enabled_DiscoversHandlerByNamingConvention()
7+
{
8+
var src = """
9+
using System.Threading;
10+
using System.Threading.Tasks;
11+
using Foundatio.Mediator;
12+
13+
public record MyMessage;
14+
15+
// Discovered by naming convention (ends with Handler)
16+
public class MyMessageHandler
17+
{
18+
public void Handle(MyMessage m) { }
19+
}
20+
""";
21+
22+
var opts = CreateOptions(("build_property.MediatorDisableConventionalDiscovery", "false"));
23+
var (_, _, trees) = RunGenerator(src, [new MediatorGenerator()], opts);
24+
25+
// Handler should be discovered and a wrapper generated
26+
Assert.Contains(trees, t => t.HintName.Contains("MyMessage_Handler.g.cs"));
27+
}
28+
29+
[Fact]
30+
public void ConventionalDiscovery_Disabled_DoesNotDiscoverHandlerByNamingConvention()
31+
{
32+
var src = """
33+
using System.Threading;
34+
using System.Threading.Tasks;
35+
using Foundatio.Mediator;
36+
37+
public record MyMessage;
38+
39+
// Should NOT be discovered when conventional discovery is disabled
40+
public class MyMessageHandler
41+
{
42+
public void Handle(MyMessage m) { }
43+
}
44+
""";
45+
46+
var opts = CreateOptions(("build_property.MediatorDisableConventionalDiscovery", "true"));
47+
var (_, _, trees) = RunGenerator(src, [new MediatorGenerator()], opts);
48+
49+
// Handler should NOT be discovered
50+
Assert.DoesNotContain(trees, t => t.HintName.Contains("MyMessage_Handler.g.cs"));
51+
}
52+
53+
[Fact]
54+
public void ConventionalDiscovery_Disabled_DiscoversHandlerWithIHandlerInterface()
55+
{
56+
var src = """
57+
using System.Threading;
58+
using System.Threading.Tasks;
59+
using Foundatio.Mediator;
60+
61+
public record MyMessage;
62+
63+
// Discovered via IHandler interface
64+
public class MessageProcessor : IHandler
65+
{
66+
public void Handle(MyMessage m) { }
67+
}
68+
""";
69+
70+
var opts = CreateOptions(("build_property.MediatorDisableConventionalDiscovery", "true"));
71+
var (_, _, trees) = RunGenerator(src, [new MediatorGenerator()], opts);
72+
73+
// Handler should be discovered via IHandler interface
74+
Assert.Contains(trees, t => t.HintName.Contains("MyMessage_Handler.g.cs"));
75+
}
76+
77+
[Fact]
78+
public void ConventionalDiscovery_Disabled_DiscoversHandlerWithClassAttribute()
79+
{
80+
var src = """
81+
using System.Threading;
82+
using System.Threading.Tasks;
83+
using Foundatio.Mediator;
84+
85+
public record MyMessage;
86+
87+
// Discovered via [Handler] attribute on class
88+
[Handler]
89+
public class MessageProcessor
90+
{
91+
public void Handle(MyMessage m) { }
92+
}
93+
""";
94+
95+
var opts = CreateOptions(("build_property.MediatorDisableConventionalDiscovery", "true"));
96+
var (_, _, trees) = RunGenerator(src, [new MediatorGenerator()], opts);
97+
98+
// Handler should be discovered via [Handler] attribute
99+
Assert.Contains(trees, t => t.HintName.Contains("MyMessage_Handler.g.cs"));
100+
}
101+
102+
[Fact]
103+
public void ConventionalDiscovery_Disabled_DiscoversHandlerWithMethodAttribute()
104+
{
105+
var src = """
106+
using System.Threading;
107+
using System.Threading.Tasks;
108+
using Foundatio.Mediator;
109+
110+
public record MyMessage;
111+
112+
// Discovered via [Handler] attribute on method
113+
public class MessageProcessor
114+
{
115+
[Handler]
116+
public void Process(MyMessage m) { }
117+
}
118+
""";
119+
120+
var opts = CreateOptions(("build_property.MediatorDisableConventionalDiscovery", "true"));
121+
var (_, _, trees) = RunGenerator(src, [new MediatorGenerator()], opts);
122+
123+
// Handler should be discovered via [Handler] attribute on method
124+
Assert.Contains(trees, t => t.HintName.Contains("MyMessage_Handler.g.cs"));
125+
}
126+
127+
[Fact]
128+
public void ConventionalDiscovery_Disabled_DefaultsToEnabledWhenPropertyNotSet()
129+
{
130+
var src = """
131+
using System.Threading;
132+
using System.Threading.Tasks;
133+
using Foundatio.Mediator;
134+
135+
public record MyMessage;
136+
137+
// Discovered by naming convention when property not set (default behavior)
138+
public class MyMessageHandler
139+
{
140+
public void Handle(MyMessage m) { }
141+
}
142+
""";
143+
144+
// No options set - defaults to conventional discovery enabled
145+
var (_, _, trees) = RunGenerator(src, [new MediatorGenerator()], optionsProvider: null);
146+
147+
// Handler should be discovered
148+
Assert.Contains(trees, t => t.HintName.Contains("MyMessage_Handler.g.cs"));
149+
}
150+
151+
[Fact]
152+
public void ConventionalDiscovery_Disabled_MixedHandlers_OnlyExplicitHandlersGenerated()
153+
{
154+
var src = """
155+
using System.Threading;
156+
using System.Threading.Tasks;
157+
using Foundatio.Mediator;
158+
159+
public record Message1;
160+
public record Message2;
161+
public record Message3;
162+
163+
// Should NOT be discovered (naming convention only)
164+
public class Message1Handler
165+
{
166+
public void Handle(Message1 m) { }
167+
}
168+
169+
// SHOULD be discovered (IHandler interface)
170+
public class Processor2 : IHandler
171+
{
172+
public void Handle(Message2 m) { }
173+
}
174+
175+
// SHOULD be discovered ([Handler] attribute)
176+
[Handler]
177+
public class Processor3
178+
{
179+
public void Handle(Message3 m) { }
180+
}
181+
""";
182+
183+
var opts = CreateOptions(("build_property.MediatorDisableConventionalDiscovery", "true"));
184+
var (_, _, trees) = RunGenerator(src, [new MediatorGenerator()], opts);
185+
186+
// Message1Handler should NOT be discovered (conventional only)
187+
Assert.DoesNotContain(trees, t => t.HintName.Contains("Message1_Handler.g.cs"));
188+
189+
// Processor2 and Processor3 SHOULD be discovered (explicit)
190+
Assert.Contains(trees, t => t.HintName.Contains("Message2_Handler.g.cs"));
191+
Assert.Contains(trees, t => t.HintName.Contains("Message3_Handler.g.cs"));
192+
}
193+
}

0 commit comments

Comments
 (0)