The hot path for Send() resolves a transient PipelineChainHandler from DI:
Mediator.Send<TRequest, TResponse>()
│
▼
serviceProvider.GetRequiredService<PipelineChainHandler<TRequest, TResponse>>()
│
▼
[no behaviors] → handler.Handle() ← fast path: direct call
[N behaviors] → PipelineChainHandler (index++) ← zero-alloc: interface dispatch, no closures
The runtime-typed Send(object) path uses a separate dispatch table:
SenderObjectExtensions.Send(ISender, object)
│
▼
RequestObjectDispatch.Dispatch(Type, object, IServiceProvider, CancellationToken)
│
▼
generated switch (request) — one type-pattern case per known request type
│
▼
that case enters the same pipeline as Send<TRequest, TResponse>()
│
▼
(type unknown at compile time → FrozenDictionary<Type, DispatchDelegate> fallback)
Service resolution goes directly through IServiceProvider — the standard DI container call that every mediator must make. This avoids additional container abstractions or service locators.
Note: Backward-compatible overloads (
Send<TResponse>(IRequest<TResponse>),Publish(object), andCreateStream) use a one-time reflection +ConcurrentDictionarycache on first call per type. Subsequent calls hit the cache with zero reflection. TheSend(object)overload uses a precompiledFrozenDictionary<Type, DispatchDelegate>— no reflection at any point. Prefer the strongly-typed overloads for maximum performance.
RegisterMediatorHandlers() — registers handlers with automatic lifetime selection: Singleton for stateless handlers (no constructor parameters), Transient for handlers with DI dependencies. This eliminates per-call allocation for stateless handlers while preserving correct DI semantics for handlers that inject services.PrecompilePipelines() — registers PipelineChainHandler<TRequest, TResponse> for every request type that has pipeline components (behaviors, pre/post processors, exception handlers). The chain’s lifetime is the lowest lifetime of everything it wraps — the handler and its components: Singleton only when the handler and every component are Singleton, Scoped when any is Scoped, Transient when any is Transient. The handler is included because the chain’s constructor consumes it: a Singleton chain wrapping a Transient/Scoped handler would capture that handler (and its scoped dependencies, e.g. an injected IMediator) for the application lifetime — the classic captive-dependency error. Also freezes RequestObjectDispatch — the FrozenDictionary<Type, DispatchDelegate> used by Send(object) for runtime-typed dispatch.PrecompileNotifications() — populates NotificationDispatch<T>.Handlers static arrays with factory delegates for each notification type.PrecompileStreams() — populates StreamDispatch<TRequest, TResponse>.Handler static factory delegates for each stream type.PipelineChainHandler is resolved — at construction it pre-links the behaviors into a nested chain of single-link adapters, each holding the next step. Dispatch is then a straight sequence of interface calls: no closures, no delegates, and no per-call index state[ exception-handler guard: pre-processors (registration order) → behaviors → handler ] → post-processors (registration order). The guard covers the pre-processor stage, the behaviors and the handler; a throw from a post-processor is deliberately not covered, because a response already exists by then and substituting another would contradict the post-processors that already ranThis design ensures correct lifetime semantics: Singleton handlers are shared across all calls (safe for stateless handlers), Transient handlers get new instances per call (safe for handlers with DI dependencies), and Scoped handlers are shared within the HTTP request scope. Users can always override the auto-detected lifetime by re-registering after RegisterMediatorHandlers() — the last registration wins.
GetTypes(), no attribute reflection.PipelineChainHandler through PipelineChainCache, which reuses it per provider when the chain’s lifetime permits and falls back to resolving it from DI when it does not. The handler, behaviors, pre/post processors, and exception handlers are injected by the container.Send(object)) matches the request against a generated type switch and enters the same chain as the typed call; only a type the compilation never saw reaches the FrozenDictionary fallback keyed on the runtime Type, casts object → TRequest, and enters the same pipeline as Send<TRequest, TResponse>(). No reflection, AOT-safe.Func<IServiceProvider, INotificationHandler<T>>[] array — no GetServices<T>() enumeration per publish. Registering a custom INotificationPublisher opts out of this path and resolves the handlers through IEnumerable<INotificationHandler<T>> on every publish.StreamDispatch<TRequest, TResponse>.Handler.The result is that every Send(), Send(object), Publish(), and CreateStream() call at runtime uses precompiled dispatch tables with zero discovery overhead.