DSoftStudio Mediator

← Back to Documentation

ADR-0001: Architecture Overview

Status

Released in v1.0.0

Overview

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.


1. Package Architecture

Decision

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

Rationale

Consequences


2. Compile-Time Dispatch via Source Generators

Decision

Use Roslyn incremental source generators to discover handlers, emit dispatch tables, and intercept Send()/Publish()/CreateStream() call sites at compile time.

Generators

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)

Rationale

Consequences


3. Static-Generic Dispatch Tables

Decision

Use static generic classes (RequestDispatch<TRequest, TResponse>, NotificationDispatch<TNotification>, StreamDispatch<TRequest, TResponse>) as write-once dispatch tables.

How it works

Rationale

Consequences


4. Zero-Allocation Pipeline via Interface Dispatch

Decision

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.

How it works

Allocation profile

Rationale

Consequences


5. ThreadStatic Caching Strategy

Decision

Use [ThreadStatic] fields to cache handler and pipeline chain resolutions per thread.

Cache types

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)

Guard mechanism

Rationale

Consequences


6. Notification Dispatch by Exact Type

Decision

Notification dispatch is by exact compile-time type, not by inheritance hierarchy.

How it works

Rationale

Consequences


7. AOT and Trimming Compatibility

Decision

The library is fully compatible with .NET Native AOT publishing and IL trimming.

Implementation

Amended 2026-09-16. That second line was not accurate, then or since. DSoftStudio.Mediator.Abstractions targets netstandard2.0, where both properties are ignored by the SDK, so the package every consumer references shipped with no trim marker while its own tags advertised native-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.

  • EnableTrimAnalyzer is active at build time.
  • Hot path uses no reflection, no 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.
  • Deleted legacy NotificationDispatcher, NotificationHandlerWrapper, NotificationHandlerWrapperImpl<T> (reflection-based).
  • IServiceProviderAccessor moved from Abstractions (public) to core (internal).

Rationale

Consequences


8. Automatic Handler Lifetime

Decision

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.

Rationale

Consequences


9. Pipeline Lifetime Determination

Decision

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.

Rationale

Consequences


10. Registration Order Contract

Decision

All service registrations must happen before the corresponding Precompile* call.

Required order

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

Rationale

Consequence

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.

Consequences


11. Sync Fast-Path Optimization

Decision

All dispatch methods check IsCompletedSuccessfully before entering the async state machine.

How it works

Rationale

Consequences


12. Open-Generic Pipeline Behavior Support

Decision

Open-generic registrations (typeof(IPipelineBehavior<,>), typeof(IRequestPreProcessor<>), typeof(IRequestPostProcessor<,>), typeof(IRequestExceptionHandler<,>)) are detected at pipeline precompilation time via IsGenericTypeDefinition.

Rationale

Consequences


13. CQRS Support

Decision

Provide ICommand<TResponse>, IQuery<TResponse>, ICommandHandler, and IQueryHandler as semantic aliases for IRequest<TResponse> and IRequestHandler<TRequest, TResponse>.

Rationale

Consequences


14. Pluggable Notification Strategies

Decision

Notification dispatch strategy is configurable via INotificationPublisher.

Built-in strategies

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.

Custom strategies

Users can implement INotificationPublisher for fire-and-forget, batched, prioritized, etc.

Rationale

Consequences


15. Multi-Project Support

Decision

Reference the source generator package only in the host project. Feature modules reference only the abstractions package.

Requirements

Rationale

Consequences


16. Performance Benchmarks (.NET 10, Isolated Runs)

Send (No Behaviors)

Method Latency Alloc Ratio
DirectCall 6.996 ns 72 B 1.00×
DSoft_Send 7.245 ns 72 B 1.04×

Send (Behaviors)

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×

Cross-Library Comparison (All Libraries, Isolated Runs)

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

Allocation Comparison

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

Realistic Pipeline (Validation → Logging → Metrics → async DB)

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×

17. Code Quality & CI

Decision

Maintain strict code quality via SonarCloud, Roslyn analyzers, and performance regression tests.

Measures

Consequences


Document History

Date Version Changes
— Draft Initial ADR covering all architectural decisions
2026-03-11 v1.0.0 Released with core mediator package