Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions docs/mediator/adr/0001-architecture-overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,9 +231,9 @@ Handlers with DI dependencies are registered as Transient.
## 9. Pipeline Lifetime Determination

### Decision
`PrecompilePipelines()` determines each `PipelineChainHandler` lifetime based on the registered components:
`PrecompilePipelines()` determines each `PipelineChainHandler` lifetime from the lifetime of **everything the chain wraps — the handler AND the registered components** (behaviors, pre/post processors, exception handlers):

| Components | Chain Lifetime |
| Lowest lifetime among handler + components | Chain Lifetime |
|------------|---------------|
| All Singleton | Singleton |
| Any Scoped | Scoped |
Expand All @@ -242,6 +242,7 @@ Handlers with DI dependencies are registered as Transient.
### Rationale
- Ensures correct DI semantics without manual configuration.
- Singleton chains are cached per-thread for maximum performance.
- 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` 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.

### Consequences
- Registrations added after `PrecompilePipelines()` are not picked up.
Expand Down
2 changes: 1 addition & 1 deletion docs/mediator/architecture/dispatch-pipeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ Service resolution goes directly through `IServiceProvider` — the standard DI
## What Is Registered at Startup

- **`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 determined by its components: Singleton when all are Singleton, Scoped when any is Scoped, Transient when any is Transient. Also freezes `RequestObjectDispatch` — the `FrozenDictionary<Type, DispatchDelegate>` used by `Send(object)` for runtime-typed dispatch.
- **`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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

<!-- NuGet -->
<PackageId>DSoftStudio.Mediator.Abstractions</PackageId>
<Version>1.3.0-rc.1</Version>
<Version>1.3.0-rc.2</Version>

<Authors>DSoftStudio</Authors>
<Company>DSoftStudio</Company>
Expand Down
48 changes: 48 additions & 0 deletions src/DSoftStudio.Mediator.Abstractions/HandlerLifetimeAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// Copyright (c) DSoftStudio. All rights reserved.
// Licensed under the MIT License. See LICENSE in the project root for license information.

using System;

namespace DSoftStudio.Mediator.Abstractions
{
/// <summary>
/// The dependency-injection lifetime a mediator handler is registered with. Mirrors the three
/// <c>Microsoft.Extensions.DependencyInjection.ServiceLifetime</c> values without coupling the
/// abstractions package to that dependency.
/// </summary>
public enum HandlerLifetime
{
/// <summary>A new instance per resolution.</summary>
Transient,

/// <summary>One instance per DI scope (e.g. per web request).</summary>
Scoped,

/// <summary>A single shared instance for the whole application lifetime.</summary>
Singleton,
}

/// <summary>
/// Pins the DI lifetime the mediator registers this handler with, overriding the automatic
/// dependency-driven detection.
/// <para>
/// By default the mediator picks the lifetime that matches the handler's constructor dependencies:
/// <see cref="HandlerLifetime.Singleton"/> when every dependency is itself a singleton (cached,
/// zero-allocation per request), <see cref="HandlerLifetime.Scoped"/> when any dependency is scoped
/// (cached per scope), <see cref="HandlerLifetime.Transient"/> otherwise. Apply this attribute when
/// the handler must use a specific lifetime regardless - for example
/// <see cref="HandlerLifetime.Transient"/> when it must be a fresh instance per call because it (or
/// a dependency) carries per-call state.
/// </para>
/// </summary>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
public sealed class HandlerLifetimeAttribute : Attribute
{
/// <summary>Initializes the attribute with the lifetime to register the handler with.</summary>
/// <param name="lifetime">The lifetime to pin.</param>
public HandlerLifetimeAttribute(HandlerLifetime lifetime) => Lifetime = lifetime;

/// <summary>The pinned lifetime.</summary>
public HandlerLifetime Lifetime { get; }
}
}
71 changes: 71 additions & 0 deletions src/DSoftStudio.Mediator.Abstractions/IMediatorDispatchObserver.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
// Copyright (c) DSoftStudio. All rights reserved.
// Licensed under the MIT License. See LICENSE in the project root for license information.

using System;

namespace DSoftStudio.Mediator.Abstractions;

/// <summary>
/// Optional observation port for the request-dispatch boundary (Ports & Adapters).
/// <para>
/// The mediator itself does NOT observe or trace — it merely EXPOSES the dispatch lifecycle so an external
/// adapter (e.g. the OpenTelemetry bridge) can wrap the WHOLE pipeline: pre-processors, behaviors, handler
/// and post-processors. A pipeline behavior cannot do this, because pre-/post-processors run OUTSIDE the
/// behavior chain — so the only place a span can nest every component is the dispatch boundary the mediator
/// owns. This keeps the core tracing-agnostic (no <c>System.Diagnostics.Activity</c> dependency): the core
/// defines the port; the bridge is the adapter.
/// </para>
/// <para>
/// Cost contract: when no observer is registered (the common case) the mediator pays nothing — the dispatch
/// stays on its fast path. When one IS registered, the mediator first reads <see cref="IsActive"/> (cheap,
/// allocation-free) and only calls <see cref="BeginDispatch{TRequest,TResponse}"/> when something is
/// actually observing — so a registered-but-idle adapter (bridge present, no exporter attached) adds no
/// hot-path cost beyond a single property read.
/// </para>
/// </summary>
public interface IMediatorDispatchObserver
{
/// <summary>
/// Cheap, allocation-free check for whether anything is observing right now (e.g. an active tracing
/// listener/exporter). The mediator skips wrapping the dispatch entirely when this is <see langword="false"/>.
/// </summary>
bool IsActive { get; }

/// <summary>
/// Called at the START of a request dispatch, BEFORE pre-processors run. The returned scope is disposed
/// when the ENTIRE dispatch completes (after post-processors), so the adapter can open a span that nests
/// every pipeline component. Returns <see langword="null"/> to observe nothing for this dispatch (e.g. the
/// adapter filtered this request type out, or sampling dropped it).
/// </summary>
/// <param name="request">
/// The request being dispatched — lets the adapter enrich the observation with request-specific data
/// (e.g. custom span tags) without the mediator knowing what enrichment means.
/// </param>
/// <param name="handler">
/// The terminal handler for this dispatch — lets the adapter record the concrete handler type without the
/// mediator resolving anything (the adapter inspects it, e.g. via <see cref="IPipelineHandlerTypeAccessor"/>).
/// </param>
IMediatorDispatchScope? BeginDispatch<TRequest, TResponse>(TRequest request, IRequestHandler<TRequest, TResponse> handler)
where TRequest : IRequest<TResponse>;
}

/// <summary>
/// The lifetime scope of a single observed dispatch, returned by
/// <see cref="IMediatorDispatchObserver.BeginDispatch{TRequest,TResponse}"/>.
/// <para>
/// <see cref="IDisposable.Dispose"/> is called when the dispatch completes (success OR failure), ending the
/// observation. The mediator reports an unhandled failure via <see cref="OnError"/> BEFORE disposing, so the
/// adapter can mark the observation (e.g. set the span status to error and record the exception). The notion
/// of "the dispatch failed with this exception" is generic dispatch-outcome data — not a tracing concept —
/// so reporting it keeps the core tracing-agnostic.
/// </para>
/// </summary>
public interface IMediatorDispatchScope : IDisposable
{
/// <summary>
/// Reports that the dispatch failed with an exception that propagated past every pipeline component
/// (including exception handlers). Called at most once, just before <see cref="IDisposable.Dispose"/>.
/// Not called when the dispatch completes successfully.
/// </summary>
void OnError(Exception exception);
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

<!-- NuGet -->
<PackageId>DSoftStudio.Mediator.FluentValidation</PackageId>
<Version>1.0.9-rc.1</Version>
<Version>1.0.9-rc.2</Version>

<Authors>DSoftStudio</Authors>
<Company>DSoftStudio</Company>
Expand Down
Loading
Loading