The source generators emit C# interceptors that replace ISender.Send, IPublisher.Publish, and IMediator.CreateStream call sites at compile time with direct pipeline invocations — eliminating virtual dispatch entirely.
The generated code adapts to the build’s OptimizationLevel:
| Build | Generated pattern | Overhead vs direct call | Mock-safe |
|---|---|---|---|
| Release | Branchless castclass IServiceProviderAccessor |
~0.6 ns | ❌ throws InvalidCastException on mocks |
| Debug | is not IServiceProviderAccessor + virtual fallback |
~3 ns | ✅ graceful fallback to virtual dispatch |
Why? A single isinst + branch instruction prevents the JIT’s Guarded Devirtualization (GDV) from fully devirtualizing the interface call, adding ~3 ns on every invocation. The branchless castclass pattern in Release lets GDV optimize the dispatch to a method-table pointer comparison + direct field load — effectively zero overhead.
In Debug builds (where tests typically run), the generated interceptors detect test doubles (Moq, NSubstitute, etc.) that don’t implement IServiceProviderAccessor and fall back to virtual dispatch, so mocked ISender/IMediator instances work as expected.
Tip: If you run tests in Release mode and mock
ISenderin a project that references the generator, the interceptor will throwInvalidCastException. Either remove the generator reference from pure unit-test projects or use the strongly-typed mock setup. See the Suppressing interceptors section below.
Typed extensions (generated by MediatorExtensionsGenerator — e.g. Send(Ping), CreateStream(PingStream)) always use the defensive isinst + virtual-dispatch fallback, regardless of build configuration. They never use the branchless castclass pattern.
This is deliberate: typed extensions are the fallback dispatch path when interceptors are suppressed (via DSoftMediatorSuppressInterceptors). If they also used castclass, running dotnet test -c Release on a project that mocks ISender would throw InvalidCastException even with interceptors disabled. The ~1–2 ns overhead of isinst is negligible compared to the safety guarantee.
| Code path | Release | Debug | Mock-safe |
|---|---|---|---|
| Interceptors | Branchless castclass |
isinst + fallback |
Release: ❌ / Debug: ✅ |
| Typed extensions | isinst + fallback |
isinst + fallback |
✅ always |
Both paths share the same dispatch body via InterceptorHelpers.AppendSendDispatchBody() — the isRelease parameter controls which pattern is emitted.
DSoftMediatorSuppressInterceptors)If your test project references DSoftStudio.Mediator directly (not just Abstractions) and mocks ISender/IPublisher/IMediator, you can disable interceptor generation entirely with an MSBuild property:
<PropertyGroup>
<DSoftMediatorSuppressInterceptors>true</DSoftMediatorSuppressInterceptors>
</PropertyGroup>
When set to true:
.props auto-import stops adding the interceptor namespace, so the C# compiler will not recognize [InterceptsLocation] attributes.Send, Publish, Stream) read the property via AnalyzerConfigOptionsProvider and skip code emission entirely.IMediator / ISender interfaces — fully mock-safe at any optimization level.The generator package includes an incremental analyzer that scans ReferencedAssemblyNames for well-known mocking libraries:
Moq · NSubstitute · FakeItEasy · Telerik.JustMock · RhinoMocks · NimbleMocks
If any of these are referenced and DSoftMediatorSuppressInterceptors is not true, the build emits:
warning DSOFT004: This project references mocking library 'Moq' and has interceptors enabled.
In Release builds, interceptors use a branchless cast that throws InvalidCastException on mock
objects. Either reference only DSoftStudio.Mediator.Abstractions in test projects, or set
<DSoftMediatorSuppressInterceptors>true</DSoftMediatorSuppressInterceptors> in this project.
Recommended patterns:
| Test project setup | Interceptors | Mock-safe | Action needed |
|---|---|---|---|
References only Abstractions |
Not generated | ✅ | None (preferred) |
References Mediator + SuppressInterceptors=true |
Suppressed | ✅ | Add MSBuild property |
References Mediator + Debug build |
Generated (isinst fallback) | ✅ | None |
References Mediator + Release build |
Generated (castclass) | ❌ | Suppress or restructure |
The recommended multi-project architecture separates the generator host from your domain/application layer:
Host (Web API / Worker)
├── References: DSoftStudio.Mediator (includes source generators)
├── References: Host.Application
│
Host.Application (Domain / Application layer)
├── References: DSoftStudio.Mediator.Abstractions (interfaces only)
├── Contains: IRequest<T> commands, IRequestHandler<,> implementations
│
Host.Tests (Unit tests)
├── References: DSoftStudio.Mediator.Abstractions (interfaces only)
├── Mocks: ISender / IPublisher via Moq, NSubstitute, etc.
Why this works:
Host — they discover handlers from Host.Application via the ReferencedAssemblyScanner Phase 2 (type-based fallback), which walks all exported types in assemblies that reference Abstractions.sender.Send(new RunTaskCommand()) compiles to sender.Send<RunTaskCommand, int>(request, ct) via pure virtual dispatch. No IServiceProviderAccessor cast.Abstractions, no interceptors are generated. Mocking ISender works with any test double framework.// Host.Application — only references Abstractions
public sealed record RunTaskCommand(string Name) : IRequest<int>;
public sealed class RunTaskCommandHandler : IRequestHandler<RunTaskCommand, int>
{
public ValueTask<int> Handle(RunTaskCommand request, CancellationToken ct) => new(42);
}
// Host.Tests — mocks ISender freely, no generator interference
var sender = new Mock<ISender>();
sender.Setup(s => s.Send<RunTaskCommand, int>(It.IsAny<RunTaskCommand>(), default))
.ReturnsAsync(42);
Note: The
Send(object)runtime dispatch overload requires the realMediatorinstance (it needsIServiceProviderAccessor). If you callsender.Send((object)request)on a mock, you’ll get a helpfulInvalidOperationExceptionguiding you to use the typed overload instead.
Notification handlers are dispatched by exact compile-time type, not by runtime inheritance hierarchy. This is a deliberate design decision:
// Only handlers registered for OrderPlaced are invoked —
// handlers for INotification or a base class are NOT invoked.
await mediator.Publish(new OrderPlaced(orderId));
This avoids the MediatR duplicate-handler bug where a single notification can trigger base-class handlers unexpectedly, and enables the source generator to emit a static dispatch table at build time with zero reflection.
NotificationPublisherFlag bypassMost applications never register a custom INotificationPublisher. Without optimization, every generated Publish interceptor would call GetService<INotificationPublisher>() on every invocation — even when the result is always null. That DI probe alone costs ~3–4 ns per call.
NotificationPublisherFlag is a write-once global Volatile boolean that eliminates this probe:
false. The generated interceptor reads HasCustomPublisher (~0.1 ns) and short-circuits directly to NotificationCachedDispatcher.DispatchSequential — zero DI lookup.INotificationPublisher is registered (e.g. ParallelNotificationPublisher, OpenTelemetry’s InstrumentedNotificationPublisher), the Mediator constructor calls MarkRegistered() once. All subsequent interceptors see the flag and take the GetService path as before.| Scenario | Before optimization | After optimization |
|---|---|---|
| No custom publisher (default) | GetService returns null (~3–4 ns) |
Volatile.Read (~0.1 ns) |
| Custom publisher registered | GetService returns instance (~3–4 ns) |
Volatile.Read + GetService (~3–4 ns) |
Net effect: ~4 ns saved per Publish call in the default (no custom publisher) path — bringing the Publish interceptor from ~2.2× to ~1.1× overhead vs direct dispatch.