Released in v1.0.0
DSoftStudio.Mediator is an ultra-low-latency mediator for .NET with compile-time dispatch, zero-allocation pipelines, and a familiar MediatR-style API. This ADR documents all architectural decisions, design patterns, performance strategies, and trade-offs made during the development of the library.
Split the library into three internal assemblies shipped as a single NuGet package:
| Assembly | Target | Role |
|---|---|---|
DSoftStudio.Mediator.Abstractions |
.NET Standard 2.0 + .NET 8 | Public contracts (IRequest, INotification, IPipelineBehavior, etc.) |
DSoftStudio.Mediator |
.NET 8 | Runtime (mediator, caches, dispatch tables, publishers) |
DSoftStudio.Mediator.Generators |
.NET Standard 2.0 | Roslyn 4.12 incremental source generators |
FrozenDictionary, ArgumentNullException.ThrowIfNull, primary constructors).DSoftStudio.Mediator NuGet package — Abstractions and Generators are not published separately.Use Roslyn incremental source generators to discover handlers, emit dispatch tables,
and intercept Send()/Publish()/CreateStream() call sites at compile time.
| Generator | Purpose |
|---|---|
SendInterceptorGenerator |
Intercepts ISender.Send<TRequest, TResponse>() call sites and replaces them with direct pipeline invocation via C# interceptors |
PublishInterceptorGenerator |
Intercepts IPublisher.Publish<TNotification>() call sites |
StreamInterceptorGenerator |
Intercepts ISender.CreateStream<TRequest, TResponse>() call sites |
MediatorPipelineGenerator |
Discovers all IRequestHandler<,> implementations, generates MediatorRegistry for pipeline precompilation |
NotificationGenerator |
Discovers all INotificationHandler<> implementations, generates NotificationRegistry |
StreamGenerator |
Discovers all IStreamRequestHandler<,> implementations, generates StreamRegistry |
DependencyInjectionGenerator |
Generates RegisterMediatorHandlers() extension method with auto-lifetime detection |
MediatorExtensionsGenerator |
Generates typed extension methods for Send, Publish, CreateStream |
HandlerDiscovery |
Shared helper for extracting handler type information from symbols |
ReferencedAssemblyScanner |
Scans referenced assemblies for handler registrations (cross-project support) |
InterceptorHelpers |
Shared helpers (ImplementsInterface, ResolveRequestParameter) |
Mediator.Send() method frame entirely — the JIT sees a direct call to the pipeline.Use static generic classes (RequestDispatch<TRequest, TResponse>, NotificationDispatch<TNotification>,
StreamDispatch<TRequest, TResponse>) as write-once dispatch tables.
Interlocked.CompareExchange enforces write-once semantics.Volatile.Read/Volatile.Write for flags (HasPipelineChain, IsPipelineChainCacheable).PipelineChainHandler<TRequest, TResponse> passes this as the next parameter to each
behavior via IRequestHandler<TRequest, TResponse> interface dispatch (virtual call),
instead of using delegate closures.
this (the chain handler) as next.next.Handle(request, ct) is a virtual call (~0.5 ns) instead of a delegate invocation (~2 ns).Func<> wrapping.StreamPipelineChainHandler).Send() regardless of pipeline depth (0, 1, 3, 5 behaviors).Publish().CreateStream()._active flag) falls back to closure-based chain (correct but allocating).Use [ThreadStatic] fields to cache handler and pipeline chain resolutions per thread.
| Cache | Purpose | Hit cost | Miss cost |
|---|---|---|---|
HandlerCache<TRequest, TResponse> |
Caches IRequestHandler on no-behaviors path |
~1 ns | ~10 ns (DI resolve) |
PipelineChainCache<TRequest, TResponse> |
Caches PipelineChainHandler on behaviors path |
~1 ns | ~10 ns (DI resolve) |
StreamPipelineChainCache<TRequest, TResponse> |
Caches StreamPipelineChainHandler |
~1 ns | ~10 ns (DI resolve) |
StreamHandlerCache<TRequest, TResponse> |
Caches IStreamRequestHandler |
~1 ns | ~10 ns (DI resolve) |
NotificationHandlerCache<TNotification> |
Caches resolved notification handler arrays | ~1 ns | ~10 ns (DI resolve) |
ReferenceEquals(_cachedProvider, serviceProvider) detects scope changes.async/await thread hop → cache miss → one DI lookup on new thread.GetRequiredService call on every Send() for Scoped/Singleton handlers.[ThreadStatic] is lock-free, zero-allocation, and CPU cache friendly.IsPipelineChainCacheable is true (Scoped/Singleton). Transient chains always resolve fresh.await cause a single cache miss (no correctness issue).Notification dispatch is by exact compile-time type, not by inheritance hierarchy.
Publish<TNotification>() dispatches only to handlers registered for the exact TNotification type.The library is fully compatible with .NET Native AOT publishing and IL trimming.
IsAotCompatible and IsTrimmable enabled.EnableTrimAnalyzer is active at build time.MakeGenericType, no Expression.Compile, no dynamic method generation.Publish(object) overload uses NotificationObjectDispatch — a compile-time generated FrozenDictionary<Type, DispatchDelegate> dispatch table populated by the source generator. No MakeGenericType at runtime.NotificationDispatcher, NotificationHandlerWrapper, NotificationHandlerWrapperImpl<T> (reflection-based).IServiceProviderAccessor moved from Abstractions (public) to core (internal).Stateless handlers (no constructor parameters) are automatically registered as Singleton. Handlers with DI dependencies are registered as Transient.
RegisterMediatorHandlers() and before PrecompilePipelines().PrecompilePipelines().PrecompilePipelines() determines each PipelineChainHandler lifetime based on the registered components:
| Components | Chain Lifetime |
|---|---|
| All Singleton | Singleton |
| Any Scoped | Scoped |
| Any Transient | Transient |
PrecompilePipelines() are not picked up.All service registrations must happen before the corresponding Precompile* call.
AddMediator() → Core mediator services
RegisterMediatorHandlers() → Source-generated handler registrations
[Register behaviors, processors, exception handlers, lifetime overrides]
PrecompilePipelines() → Scans for IPipelineBehavior, Pre/Post processors, exception handlers
PrecompileNotifications() → Builds static dispatch arrays for each INotification type
PrecompileStreams() → Builds static factory delegates for each IStreamRequest type
IServiceCollection snapshot at that point in time.Precompile* calls are silently ignored.All dispatch methods check IsCompletedSuccessfully before entering the async state machine.
ValueTask/Task is returned directly.SequentialNotificationPublisher and NotificationCachedDispatcher use index-based for loops with IsCompletedSuccessfully short-circuit.Open-generic registrations (typeof(IPipelineBehavior<,>), typeof(IRequestPreProcessor<>),
typeof(IRequestPostProcessor<,>), typeof(IRequestExceptionHandler<,>)) are detected
at pipeline precompilation time via IsGenericTypeDefinition.
Provide ICommand<TResponse>, IQuery<TResponse>, ICommandHandler, and IQueryHandler
as semantic aliases for IRequest<TResponse> and IRequestHandler<TRequest, TResponse>.
ICommand, IQuery) allow runtime behavior targeting (e.g., transactions for commands, caching for queries).is ICommand / is IQuery for targeting.Notification dispatch strategy is configurable via INotificationPublisher.
| Strategy | Behavior |
|---|---|
| Sequential (default) | Handlers run one at a time in registration order. If one throws, the rest are skipped. |
ParallelNotificationPublisher |
All handlers start concurrently via Task.WhenAll. If any throw, AggregateException is raised after all complete. |
Users can implement INotificationPublisher for fire-and-forget, batched, prioritized, etc.
Reference the source generator package only in the host project. Feature modules reference only the abstractions package.
InternalsVisibleTo or same-assembly placement.IMediator registration in the DI container.AddMediator() called only once, in the host project.IMediator implementations and conflicting dispatch tables.ReferencedAssemblyScanner discovers handlers in referenced assemblies.| Method | Latency | Alloc | Ratio |
|---|---|---|---|
| DirectCall | 6.996 ns | 72 B | 1.00× |
| DSoft_Send | 7.245 ns | 72 B | 1.04× |
| Method | Latency | Alloc | Ratio |
|---|---|---|---|
| DirectCall | 6.745 ns | 72 B | 1.00× |
| DSoft_Send_3Behaviors | 13.820 ns | 72 B | 2.05× |
| DSoft_Send_5Behaviors | 15.635 ns | 72 B | 2.32× |
| Operation | DSoft | Mediator SG | DispatchR | MediatR |
|---|---|---|---|---|
| Send() | 7.2 ns | 12.2 ns | 33.4 ns | 41.3 ns |
| Send() 5 beh | 15.6 ns | 36.8 ns | 54.1 ns | 153.1 ns |
| Publish() | 4.5 ns | 10.6 ns | 35.7 ns | 123.4 ns |
| CreateStream() | 45.5 ns | 44.7 ns | 68.1 ns | 122.9 ns |
| Cold Start | 1.62 µs | 9.91 µs | 1.88 µs | 3.24 µs |
| Operation | DSoft | Mediator SG | DispatchR | MediatR |
|---|---|---|---|---|
| Send() | 72 B | 72 B | 72 B | 272 B |
| Send() 5 beh | 72 B | 72 B | 72 B | 1,088 B |
| Publish() | 0 B | 0 B | 0 B | 768 B |
| CreateStream() | 232 B | 232 B | 232 B | 624 B |
| Library | Pipeline | Latency | Memory | Overhead |
|---|---|---|---|---|
| DSoft | Direct call | 674 ns | 271 B | — |
| Mediator pipeline | 667 ns | 255 B | 0.99× | |
| DispatchR | Direct call | 661 ns | 271 B | — |
| Mediator pipeline | 667 ns | 255 B | 1.01× | |
| Mediator (SG) | Direct call | 679 ns | 270 B | — |
| Mediator pipeline | 718 ns | 397 B | 1.06× | |
| MediatR | Direct call | 714 ns | 270 B | — |
| Mediator pipeline | 857 ns | 1,032 B | 1.20× |
Maintain strict code quality via SonarCloud, Roslyn analyzers, and performance regression tests.
Unit operators (<, >, <=, >=) for CA1036 compliance.| Date | Version | Changes |
|---|---|---|
| — | Draft | Initial ADR covering all architectural decisions |
| 2026-03-11 | v1.0.0 | Released with core mediator package |