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.AddMediator registers a per-container lifetime snapshot, and each cache consults it once per (thread, provider). A static flag is one per closed generic pair and therefore shared by every container in the process, so the first container to register a Scoped or Singleton chain used to declare that pair cacheable for containers that had registered it Transient.await cause a single cache miss (no correctness issue).[ThreadStatic] cannot be written by another thread, so nothing could release it: the slot was emptied only when that same thread next dispatched that same type against a different provider, which for a rarely-served request type or a pool thread that moves on is never. The caches now hold a DispatchCacheSlot<T>, and the scope’s disposal empties it via DispatchCacheReleaser from whichever thread disposes the scope.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.Amended 2026-09-16. That second line was not accurate, then or since.
DSoftStudio.Mediator.Abstractionstargetsnetstandard2.0, where both properties are ignored by the SDK, so the package every consumer references shipped with no trim marker while its own tags advertisednative-aot. It now carries[AssemblyMetadata("IsTrimmable", "True")]directly — the same marker the properties would have emitted. The decision above stands and is now verified rather than declared: see Native AOT, including the two cases that still need a step from the application.
EnableTrimAnalyzeris active at build time.- Hot path uses no reflection, no
MakeGenericType, noExpression.Compile, no dynamic method generation.Publish(object)overload usesNotificationObjectDispatch— a compile-time generatedFrozenDictionary<Type, DispatchDelegate>dispatch table populated by the source generator. NoMakeGenericTypeat runtime.- Deleted legacy
NotificationDispatcher,NotificationHandlerWrapper,NotificationHandlerWrapperImpl<T>(reflection-based).IServiceProviderAccessormoved from Abstractions (public) to core (internal).
A handler’s lifetime is derived from its constructor dependencies rather than fixed by hand:
| Dependencies | Handler lifetime |
|---|---|
| none (stateless) | Singleton |
| all Singleton | Singleton |
| any Scoped, none Transient | Scoped |
| any Transient, or any not registered | Transient |
Each dependency’s lifetime is read from its registered ServiceDescriptor — no reflection, so this is
AOT- and trim-safe. A CLOSED dependency type falls back to its OPEN-generic registration, exactly as
the container resolves: ILogger<THandler> is served by the ILogger<> that AddLogging() registers
and therefore reads as Singleton. Without that fallback it read as unregistered, and since injecting
a logger is the commonest thing a handler does, almost every real handler stayed Transient — and, via
§9, dragged its whole pipeline chain onto the uncached path with it.
The services the container provides itself have no descriptor either, and are answered directly:
IServiceProvider as Scoped, because it is the scope that asked and lives exactly as long, and
IServiceScopeFactory as Singleton, because one rooted factory serves every scope. Both lookups
above run first, so a caller who registers one of these explicitly still decides.
RegisterMediatorHandlers() and before PrecompilePipelines().
The optimizer upgrades only a descriptor it still owns: any user re-registration appends a newer
descriptor and is left alone, including an identical re-Add that forces Transient.PrecompilePipelines().PrecompilePipelines() determines each PipelineChainHandler lifetime from the lifetime of everything the chain’s constructor consumes — the registered components (behaviors, pre/post processors, exception handlers), the handler, and any registered IMediatorDispatchObserver:
| Registered | Chain Lifetime |
|---|---|
| All Singleton | Singleton |
| Any component, the handler, or an observer, Transient | Transient |
| Anything else | Scoped |
PrecompileStreams() folds the stream handler and the stream behaviors the same way.
A Transient chain is never cached: it is re-resolved and re-linked on every dispatch. That is the correct answer when something it wraps really is Transient, and the reason a component’s default lifetime matters — one Transient component puts the whole request on that path.
AddDispatchObserver defaults to Singleton because an observer is meant to be a stateless adapter, but
a caller who states Transient gets it: the observer is a chain dependency like any other.
IMediator used to publish domain events) — for the whole application lifetime, producing the “Cannot consume scoped service from singleton” captive-dependency error at BuildServiceProvider. Likewise, instrumentation behaviors (e.g. the profiler’s EventSourceProfilingBehavior) are registered Scoped, not Singleton, so adding them never promotes a chain to Singleton and captures a non-singleton handler. A component’s lifetime is therefore a deliberate choice rather than a detail: MediatorBuilder defaults one to Scoped, the longest lifetime that is safe without inspecting its constructor, because a single Transient component takes the whole chain to Transient and off the cached path.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() → Scans for IStreamPipelineBehavior; builds static factory delegates per IStreamRequest type
IServiceCollection snapshot at that point in time.The scan is a freeze point. It decides, per request/response pair, whether a pipeline chain is built at all, and fixes its lifetime; a second call returns immediately rather than re-opening the decision, so a late registration cannot half-rebuild a pipeline that other code is already dispatching through. Sealing is what allows dispatch to be a static-field read.
The cost is that registration order is part of the contract. A pair with no component registered at
that moment gets no chain, so components added afterwards never run. Where a chain does exist, a
component added later still runs, but under the lifetime the snapshot chose: a Transient component
added late can be constructed once and shared.
Neither case is silent any more. DSOFT010 reports the same-method case at compile time. It
resolves the collection through a fluent chain, through new MediatorBuilder(services), and through
the builder handed to an AddMediator(configure) lambda — registration styles it was previously blind
to rather than merely quiet about — and it pairs a component only with the scan that governs ITS kind,
so a stream behavior is never reported against PrecompilePipelines().
The generated ValidateMediatorHandlers() reports the rest at startup — registrations that cross
methods and assemblies, which no analyzer can see because there is no compilation-wide ordering to
consult, and the components a second AddMediator(configure) registered after the chains were already
frozen.
Precompile* calls are not folded into the scan — and are not silent: DSOFT010 flags the same-method case at compile time, and ValidateMediatorHandlers() names the rest at startup.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; each completes before the next starts. If one throws, the rest are skipped. The order between handlers is the generated table’s — sorted by handler type name — not registration order. |
ParallelNotificationPublisher |
Queues every handler to the thread pool and awaits them together with Task.WhenAll, so handlers run concurrently whether or not they suspend. Awaiting rethrows the first faulted handler’s exception, not an AggregateException. |
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× |
Historical. The figures below are the ones this decision was taken on and are kept as written. They no longer describe the library: dispatch has since got faster, and the cold-start row measured something it should not have — a warm dispatch behind a fresh provider, which is why it reads in microseconds where a real startup reads in milliseconds. For current numbers see benchmarks.
| 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 |