Table of Contents

Trellis.Mediator — API Reference

Package: Trellis.Mediator Namespace: Trellis.Mediator Purpose: Provides Trellis result-aware Mediator pipeline behaviors plus DI helpers for validation, authorization, tracing, logging, and optional resource authorization.

See also: trellis-start-here.md — recipes using this package.

Use this file when

  • You are wiring Trellis result-aware behaviors into the Mediator pipeline.
  • You need exact command/query interfaces, validation behavior, static authorization, resource authorization, tracing/logging, or EF unit-of-work behavior.
  • You need to know which DI helper registers a behavior versus which helper registers resource loaders.

Patterns Index

Goal Canonical API / pattern See
Add the standard Trellis mediator behaviors services.AddTrellisBehaviors() ServiceCollectionExtensions
Receive the checked actor and loaded resource/leaf without provider/accessor constructor dependencies Derive from the appropriate Actor...Handler and override protected Handle(message, actor[, resource/leaf], token) Actor-aware handler bases
Configure a Native AOT pipeline for value-type Result responses Literal typeof(...) source-generator configuration, not open DI behaviors Native AOT registration
Add validation to a message Implement IValidate and register IMessageValidator<TMessage> or FluentValidation adapter ValidationBehavior<TMessage,TResponse>
Add static permission authorization Message implements IAuthorize; register AddTrellisBehaviors() AuthorizationBehavior<TMessage,TResponse>
Add resource authorization with assembly scanning services.AddResourceAuthorization(typeof(SomeType).Assembly) ServiceCollectionExtensions
Add resource authorization explicitly services.AddResourceAuthorization<TMessage,TResource,TResponse>() plus loader registration ResourceAuthorizationBehavior<TMessage,TResource,TResponse>
Authorize an identified resource using a shared loader without scanning services.AddSharedResourceAuthorization<TMessage,TResource,TId,TResponse>() plus the shared-loader implementation ServiceCollectionExtensions
Hide existence of sensitive resources from unauthorized callers services.AddResourceAuthorization(o => o.HideExistence<TResource>()) ResourceAuthorizationOptions
Bridge IIdentifyResource<TResource,TId> to a shared loader services.AddSharedResourceLoader<TMessage,TResource,TId>() ServiceCollectionExtensions
Register EF unit-of-work behavior services.AddTrellisUnitOfWork<TContext>() Canonical pipeline order
Keep commits inside the pipeline Repositories stage changes; TransactionalCommandBehavior commits on success Behavioral notes
Dispatch domain events on a successful command (assembly scan) services.AddDomainEventDispatch(typeof(MyHandler).Assembly) DomainEventDispatchServiceCollectionExtensions
Dispatch domain events with explicit (AOT-friendly) handler registration services.AddDomainEventHandler<TEvent, THandler>() DomainEventDispatchServiceCollectionExtensions
Auto-dispatch domain events from every tracked aggregate (regardless of response shape) services.AddTrackedAggregateDomainEventDispatch() TrackedAggregateDomainEventDispatchServiceCollectionExtensions
Implement a domain-event handler Implement IDomainEventHandler<TEvent> IDomainEventHandler
Define an external/integration contract Implement IIntegrationEvent with stable, primitive or nullable transport members IIntegrationEventHandler
Translate a domain event into an integration event A domain-event handler injects IIntegrationEventCollector and calls Add(...) IIntegrationEventCollector
Consume an integration event in-process Implement IIntegrationEventHandler<TEvent> IIntegrationEventHandler
Register integration-event publishing services.AddIntegrationEventDispatch(...) or services.AddIntegrationEventHandler<TEvent, THandler>() IntegrationEventDispatchServiceCollectionExtensions

Common traps

  • Explicit AddResourceAuthorization<TMessage,TResource,TResponse>() registers the behavior and authorized-resource accessor, but not a loader. For messages implementing IIdentifyResource<TResource,TId>, AddSharedResourceAuthorization<TMessage,TResource,TId,TResponse>() also registers the shared-loader bridge. The shared-loader implementation remains application-owned.
  • Loader precedence is application registration, then scanned custom loader, then framework shared-loader adapter. Both AddResourceAuthorization(assemblies) and AddResourceLoaders(assembly) replace an earlier framework adapter when a custom loader is discovered, so typed-first and scan-first composition select the same custom loader. Scanning preserves application-provided implementation, factory, and instance descriptors and leaves keyed registrations untouched.
  • AddTrellisUnitOfWork<TContext>() is order-independent versus AddTrellisBehaviors() and domain-event dispatch helpers; the transaction behavior is rehomed innermost.
  • Handlers should return Trellis Result / Result<T> failures, not throw for expected business outcomes.

Cross-package preflight for pipeline changes

Mediator pipeline work is rarely isolated. Load these companion references before changing registrations, behavior ordering, or handler patterns:

If the change touches... Also read Why
EF-backed command commits or AddTrellisUnitOfWork<TContext>() trellis-api-efcore.md, trellis-api-servicedefaults.md The transaction behavior is registered by EF Core and applied last by the service-defaults builder.
Resource authorization trellis-api-authorization.md, trellis-api-efcore.md when resources load from repositories The interfaces live in Authorization; resource loading often composes with EF repository/result semantics.
FluentValidation in the validation stage trellis-api-mediator-fluentvalidation.md FluentValidation contributes IMessageValidator<TMessage> instances to ValidationBehavior; it does not add another pipeline slot. The adapter lives in the dedicated Trellis.Mediator.FluentValidation package.
ASP endpoints that send commands/queries trellis-api-asp.md Endpoint response mapping and scalar validation happen at the ASP boundary; handlers should stay transport-free.

Types

Actor-aware handler bases

Base Message capability Business parameters
ActorCommandHandler<TCommand,TResponse> ICommand<TResponse>, IAuthorizationMessage command, actor, token
ActorQueryHandler<TQuery,TResponse> IQuery<TResponse>, IAuthorizationMessage query, actor, token
ActorResourceCommandHandler<TCommand,TResource,TResponse> ICommand<TResponse>, IAuthorizeResource<TResource> command, actor, resource, token
ActorResourceQueryHandler<TQuery,TResource,TResponse> IQuery<TResponse>, IAuthorizeResource<TResource> query, actor, resource, token
ActorResourceViaCommandHandler<TCommand,TLeaf,TOwner,TResponse> ICommand<TResponse>, IAuthorizeResourceVia<TOwner> command, actor, leaf, token
ActorResourceViaQueryHandler<TQuery,TLeaf,TOwner,TResponse> IQuery<TResponse>, IAuthorizeResourceVia<TOwner> query, actor, leaf, token

All six abstract bases implement Mediator's existing command/query handler interfaces. Their framework constructors are parameterless; concrete constructors retain business dependencies only. TResponse : IResult, IFailureFactory<TResponse>; resources/leaves are class. There is no extra message-class or owner-class constraint. Resource bases do not require static IAuthorize.

Each exposes exactly one public entry: nonvirtual ValueTask<TResponse> Handle(message, CancellationToken) for Mediator. Override only protected abstract ValueTask<TResponse> Handle(message, Actor actor, [resource/leaf,] CancellationToken). The protected overload is the business hook, not a public invocation API; sealed concrete handlers work with inherited interface dispatch. The normal entry acquires an active matching snapshot only after all declared gates succeed. For multiple direct resource contracts, explicitly register each closed form; the handler can receive any successfully authorized resource type in that dispatch. Via handlers receive the leaf, never owners; the registered leaf/path must match the base's TLeaf. Configuration/invocation faults throw before business logic.

Invoke actor-aware handlers through Mediator in production and in handler tests. Register TestActorProvider from Trellis.Testing, fake resource loaders/repositories, and the normal pipeline; use fake commit/event dependencies when those stages are enabled. Supplying a provider alone does not establish an authorized dispatch. There is no public actor/resource overload that bypasses the pipeline, and calling the two-argument entry without an authorized dispatch throws before business logic. Unit-test aggregates, policies, or application services directly when their logic needs pipeline-free isolation. Existing IAuthorizedResource handlers remain supported, including resource-only business methods that do not need an actor.

Direct resource example

using Mediator;
using Trellis;
using Trellis.Authorization;
using Trellis.Mediator;

public sealed record Order(string Id, string OwnerId);

public sealed record GetOrderQuery(string Id)
    : IQuery<Result<Order>>, IAuthorizeResource<Order>, IIdentifyResource<Order, string>
{
    public string GetResourceId() => Id;
    public IResult Authorize(Actor actor, Order resource) =>
        Result.Ensure(actor.IsOwner(resource.OwnerId), () => new Error.Forbidden("orders.owner"));
}

public sealed class GetOrderHandler : ActorResourceQueryHandler<GetOrderQuery, Order, Result<Order>>
{
    protected override ValueTask<Result<Order>> Handle(
        GetOrderQuery query, Actor actor, Order resource, CancellationToken cancellationToken)
        => new(Result.Ok(resource));
}

The message intentionally omits IAuthorize: resource-only existence hiding remains valid. Register the loader and resource behavior as usual; no extra actor-handler toggle or provider/accessor constructor parameter is needed. A business method that does not need an actor may also keep its ordinary handler plus IAuthorizedResource.

For Cricket's Match -> {HomeTeam, AwayTeam}, use ActorResourceViaCommandHandler<UploadScorecardCommand,Match,Team,Result<Trellis.Unit>>. Its protected Handle receives the actor and match. The message's existing Authorize(actor, teams) still decides home OR away ownership; owners are not new handler parameters. See Recipe 24.

Migration and diagnostics

Replace the corresponding application-owned actor/resource base; rename its business override to protected Handle(message, actor[, resource/leaf], token) and remove constructor/base arguments used only for actor or loaded-resource lookup. Retain repositories, clocks, publishers, and other business dependencies. Normal hosts keep AddMediator followed by AddTrellisBehaviors() and existing loader/resource registrations.

A capability constraint proves a declaration, not that a pipeline ran. The normal Handle diagnoses missing context, pending static/resource gates, mismatched closed message/response types, expired frames, wrong via leaves, and resources belonging to a different dispatch. It never searches ancestors. Each nested/sibling send gets a fresh frame; disposal restores the parent and expires captured orphan frames. Once supplied, references cannot be revoked from application code.

Hand-built authorization pipelines must add AuthorizationContextBehavior before authorization. Omitting it throws; no implicit frame or second provider lookup repairs the configuration. Test handler business outcomes, policy, denial, exposure, and transactions with actual Mediator sends using a test actor provider and fake dependencies. The loaded-reference guarantee does not prove mutation readiness or close ownership TOCTOU windows: projections, no-tracking entities, and stale replicas may require a canonical reload.

AuthorizationContextBehavior<TMessage, TResponse>

public sealed class AuthorizationContextBehavior<TMessage, TResponse>
    : IPipelineBehavior<TMessage, TResponse>
    where TMessage : IAuthorizationMessage, global::Mediator.IMessage
    where TResponse : IResult, IFailureFactory<TResponse>

The parameterless behavior establishes a fresh dispatch frame before authorization and expires it after the inner pipeline unwinds, including failure, exception, and cancellation paths. Handle(TMessage message, MessageHandlerDelegate<TMessage,TResponse> next, CancellationToken cancellationToken) returns ValueTask<TResponse>. It does not resolve an actor eagerly. Authorization stages share one provider resolution in this frame; handler acquisition requires all declared static/resource gates to have succeeded. Nested and sibling dispatches get distinct frames. Captured expired frames never fall back to a parent snapshot.

Standard AddTrellisBehaviors and typed/scanned resource-authorization helpers install the context automatically. A manual pipeline must place it before authorization; missing or mismatched state throws an actionable InvalidOperationException.

AuthorizationBehavior<TMessage, TResponse>

Declaration

public sealed class AuthorizationBehavior<TMessage, TResponse>(IActorProvider actorProvider) : IPipelineBehavior<TMessage, TResponse> where TMessage : IAuthorize, global::Mediator.IMessage where TResponse : IResult, IFailureFactory<TResponse>

Constructors

Signature Description
public AuthorizationBehavior(IActorProvider actorProvider) Builds the static-permission behavior.

Properties

Name Type Description
— — None.

Methods

Signature Returns Description
public async ValueTask<TResponse> Handle(TMessage message, MessageHandlerDelegate<TMessage, TResponse> next, CancellationToken cancellationToken) ValueTask<TResponse> Resolves the current actor via IActorProvider. When the provider returns Maybe<Actor>.None, short-circuits with TResponse.CreateFailure(new Error.AuthenticationRequired { Detail = "Authentication required." }) (HTTP 401, RFC 9110 §15.5.2). When the actor is present but lacks one of RequiredPermissions, short-circuits with TResponse.CreateFailure(new Error.Forbidden("authorization.insufficient.permissions") { Detail = "Insufficient permissions." }) (HTTP 403). The 401 vs 403 distinction is shared with ResourceAuthorizationBehavior and ResourceAuthorizationViaBehavior via the internal ActorResolution.TryResolveAsync / ActorResolution.AuthenticationRequired() helpers; provider-side InvalidOperationException (genuine bugs — no HttpContext, mapping delegate threw, etc.) propagates uncaught and surfaces as Error.Unexpected (HTTP 500) via ExceptionBehavior.

The dispatch context resolves one Actor candidate at the first authorization stage; static and direct/via stages share its reference. Actor-aware bases acquire it only after every declared gate succeeds. The context is dispatch-local, not a request-wide cache.

Ordinary handlers can still use actorProvider.RequireActorAsync(cancellationToken) from Trellis.Authorization only when actor presence and stable provider resolution are guaranteed. It performs another lookup, not retrieval of the snapshot checked by this behavior. For mutable providers, configure a scoped CachingActorProvider before dispatch and inject that same scoped provider into the behavior and handler; authorization alone does not freeze identity or permissions. This accessor does not replace normal Maybe.None to Error.AuthenticationRequired handling, check permissions, or cache independently. Missing actor state at this invariant boundary is a fault; ordinary unauthenticated requests must still take the normal 401 path. See ActorProviderExtensions.

ExceptionBehavior<TMessage, TResponse>

Declaration

public sealed partial class ExceptionBehavior<TMessage, TResponse> : IPipelineBehavior<TMessage, TResponse> where TMessage : global::Mediator.IMessage where TResponse : IResult, IFailureFactory<TResponse>

Constructors

Signature Description
public ExceptionBehavior(ILogger<ExceptionBehavior<TMessage, TResponse>> logger) Builds the exception-to-failure behavior.

Properties

Name Type Description
— — None.

Methods

Signature Returns Description
public async ValueTask<TResponse> Handle(TMessage message, MessageHandlerDelegate<TMessage, TResponse> next, CancellationToken cancellationToken) ValueTask<TResponse> Catches unhandled exceptions except OperationCanceledException, logs them with a per-incident fault id, and returns TResponse.CreateFailure(new Error.Unexpected("unhandled-exception", faultId) { Detail = "An unexpected error occurred while processing the request." }).

IValidate

Declaration

public interface IValidate

Constructors

No public constructors.

Properties

Name Type Description
— — None.

Methods

Signature Returns Description
IResult Validate() IResult Returns success to continue or any failure result to short-circuit the pipeline.

LoggingBehavior<TMessage, TResponse>

Declaration

public sealed partial class LoggingBehavior<TMessage, TResponse> : IPipelineBehavior<TMessage, TResponse> where TMessage : global::Mediator.IMessage where TResponse : IResult

Constructors

Signature Description
public LoggingBehavior(ILogger<LoggingBehavior<TMessage, TResponse>> logger, TrellisMediatorTelemetryOptions? options = null) Builds the logging behavior. options is resolved from DI; under AddTrellisBehaviors() the TrellisMediatorTelemetryOptions singleton is always registered, so this argument is non-null in production. The optional-null fallback exists only for consumers that instantiate the behavior outside of DI (custom test fixtures); when null, the safe-by-default options are used and Error.Detail is redacted. Throws ArgumentNullException when logger is null.

Properties

Name Type Description
— — None.

Methods

Signature Returns Description
public async ValueTask<TResponse> Handle(TMessage message, MessageHandlerDelegate<TMessage, TResponse> next, CancellationToken cancellationToken) ValueTask<TResponse> Logs start (Debug), end with elapsed milliseconds (Debug on success, Information for expected caller/domain failures, Warning for unexpected/dependency/opaque transport failures). Per-call timing is at Debug so production at the default Information minimum stays quiet; raise via "Trellis.Mediator": "Debug" in logging configuration to opt back in. On failure emits the error type name and Error.Code — for example Error.NotFound (error.unspecified) — so a code read from a log matches the one in the response body and on the span; the free-text Error.Detail is included only when TrellisMediatorTelemetryOptions.IncludeErrorDetail is true.

ResourceAuthorizationViaBehavior<TMessage, TLeaf, TOwner, TResponse>

Declaration

public sealed partial class ResourceAuthorizationViaBehavior<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.Interfaces)] TMessage, TLeaf, TOwner, TResponse>
    : IPipelineBehavior<TMessage, TResponse>
    where TMessage : IAuthorizeResourceVia<TOwner>, global::Mediator.IMessage
    where TLeaf : class
    where TResponse : IResult, IFailureFactory<TResponse>

Pipeline behavior implementing indirect (multi-hop) resource authorization. Loads the leaf via the existing IResourceLoader<TMessage, TLeaf> infrastructure (typically the SharedResourceLoaderAdapter bridge for messages also implementing IIdentifyResource<TLeaf, TLeafId>), then walks the pre-resolved ResolvedAuthorizationPath from leaf to owner, and finally invokes the command's IAuthorizeResourceVia<TOwner>.Authorize(actor, IReadOnlyList<TOwner>).

ResourceAuthorizationPathResolver rejects Nullable<T> identifier types on selected hops (such as Guid?), even though null reference-type IDs can express absence. For optional value-type IDs, use a plural terminal hop with zero or one non-nullable ID, a hand-built ResolvedAuthorizationPath, or a custom projection loader.

Constructors

Signature Description
public ResourceAuthorizationViaBehavior(IActorProvider actorProvider, IServiceProvider serviceProvider, ResolvedAuthorizationPathHolder<TMessage, TLeaf, TOwner, TResponse> pathHolder) Uses fresh options (DefaultExposurePolicy = Propagate) and a null logger.
public ResourceAuthorizationViaBehavior(IActorProvider actorProvider, IServiceProvider serviceProvider, ResolvedAuthorizationPathHolder<TMessage, TLeaf, TOwner, TResponse> pathHolder, IOptions<ResourceAuthorizationOptions>? options, ILogger<ResourceAuthorizationViaBehavior<TMessage, TLeaf, TOwner, TResponse>>? logger = null) DI-friendly options-aware overload. options is required but nullable; null uses fresh options (DefaultExposurePolicy = Propagate).
public ResourceAuthorizationViaBehavior(IActorProvider actorProvider, IServiceProvider serviceProvider, ResolvedAuthorizationPath path) Accepts a hand-built path; uses fresh options and a null logger.
public ResourceAuthorizationViaBehavior(IActorProvider actorProvider, IServiceProvider serviceProvider, ResolvedAuthorizationPath path, IOptions<ResourceAuthorizationOptions>? options, ILogger<ResourceAuthorizationViaBehavior<TMessage, TLeaf, TOwner, TResponse>>? logger = null) Options-aware manual overload; validates path message/leaf/owner types. options is required but nullable.

Methods

Signature Returns Description
public async ValueTask<TResponse> Handle(TMessage message, MessageHandlerDelegate<TMessage, TResponse> next, CancellationToken cancellationToken) ValueTask<TResponse> Resolves the actor before loader construction or I/O; absence produces Error.AuthenticationRequired, while provider faults propagate. Loads the leaf, walks the path using deduplicated non-null IDs, and calls message.Authorize(actor, IReadOnlyList<TOwner>). Empty hops deny with Error.Forbidden; intermediate/owner load failures of any kind collapse to Forbidden("resource.authorization-via.load-failed"). Leaf null-success denies with resource.authorization-via.null-payload; hop null-success is a hop load failure. On success publishes the exact leaf through IAuthorizedResource<TMessage,TLeaf> for the handler's dispatch. Exposure policy: keyed on TLeaf, never TOwner. Under HideAsNotFound, resource-stage root NotFound, Gone, Forbidden, and AuthenticationRequired become one fresh public NotFound using the configured public type, request ID, and fixed code/detail. Other leaf errors pass through unchanged; collapsed owner failures become NotFound, including owner Unavailable. Normalization logs the original input kind/code as ExistenceHidden. See ResourceAuthorizationOptions and Recipe 32.

ResolvedAuthorizationPath

Declaration

public sealed class ResolvedAuthorizationPath

Pre-built navigation path from leaf to owner used by ResourceAuthorizationViaBehavior<,,,>. Topology is validated at construction: Hops non-empty, hops[0].FromType == LeafType, terminal hops[N].ToType == OwnerType, adjacent hops chain (hops[i].ToType == hops[i+1].FromType), at most one plural hop and only at the terminal position. The Hops collection is defensively copied.

Constructors

Signature Description
public ResolvedAuthorizationPath(Type messageType, Type leafType, Type ownerType, IReadOnlyList<ResolvedAuthorizationHop> hops) Builds the path; throws ArgumentException on invariant violations.

Properties

Name Type Description
MessageType Type Command/query type this path serves.
LeafType Type Leaf resource type the command identifies.
OwnerType Type Owner resource type authorization is evaluated against.
Hops IReadOnlyList<ResolvedAuthorizationHop> Ordered hops from leaf to owner.

ResolvedAuthorizationHop

Declaration

public sealed class ResolvedAuthorizationHop

Single hop in an indirect authorization chain.

Constructors

Signature Description
public ResolvedAuthorizationHop(Type fromType, Type toType, Type toIdType, Func<object, IReadOnlyList<object>> extractIds, Func<IServiceProvider, object, CancellationToken, Task<HopLoadResult>> loadAsync, bool isPlural) Builds the hop with typed extractor and loader delegates.

Properties

Name Type Description
FromType Type Source resource type.
ToType Type Destination resource type.
ToIdType Type Identifier type for the destination resource.
ExtractIds Func<object, IReadOnlyList<object>> Extracts related-resource IDs from a single source instance.
LoadAsync Func<IServiceProvider, object, CancellationToken, Task<HopLoadResult>> Loads one resource by ID from the request-scoped service provider.
IsPlural bool True when the hop is plural (only the terminal hop may be plural).

HopLoadResult

Declaration

public readonly struct HopLoadResult

Result of loading a single related resource at one ID during a hop walk. Uses an explicit success flag — default(HopLoadResult) is a failure with a sentinel error, so a misconfigured hop loader cannot accidentally produce a "successful" result carrying null.

Methods

Signature Returns Description
public static HopLoadResult Success(object value) HopLoadResult Throws ArgumentNullException on null.
public static HopLoadResult Failure(Error error) HopLoadResult Throws ArgumentNullException on null.

Properties

Name Type Description
Value object? The loaded value when successful; null when failed.
Error Error? The loader's error when failed; null when successful.
IsSuccess bool False for default(HopLoadResult) so misconfigured loaders cannot silently bypass short-circuits.

ResolvedAuthorizationPathHolder<TMessage, TLeaf, TOwner, TResponse>

Declaration

public sealed class ResolvedAuthorizationPathHolder<TMessage, TLeaf, TOwner, TResponse>

Closed-generic carrier that lets DI naturally disambiguate the ResolvedAuthorizationPath per via-authorized command. Each via-command's path is registered as Singleton<ResolvedAuthorizationPathHolder<TM, TL, TO, TR>>(holder). The matching ResourceAuthorizationViaBehavior<TM, TL, TO, TR> constructor takes the holder, so registration is a typed (not factory) descriptor — letting the relocator recognize Trellis-owned descriptors by ImplementationType alone without a factory-shape heuristic.

ResourceAuthorizationPathResolver

Declaration

public static class ResourceAuthorizationPathResolver

Resolves a ResolvedAuthorizationPath from a leaf type to an owner type by walking the entity graph defined by IIdentifyRelatedResource<TRelated, TId> and IIdentifyRelatedResources<TRelated, TId> declarations on candidate entity types.

Methods

Signature Returns Description
[RequiresUnreferencedCode] [RequiresDynamicCode] public static ResolvedAuthorizationPath Resolve(Type messageType, Type leafType, Type ownerType, IReadOnlyCollection<Type> candidateEntityTypes) ResolvedAuthorizationPath DFS-enumerates distinct simple paths from leafType to ownerType. Throws InvalidOperationException when no path exists, when multiple distinct simple paths exist (lists all paths in the message), when a plural hop is non-terminal, or when leafType == ownerType. Cycles in the graph are tolerated (per-path visited-set); duplicate candidate types are deduplicated. Builds typed extractor + loader delegates so the runtime hot path has no dynamic and no per-call reflection.

ResourceAuthorizationBehavior<TMessage, TResource, TResponse>

Declaration

public sealed partial class ResourceAuthorizationBehavior<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.Interfaces)] TMessage, TResource, TResponse> : IPipelineBehavior<TMessage, TResponse> where TMessage : IAuthorizeResource<TResource>, global::Mediator.IMessage where TResource : class where TResponse : IResult, IFailureFactory<TResponse>

Constructors

Signature Description
public ResourceAuthorizationBehavior(IActorProvider actorProvider, IServiceProvider serviceProvider) Uses fresh options (DefaultExposurePolicy = Propagate) and a null logger.
public ResourceAuthorizationBehavior(IActorProvider actorProvider, IServiceProvider serviceProvider, IOptions<ResourceAuthorizationOptions>? options, ILogger<ResourceAuthorizationBehavior<TMessage, TResource, TResponse>>? logger = null) Options-aware overload. options is required but nullable; passing null uses fresh options. logger defaults to NullLogger.Instance.

Properties

Name Type Description
— — None.

Methods

Signature Returns Description
public async ValueTask<TResponse> Handle(TMessage message, MessageHandlerDelegate<TMessage, TResponse> next, CancellationToken cancellationToken) ValueTask<TResponse> Resolves the actor before loader construction or I/O; absence produces Error.AuthenticationRequired, while provider faults propagate. Loads through IResourceLoader<TMessage,TResource> and calls message.Authorize(actor, resource) before the handler. On success publishes the exact loaded instance through IAuthorizedResource<TMessage,TResource> for this dispatch; cleanup invalidates orphan reads and restores nested frames. See Recipe 31. Exposure policy: under HideAsNotFound, resource-stage root NotFound, Gone, Forbidden, and AuthenticationRequired become one fresh public NotFound using the configured public type, request ID, and fixed code/detail. Other errors pass through unchanged. Normalization emits ExistenceHidden (EventId = 1, Information) with the original kind/code. Loader null-success denies with Forbidden("resource.authorization.null-payload"), also subject to the policy. Requires explicit or scanned resource registration; not included in AddTrellisBehaviors() or PipelineBehaviors. See ResourceAuthorizationOptions and Recipe 32.

AuthFailureExposurePolicy

Declaration

public enum AuthFailureExposurePolicy { Propagate = 0, HideAsNotFound = 1 }
Member Description
Propagate Default. Resource-stage errors retain their original metadata; via intermediate/owner load failures still collapse to Forbidden.
HideAsNotFound Resource-stage root Error.NotFound, Error.Gone, Error.Forbidden, and Error.AuthenticationRequired normalize to the same public NotFound. Original code, detail, resource, and cause are not copied. Other direct-resource / via-leaf errors are unchanged.

ResourceAuthorizationOptions

Declaration

public sealed class ResourceAuthorizationOptions

Constructors

Signature Description
public ResourceAuthorizationOptions() Constructs the options bag with DefaultExposurePolicy = AuthFailureExposurePolicy.Propagate and no per-resource overrides.

Properties

Name Type Description
DefaultExposurePolicy AuthFailureExposurePolicy Policy applied to resources that have no per-resource override. Defaults to Propagate. Set to HideAsNotFound to flip the default for an entire service; use Propagate<TResource>() to mark individual safe-to-disclose resources.

Methods

Signature Returns Description
public ResourceAuthorizationOptions HideExistence<TResource>() where TResource : class ResourceAuthorizationOptions Opt TResource into HideAsNotFound with default public code/detail. NotFound.Resource.Type uses ResourceRef.FormatTypeName(typeof(TResource)); ID comes from the message's IIdentifyResource<TResource,TId> or is absent when not declared. Returns this.
public ResourceAuthorizationOptions HideExistence<TResource>(string? code = null, string? detail = null) where TResource : class ResourceAuthorizationOptions Same type/ID selection, with fixed public code/detail applied identically to missing and withheld resources. Null/empty/whitespace code becomes error.unspecified; null detail uses the standard NotFound display message.
public ResourceAuthorizationOptions HideExistence<TAuthorizationResource, TPublicResource>() where TAuthorizationResource : class ResourceAuthorizationOptions Projection-loader form: policy is keyed on the authorization type, while the public resource type is TPublicResource. ID lookup tries IIdentifyResource<TPublicResource,TId> first, then IIdentifyResource<TAuthorizationResource,TId>. Uses default public code/detail.
public ResourceAuthorizationOptions HideExistence<TAuthorizationResource, TPublicResource>(string? code = null, string? detail = null) where TAuthorizationResource : class ResourceAuthorizationOptions Projection form with the same optional fixed public metadata and code normalization.
public ResourceAuthorizationOptions Propagate<TResource>() where TResource : class ResourceAuthorizationOptions Opt TResource into Propagate, clearing its hidden public-type/code/detail configuration. Useful for overriding DefaultExposurePolicy.

Behavioral notes

  • Canonical public metadata. Only resource-stage root NotFound/Gone/Forbidden/AuthenticationRequired normalize. Concealing Gone prevents a 410 from revealing previous existence. Type and ID come from configuration and the message, never the original error. Code/detail are fixed per resource; the original cause is cleared. Other direct-resource / via-leaf failures, aggregates, and handler failures are not normalized.
  • Configuration is last-call-wins. Reconfiguring a resource replaces its public type and metadata. Parameterless HideExistence resets code/detail to defaults. Configure callbacks compose in registration order.
  • Via owner failures. Every intermediate/owner load failure collapses to Forbidden("resource.authorization-via.load-failed") before normalization. Under hiding, even owner Unavailable becomes NotFound; private diagnostics see the collapsed kind/code.
  • Via commands key on TLeaf. HideExistence<Match>() covers IAuthorizeResourceVia<Team> + IIdentifyResource<Match,MatchId> and references the leaf, never the owner. The projection form can select a separate public type.
  • Static gates run first. IAuthorize failures remain outside resource-stage hiding. Omit that interface when anonymous probes must return the hidden NotFound instead of a static 401.
  • Observability and HTTP boundaries. ExistenceHidden retains the original input kind/code privately. The public error does not carry them. Per-request trace IDs, application response customization, and timing are not normalized; do not reintroduce private distinctions in custom writers.
  • Cache safety. Mark hidden-resource endpoints with Cache-Control: no-store or private; matching bodies do not prevent cross-actor cache contamination.

ServiceCollectionExtensions

Declaration

public static class ServiceCollectionExtensions

Constructors

No public constructors.

Properties

Name Type Description
PipelineBehaviors IReadOnlyList<Type> Ordered pipeline behavior types (outermost → innermost): ExceptionBehavior<,>, TracingBehavior<,>, LoggingBehavior<,>, AuthorizationContextBehavior<,>, AuthorizationBehavior<,>, ValidationBehavior<,>. Resource authorization and the TransactionalCommandBehavior are opt-in and not part of this list. This is an inspection list, not compile-time evaluation of generator options.

Methods

Signature Returns Description
public static IServiceCollection AddTrellisBehaviors(this IServiceCollection services) IServiceCollection Registers the six open generic behaviors listed in PipelineBehaviors and a default TrellisMediatorTelemetryOptions singleton (Detail redacted). Idempotent — uses TryAddEnumerable/TryAddSingleton so repeated normal registration does not duplicate behaviors. Absorbs known closed context descriptors into the open context and places it before authorization; resource behaviors relocate before validation and existing transaction descriptors remain innermost. Startup guardrail: a previously registered Singleton IMediator or ISender throws; use scoped (or transient) Mediator and call AddMediator first. For Native AOT with struct Result responses, use the separate closed-generator shape, not this open-generic helper.
public static IServiceCollection AddTrellisBehaviors(this IServiceCollection services, Action<TrellisMediatorTelemetryOptions> configure) IServiceCollection Same as the parameterless overload, but applies configure to the registered TrellisMediatorTelemetryOptions singleton. Replaces any prior options registration so this call wins regardless of ordering.
public static IServiceCollection AddResourceAuthorization<TMessage, TResource, TResponse>(this IServiceCollection services) where TMessage : IAuthorizeResource<TResource>, global::Mediator.IMessage where TResource : class where TResponse : IResult, IFailureFactory<TResponse> IServiceCollection Registers ResourceAuthorizationBehavior<TMessage, TResource, TResponse> and inserts it immediately before ValidationBehavior<,> when validation is already registered. Also registers IAuthorizedResource<TMessage, TResource> as scoped (backed by AuthorizedResourceHolder<,>) so handlers can inject the v4 typed accessor; see Recipe 31. Idempotent for the same closed service type + implementation type; different response types or via behaviors remain distinct. Throws InvalidOperationException when TMessage also implements IAuthorizeResourceVia<TOwner> (dual-mode commands are rejected at every entry point — security primitives are never silently composed).
[RequiresUnreferencedCode("Assembly scanning requires unreferenced types. Use explicit registration for AOT/trimming scenarios.")] [RequiresDynamicCode("Constructs closed generic types at runtime. Use explicit registration for AOT scenarios.")] public static IServiceCollection AddResourceAuthorization(this IServiceCollection services, params Assembly[] assemblies) IServiceCollection Scans assemblies for IAuthorizeResource<TResource> AND IAuthorizeResourceVia<TOwner> implementations, resolves TResponse from ICommand<T>, IQuery<T>, or IRequest<T>, registers closed ResourceAuthorizationBehavior<,,> / ResourceAuthorizationViaBehavior<,,,> instances, registers discovered IResourceLoader<,> and SharedResourceLoaderById<,> implementations, and bridges IIdentifyResource<TResource, TId> messages to shared loaders. Also auto-registers IAuthorizedResource<TMessage, TResource> (for direct commands) and IAuthorizedResource<TMessage, TLeaf> (for via commands — leaf only, owner accessor not in v4) so handlers can inject the v4 typed accessor. Closed behavior registration is idempotent across repeated scans and explicit-plus-scanned overlap when service type + implementation type match. For IAuthorizeResourceVia<TOwner> commands the scanner runs ResourceAuthorizationPathResolver.Resolve(...) over every scanned entity type and registers the closed ResolvedAuthorizationPathHolder<,,,> so the behavior receives its path via DI. Throws InvalidOperationException at startup when (a) any message's TResponse does not implement both IResult and IFailureFactory<TResponse> (security-marker fail-fast), (b) any message implements both IAuthorizeResource<T> and IAuthorizeResourceVia<TOwner> (security primitives are never silently composed), (c) any IAuthorizeResourceVia<TOwner> command does not also implement IIdentifyResource<TLeaf, TLeafId> (silent skip would leave the via-marker unprotected at runtime), (d) the path resolver finds zero or multiple distinct simple paths from leaf to owner, or (e) any discovered resource type or via-leaf type is a value type (the v4 accessor closed generics require where TResource : class / where TLeaf : class; the friendly diagnostic names the offending command and resource type).
[RequiresUnreferencedCode("Assembly scanning requires unreferenced types. Use explicit registration for AOT/trimming scenarios.")] public static IServiceCollection AddResourceLoaders(this IServiceCollection services, Assembly assembly) IServiceCollection Registers discovered IResourceLoader<,> implementations as scoped. Removes only matching unkeyed framework shared-loader adapters before try-adding a custom loader; application registrations and keyed services are preserved. Repeated scans are idempotent. Throws ArgumentNullException when services or assembly is null.
public static IServiceCollection AddSharedResourceLoader<TMessage, TResource, TId>(this IServiceCollection services) where TMessage : IIdentifyResource<TResource, TId> IServiceCollection Registers SharedResourceLoaderAdapter<TMessage, TResource, TId> as IResourceLoader<TMessage, TResource>. Constraint loosened from also requiring IAuthorizeResource<TResource> so via-commands (which use IAuthorizeResourceVia<TOwner> instead) can reuse the same bridging. There is no AddSharedResourceLoaderById helper; register SharedResourceLoaderById<TResource,TId> in DI separately.
public static IServiceCollection AddSharedResourceAuthorization<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.Interfaces)] TMessage, TResource, TId, TResponse>(this IServiceCollection services) where TMessage : IAuthorizeResource<TResource>, IIdentifyResource<TResource, TId>, global::Mediator.IMessage where TResource : class where TResponse : IResult, IFailureFactory<TResponse> IServiceCollection AOT-safe. Composes AddResourceAuthorization<TMessage,TResource,TResponse>() with AddSharedResourceLoader<TMessage,TResource,TId>(): registers the scoped behavior, authorized-resource accessor, and shared-loader adapter. Register SharedResourceLoaderById<TResource,TId> separately; if absent, adapter resolution throws rather than silently skipping authorization. Preserves an existing per-message loader. Idempotent across repeated, low-level, scanned, and builder registrations; preserves canonical ordering before validation and the unit of work. Does not register an actor provider, Mediator handlers, or standard Trellis behaviors. Throws ArgumentNullException for null services, and InvalidOperationException for a dual-mode message implementing IAuthorizeResourceVia<TOwner>, before adding registrations. Builder counterpart: UseSharedResourceAuthorization<TMessage,TResource,TId,TResponse>(). Existing low-level APIs are unchanged.
public static IServiceCollection AddRelatedResourceAuthorization<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.Interfaces)] TMessage, TLeaf, TLeafId, TOwner, TOwnerId, TResponse>(this IServiceCollection services, Func<TLeaf, TOwnerId?> extractOwnerId) where TMessage : IAuthorizeResourceVia<TOwner>, IIdentifyResource<TLeaf, TLeafId>, global::Mediator.IMessage where TLeaf : class where TOwner : class where TOwnerId : notnull where TResponse : IResult, IFailureFactory<TResponse> IServiceCollection Explicit single-hop registration for AOT / non-scanning consumers. Builds a ResolvedAuthorizationPath with one hop using extractOwnerId to extract the owner id from the loaded leaf, then registers ResourceAuthorizationViaBehavior<TMessage, TLeaf, TOwner, TResponse> as a typed descriptor and ResolvedAuthorizationPathHolder<TMessage, TLeaf, TOwner, TResponse> as a singleton. Throws ArgumentNullException when services or extractOwnerId is null. Throws InvalidOperationException if TMessage also implements IAuthorizeResource<T> (dual-mode security primitives are never silently composed). The hop loader throws InvalidOperationException at request time if SharedResourceLoaderById<TOwner, TOwnerId> is not registered (deployment bug, not authorization denial).
public static IServiceCollection AddRelatedResourceAuthorization<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.Interfaces)] TMessage, TLeaf, TOwner, TResponse>(this IServiceCollection services, ResolvedAuthorizationPath path) where TMessage : IAuthorizeResourceVia<TOwner>, global::Mediator.IMessage where TLeaf : class where TResponse : IResult, IFailureFactory<TResponse> IServiceCollection Explicit registration accepting a hand-built ResolvedAuthorizationPath for shapes the single-hop overload cannot express (chains, plural-terminal fan-out, custom extractors). Also registers IAuthorizedResource<TMessage, TLeaf> as scoped so handlers can inject the v4 typed accessor for the leaf (the typical mutation target); the owner accessor is intentionally not in v4. Throws ArgumentNullException when services or path is null. Same dual-mode rejection as the single-hop overload.
public static IServiceCollection AddResourceAuthorization(this IServiceCollection services, Action<ResourceAuthorizationOptions> configure) IServiceCollection Configures the per-resource failure-exposure policy via ResourceAuthorizationOptions. Repeated calls compose configure delegates rather than overwriting. Always-on side-effect: registers IOptions<ResourceAuthorizationOptions> (also added by every other AddResourceAuthorization / AddRelatedResourceAuthorization overload — behaviors can therefore always resolve options regardless of registration order). Throws ArgumentNullException when services or configure is null. See Recipe 32.

Direct/shared/via typed registrations and assembly scanning install a closed AuthorizationContextBehavior when no open context exists, reusing/repositioning known generated or typed descriptors. An existing open context absorbs known closed duplicates. Factories and keyed/consumer registrations are not removed. Context precedes authorization; resource behaviors precede validation or, without validation, transaction. Options-only and loader-only helpers do not install a dispatch pipeline.

For layered managed hosts, scan both the application and persistence assemblies. Options before or after the scan are supported; repeated scans deduplicate behavior and loader registrations, and repeated configuration callbacks all compose in order. Standard behaviors may be registered earlier and the unit of work afterward.

TracingBehavior<TMessage, TResponse>

Declaration

public sealed class TracingBehavior<TMessage, TResponse> : IPipelineBehavior<TMessage, TResponse> where TMessage : global::Mediator.IMessage where TResponse : IResult

Constructors

Signature Description
public TracingBehavior(TrellisMediatorTelemetryOptions? options = null) Builds the tracing behavior. options is resolved from DI; under AddTrellisBehaviors() the TrellisMediatorTelemetryOptions singleton is always registered, so this argument is non-null in production. The optional-null fallback exists only for consumers that instantiate the behavior outside of DI (custom test fixtures); when null, the safe-by-default options are used and Error.Detail is redacted from Activity.StatusDescription.

Fields

Name Type Description
ActivitySourceName string Public constant activity source name. Value: "Trellis.Mediator".

Properties

Name Type Description
— — None.

Methods

Signature Returns Description
public async ValueTask<TResponse> Handle(TMessage message, MessageHandlerDelegate<TMessage, TResponse> next, CancellationToken cancellationToken) ValueTask<TResponse> Starts an activity named after TMessage. On failed results, tags the activity with error.code (Error.Code, the same string the response body carries) and error.type (the stable error class name); sets ActivityStatusCode.Error. The StatusDescription is left empty by default — the free-text Error.Detail is included only when TrellisMediatorTelemetryOptions.IncludeErrorDetail is true. On success sets ActivityStatusCode.Ok. Rethrows consumer-initiated OperationCanceledException when the request cancellationToken is canceled and the exception carries that token, after recording the OpenTelemetry exception event and tagging otel.status_description = canceled; the activity status remains Unset. Other thrown exceptions are marked ActivityStatusCode.Error, tagged with error.type, recorded as an OpenTelemetry exception event (exception.type, exception.message, exception.stacktrace), and rethrown; the exception message is not copied into Activity.StatusDescription.

Recording these spans

AddTrellisBehaviors() registers TracingBehavior, so on each command/query it calls ActivitySource.StartActivity — but that only returns a live Activity if your TracerProvider listens to the "Trellis.Mediator" source. If the source is not registered, StartActivity returns null and the per-command/query span never appears (you still see the HTTP and value-object spans, but not the handler). Call AddTrellisMediatorInstrumentation():

builder.Services.AddOpenTelemetry()
    .WithTracing(tracing => tracing
        .AddAspNetCoreInstrumentation()
        .AddTrellisMediatorInstrumentation()
        .AddOtlpExporter());

AddSource("Trellis.Mediator") is equivalent — the helper exists so the name is not repeated as a string literal, and so this package matches AddTrellisResultsInstrumentation() in Trellis.Core and AddTrellisPrimitivesInstrumentation() in Trellis.Primitives.

This gap is silent. A service that never registers the source looks exactly like a service in which nothing failed: there is no warning, no startup error, and no empty-result signal — the spans are simply never collected. Because this span carries error.code and error.type, the gap is normally discovered during an incident, at the moment those tags were wanted.

MediatorTraceProviderBuilderExtensions

Declaration

public static class MediatorTraceProviderBuilderExtensions

Registers the mediator pipeline's activity source with an OpenTelemetry TracerProvider.

Methods

Signature Returns Description
public static TracerProviderBuilder AddTrellisMediatorInstrumentation(this TracerProviderBuilder builder) TracerProviderBuilder Subscribes the tracer provider to TracingBehavior<TMessage, TResponse>.ActivitySourceName ("Trellis.Mediator"), so the per-command/query span is collected. Returns the same builder for chaining. Throws ArgumentNullException when builder is null. Equivalent to AddSource("Trellis.Mediator").

The method is named for the Trellis pipeline rather than for the mediator alone because it instruments Trellis behaviors, not the underlying Mediator library, and the two would otherwise be easy to confuse on a TracerProviderBuilder chain.

TrellisMediatorTelemetryOptions

Declaration

public sealed class TrellisMediatorTelemetryOptions

Operator-tunable redaction settings consumed by LoggingBehavior and TracingBehavior. Resolved from DI; when not registered the behaviors fall back to a default-constructed instance (Detail redacted).

Properties

Name Type Description
IncludeErrorDetail bool When true, the logging and tracing behaviors include Error.Detail in their emitted message and activity status description. Defaults to false (Detail is redacted; only Error.Code and the error type name are emitted).

IMessageValidator

Declaration

public interface IMessageValidator<in TMessage>
    where TMessage : global::Mediator.IMessage

Extensibility hook for the unified validation stage. Implementations are resolved from DI as IEnumerable<IMessageValidator<TMessage>> by ValidationBehavior<TMessage, TResponse>; every registered validator runs before the handler. External packages (e.g., Trellis.Mediator.FluentValidation) plug additional validation sources into the pipeline through this interface without taking a dependency on a specific validation library or message-side interface from Trellis.Mediator.

Methods

Signature Returns Description
ValueTask<IResult> ValidateAsync(TMessage message, CancellationToken cancellationToken) ValueTask<IResult> Returns Result.Ok() on success, or Result.Fail(new Error.InvalidInput(...)) with field/rule violations on failure. Error.InvalidInput failures from every validator (and IValidate.Validate() if implemented) are aggregated into a single response failure by ValidationBehavior. Returning a non-Error.InvalidInput failure (e.g., Error.Conflict, Error.Forbidden) is allowed but short-circuits the stage immediately and is propagated as-is.

ValidationBehavior<TMessage, TResponse>

Declaration

public sealed class ValidationBehavior<TMessage, TResponse>(IEnumerable<IMessageValidator<TMessage>> validators) : IPipelineBehavior<TMessage, TResponse> where TMessage : global::Mediator.IMessage where TResponse : IResult, IFailureFactory<TResponse>

Unified validation stage. Runs IValidate.Validate() (when the message implements IValidate) and every IMessageValidator<TMessage> registered in DI for the message, then aggregates Error.InvalidInput failures into a single response. The behavior is registered for all messages — when the message does not implement IValidate and no validators are registered it is a no-op pass-through.

Constructors

Signature Description
public ValidationBehavior(IEnumerable<IMessageValidator<TMessage>> validators) Receives every IMessageValidator<TMessage> registered in DI. The collection is iterated once per request.

Properties

Name Type Description
— — None.

Methods

Signature Returns Description
public async ValueTask<TResponse> Handle(TMessage message, MessageHandlerDelegate<TMessage, TResponse> next, CancellationToken cancellationToken) ValueTask<TResponse> Aggregation rules: (1) Multiple Error.InvalidInput failures from IValidate and validators are merged into a single Error.InvalidInput whose Fields and Rules collect every reported violation. (2) An Error.InvalidInput with empty Fields AND empty Rules still short-circuits the handler — original failure semantics are preserved. (3) A non-Error.InvalidInput failure returned by any source short-circuits the stage immediately and is propagated as-is.

IDomainEventHandler

Declaration

public interface IDomainEventHandler<in TEvent>
    where TEvent : IDomainEvent

Handles a domain event raised by an IAggregate. Implementations are resolved via DI by DomainEventDispatchBehavior after a successful command and invoked once per matching event in the dispatch snapshot.

Dispatch matches the runtime type of the event exactly; base-type and interface-type handlers are not resolved automatically. Handlers must be idempotent and side-effect-only: send email, publish to a bus, update a projection, or enqueue external work, but do not mutate aggregates or raise more domain events from inside the dispatch loop. If a handler raises a new event on the same aggregate, or mutates another aggregate participating in tracked dispatch, post-dispatch validation throws DomainEventHandlerCascadedException. To perform more domain mutation, issue a follow-up Mediator command from the application layer after the originating command completes, or queue post-commit work that runs as a separate top-level command.

Non-cancellation exceptions thrown by a handler are logged at error level and swallowed by the default publisher so that other handlers, other events, and the originating command still complete. OperationCanceledException propagates when the publisher's supplied cancellation token is canceled, so the request can abort. Cascade detection does not make handler-side failures durable; durable at-least-once external side effects require the transactional outbox and integration-event translation pattern.

Methods

Signature Returns Description
ValueTask HandleAsync(TEvent domainEvent, CancellationToken cancellationToken) ValueTask Handles the event with the publisher's supplied token. The post-commit dispatch behaviors and DispatchAggregateEventsAsync pass CancellationToken.None, not the originating command token. The outbox relay supplies its stopping token.

IDomainEventPublisher

Declaration

public interface IDomainEventPublisher

Publishes a single IDomainEvent by resolving and invoking all IDomainEventHandler<TEvent> registrations for the event's runtime type. Application code rarely needs to inject this directly; it is useful for non-pipeline contexts such as background jobs or scheduled tasks that want to fan out an event the same way the pipeline would.

Methods

Signature Returns Description
ValueTask PublishAsync(IDomainEvent domainEvent, CancellationToken cancellationToken) ValueTask Publishes to all matching handlers. Resolution uses domainEvent.GetType(). Non-cancellation handler exceptions are logged and swallowed; OperationCanceledException propagates when the supplied token is canceled so the caller can abort. Default implementation (MediatorDomainEventPublisher) is internal and registered by AddDomainEventDispatch().

Swallowing is correct for this contract's callers: they dispatch post-commit and have no retry mechanism, so failing the request would report an error for a write that is already durable. Callers that do own a durable retry — principally the outbox relay — use IReportingDomainEventPublisher instead.

IReportingDomainEventPublisher

Declaration

public interface IReportingDomainEventPublisher

The non-swallowing counterpart to IDomainEventPublisher: it publishes a domain event and reports each handler's outcome instead of logging and discarding failures, so a caller with a durable retry mechanism can retry only what actually failed. The transactional outbox relay is the framework's user; application code rarely calls it directly.

Methods

Signature Returns Description
ValueTask<DomainEventDispatchReport> PublishReportingAsync(IDomainEvent domainEvent, IReadOnlySet<string>? completedHandlers, CancellationToken cancellationToken) ValueTask<DomainEventDispatchReport> Publishes to all matching handlers, skipping any named in completedHandlers (by DomainEventDispatchReport.HandlerIdentity; pass null on a first attempt), and reports the outcome. Every handler is attempted — a failure never short-circuits its siblings. OperationCanceledException matching the token propagates rather than being reported.

DomainEventDispatchReport

Member Type Description
static string HandlerIdentity(Type handlerType) string The stable handler identity used throughout: "{AssemblySimpleName}:{Type.FullName}". FullName alone is not collision-resistant — two assemblies can declare distinct handlers with the same namespace-qualified name, and skipping one because the other succeeded would silently drop work. The assembly's simple name keeps the identity stable across version bumps, so a rolling deploy does not re-run completed handlers.
CompletedHandlers IReadOnlyList<string> Every handler now complete, by HandlerIdentity — both those that succeeded in this dispatch and those skipped as already complete. Cumulative, so a caller persisting it can overwrite rather than merge.
Failures IReadOnlyList<DomainEventHandlerFailure> The handlers that threw, in invocation order. DomainEventHandlerFailure is a record of HandlerType and Error.
ResolutionFailure Exception? Set when handler resolution itself failed, meaning no handler ran and nothing can be marked complete.
IsComplete bool true when resolution succeeded and every resolved handler completed — i.e. no retry is needed.
FirstError Exception? The first error observed, preferring ResolutionFailure; null when IsComplete.

AddDomainEventDispatch() registers both contracts (scoped) forwarding to the same MediatorDomainEventPublisher instance. Replacing IDomainEventPublisher does not replace this one — register both if you substitute your own dispatch implementation and use the outbox.

IIntegrationEventHandler

Declaration

public interface IIntegrationEventHandler<in TEvent>
    where TEvent : IIntegrationEvent

Handles an IIntegrationEvent - the in-process consumer side of the external contract. Implementations are resolved via DI and invoked once per matching event by the default IIntegrationEventPublisher. Dispatch matches the runtime type of the event exactly; base-type and interface-type handlers are not resolved automatically.

This is the framework's default, in-process consumer for integration events: it lets a modular monolith react to its own published contracts without a message broker, and it makes integration events testable. When you move a consumer to a separate service, replace the default publisher with a broker adapter and the producing side is unchanged.

Like domain-event handlers, integration-event handlers are best-effort side effects: the default publisher logs and swallows non-cancellation exceptions so one handler's failure does not block the others. Handlers must be idempotent - the transactional outbox delivers at least once.

Methods

Signature Returns Description
ValueTask HandleAsync(TEvent integrationEvent, CancellationToken cancellationToken) ValueTask Handles the specified integration event. The cancellation token is supplied by the publisher/relay.

IIntegrationEventPublisher

Declaration

public interface IIntegrationEventPublisher

Publishes a single IIntegrationEvent to its consumers. The transactional outbox relay resolves this contract to deliver integration events durably after the producing transaction commits.

The default implementation fans out to in-process IIntegrationEventHandler<TEvent> registrations for the event's runtime type - the right choice for a modular monolith and for tests. To deliver to other services, replace this registration with a message-broker adapter (for example Azure Service Bus or Kafka); the producing side - aggregates, translators, and the outbox - does not change. This is the seam that keeps the outbox transport-agnostic.

Implementations are expected to be best-effort: non-cancellation handler exceptions are logged and swallowed so one consumer's failure does not block the others. OperationCanceledException matching the supplied token is the one exception that propagates so the relay can abort cleanly.

Methods

Signature Returns Description
ValueTask PublishAsync(OutboundIntegrationMessage message, CancellationToken cancellationToken) ValueTask Publishes an event together with the stable message identity a transport must stamp on the wire. Handler resolution uses message.Event.GetType(). Default implementation (MediatorIntegrationEventPublisher) is internal and registered by AddIntegrationEventDispatch(); it ignores the id, since in-process fan-out has no wire and nothing to deduplicate.

The message identity is part of the contract, not an optional extra. This is the interface's only method, so a transport cannot publish without the id. Outbox delivery is at-least-once, so the same row can be published more than once; carrying OutboundIntegrationMessage.MessageId verbatim onto the wire is what makes redeliveries look like one message to the consumer's (ConsumerId, MessageId) inbox dedup. An adapter that minted its own id per publish attempt would silently defeat the inbox — making that unrepresentable is why the bare-event overload was removed rather than kept alongside.

OutboundIntegrationMessage

Declaration

public sealed record OutboundIntegrationMessage(Guid MessageId, IIntegrationEvent Event)

The publish-side counterpart of IntegrationEnvelope: the event to publish plus the stable MessageId (the producer's outbox row id, a UUIDv7) a transport must carry verbatim.

The nullable MessageSource, CausationId, CorrelationId, TraceParent, and TraceState properties come from the persisted integration outbox row, not the publisher's current activity. A translator inherits the source domain row's trace and business correlation, while CausationId is the source domain row's Id. Old outbox rows without these columns populated publish with null metadata; transport adapters must preserve any present values across retries.

The MessageId and Event members are validated on construction — and on with copies, since the invariants live on the properties. Event must not be null (ArgumentNullException), and MessageId must not be Guid.Empty (ArgumentException). An empty id is rejected rather than tolerated because every message stamped with it collapses to the same inbox key.

IntegrationMessageContext

public static class IntegrationMessageContext
{
    public static Guid? CurrentMessageId { get; }
    public static string? CorrelationId { get; }
    public static string? MessageSource { get; }
    public static string? TraceParent { get; }
    public static string? TraceState { get; }
    public static IDisposable BeginCorrelation(string correlationId, string? messageSource = null);
    public static IDisposable BeginProcessing(IntegrationEnvelope envelope);
    public static bool TryParseRemoteContext(string? traceParent, string? traceState, out ActivityContext context);
}

IInboxDispatcher enters BeginProcessing for each inbound envelope. An outbox capture inside its handlers records CurrentMessageId as the domain row's direct CausationId; a nonblank inbound CorrelationId takes precedence over the application value. Applications can use using (IntegrationMessageContext.BeginCorrelation("workflow-id", "producer-service")) around the producing transaction to provide an opaque business correlation id and optional producer namespace. A blank correlation id throws ArgumentException; no HTTP request id or trace id is substituted when neither source supplies a value. BeginProcessing ignores malformed W3C trace context; a missing trace remains null. The scope is async-flow-local, restores the previous context on disposal, and does not expose values inherited from a disposed scope to child tasks. A still-active child scope retains its own inbound message id, correlation, trace, and explicitly supplied producer namespace after an outer scope ends. The child can open fresh scopes without inheriting the disposed parent's values; disposing those scopes restores the child's prior scope in order. No actor or arbitrary OpenTelemetry baggage is copied.

TryParseRemoteContext parses a W3C traceparent/tracestate pair as a remote ActivityContext (isRemote: true), returning false for a null, blank, or malformed pair. It is the single place that performs this parsing — InboxDispatcher, OutboxCaptureInterceptor, and OutboxRelay all call it instead of each re-implementing ActivityContext.TryParse with their own fallback shape.

IntegrationEventNameMap

Declaration

public sealed class IntegrationEventNameMap

An immutable, validated two-way map between an integration event's stable wire name (declared with IntegrationEventNameAttribute) and its local CLR type.

Cross-service messaging cannot identify an event by Type.AssemblyQualifiedName — which is what the outbox stores for its own in-process relaying. The consumer's assemblies differ from the producer's, and the string embeds an assembly version, so it can stop resolving after a routine version bump. A logical name is owned by the contract instead of by the CLR layout, so each side maps it to whatever local type it likes. Broker transports serialize through this map.

Members

Signature Returns Description
IntegrationEventNameMap(IEnumerable<KeyValuePair<string, Type>> contracts) — Builds a map from explicit pairs. Trimming- and NativeAOT-safe; prefer it in trimmed apps.
static IntegrationEventNameMap FromAssemblies(params Assembly[] assemblies) IntegrationEventNameMap Scans for concrete IIntegrationEvent types carrying the attribute. Types without it are skipped, so a contract assembly may hold deliberately in-process-only events. Annotated [RequiresUnreferencedCode].
static IntegrationEventNameMap Empty IntegrationEventNameMap A map in which every lookup returns None.
Maybe<string> NameFor(Type type) Maybe<string> The wire name for a local type, or None.
Maybe<Type> TypeFor(string name) Maybe<Type> The local type for a wire name, or None.
IReadOnlyCollection<string> Names IReadOnlyCollection<string> The registered wire names.

Construction throws ArgumentException when a name is blank, a type is not a concrete IIntegrationEvent, a type has unbound generic parameters, two types claim one name, or one type claims two names — each is an unrecoverable contract bug, so it surfaces at startup. Lookups return Maybe<T> instead, because an unknown name is a normal operational condition: a producer may emit contracts this consumer does not subscribe to, and the transport should dead-letter or ignore them by policy. Names compare with the ordinal comparer, so casing is significant.

IIntegrationEventCollector

Declaration

public interface IIntegrationEventCollector

Collects IIntegrationEvent instances produced by domain-event translators invoked by the outbox relay. It is not a command-side enqueue API: command handlers and ordinary in-process dispatch have no durable collector drain.

Register the collector as scoped (the Trellis registration helpers do this for you). The relay opens BeginTranslation() around domain-event publishing and drains before disposing the lease. Translators call Add; outside that active execution-context lease it throws InvalidOperationException, including late child tasks after lease disposal. A lease is relay infrastructure, not a way for a command to obtain durability. Without the outbox, collector additions are rejected.

Methods

Signature Returns Description
IDisposable BeginTranslation() IDisposable Relay-only translation lease; nested active leases throw InvalidOperationException. Disposal rejects late additions and discards undrained events; it does not persist them. Custom collectors must implement this lifecycle contract.
void Add(IIntegrationEvent integrationEvent) void Enqueues within the active translation lease. Throws ArgumentNullException for null, or InvalidOperationException outside a live relay translation.
IReadOnlyList<IIntegrationEvent> DrainPending() IReadOnlyList<IIntegrationEvent> Returns the integration events collected so far in insertion order and clears the buffer, so a subsequent drain in the same scope does not observe them again. Empty when none were added.

DomainEventHandlerCascadedException

Declaration

public sealed class DomainEventHandlerCascadedException : InvalidOperationException

Thrown when domain-event dispatch detects that a handler cascaded new domain events during a strict single-wave snapshot dispatch. The exception is raised after the original snapshot has been published but before AcceptChanges() clears the aggregate event queues, so operators can inspect the still-uncommitted events.

Constructors

Signature Description
public DomainEventHandlerCascadedException() Standard exception constructor. Offenders is empty.
public DomainEventHandlerCascadedException(string message) Standard exception constructor with a custom message. Offenders is empty.
public DomainEventHandlerCascadedException(string message, Exception innerException) Standard exception constructor with an inner exception. Offenders is empty.
public DomainEventHandlerCascadedException(IReadOnlyList<CascadeOffender> offenders) Builds the exception from all offending aggregates. The supplied list is copied.
public DomainEventHandlerCascadedException(Type aggregateType, IReadOnlyList<string> cascadedEventTypeNames) Convenience overload for a single offending aggregate.

Properties

Name Type Description
Offenders IReadOnlyList<CascadeOffender> Aggregates whose post-dispatch event queue changed from the entry snapshot. The tracked-aggregate behavior can report multiple offenders from one dispatch pass.

CascadeOffender

Declaration

public readonly record struct CascadeOffender(Type AggregateType, IReadOnlyList<string> CascadedEventTypeNames);

Identifies one aggregate type whose pending-event list changed during dispatch.

Properties

Name Type Description
AggregateType Type The offending aggregate type.
CascadedEventTypeNames IReadOnlyList<string> The event type names still present from the first changed position after the entry snapshot was published.

DomainEventDispatchBehavior

Declaration

public sealed partial class DomainEventDispatchBehavior<TMessage, TResponse>
    : IPipelineBehavior<TMessage, TResponse>
    where TMessage : ICommand<TResponse>
    where TResponse : IResult

Pipeline behavior that dispatches domain events accumulated on successful aggregate responses. Constrained to ICommand<TResponse> so queries do not trigger dispatch. A successful IResult<TAggregate> (typically Result<TAggregate>, with TAggregate : IAggregate) contributes an aggregate to the owning dispatch batch. Other response shapes contribute no aggregate of their own, but an outer DTO/Unit/tuple response still releases successful nested aggregate responses after its owning commit.

Persist-on-failure outcomes. Result.FailAfterCommit<TAggregate>(error) is still a failure, so in-process dispatch is skipped and its nested dispatch batch is discarded without clearing aggregate events. With the outbox interceptor, the successful save captures those events and clears them; the relay later delivers them despite the failed response. Without outbox capture, they remain on the in-memory aggregates until explicitly cleared or the scope is discarded. They are not an in-process retry buffer.

Nested commit ownership. IUnitOfWork.BeginScope() returns IUnitOfWorkScope; its IsOwner distinguishes a real commit from a successful deferred nested commit. Inner dispatch transfers pending aggregate references to the enclosing scope for the same unit of work, never publishes or clears them. Only an owning successful commit followed by a successful response releases the deduplicated batch. Outer failure, throw, or failed commit drops batch references without clearing events. Subsequent operations get fresh batches. Automatic dispatch under an external manually owned scope throws InvalidOperationException: disable automatic dispatch there and use DispatchAggregateEventsAsync after the manual owning commit. Concurrent commands sharing a scoped unit of work remain unsupported.

When TransactionalCommandBehavior is also registered, dispatch fires after the transaction commits — handlers see committed state. On a successful IResult<TAggregate> response, the behavior snapshots aggregate.UncommittedEvents() once at dispatch entry and publishes only that snapshot sequentially. Handler-raised events are never picked up by a later loop. After the snapshot has been published, the behavior validates the aggregate's event queue: the post-dispatch list must still equal the entry snapshot by both length and per-position reference equality. Any handler that raised new events, cleared the list via AcceptChanges, replaced events, or reordered them trips the validation and throws DomainEventHandlerCascadedException. IChangeTracking.AcceptChanges() is called only after validation proves the dispatch was clean.

On cascade, AcceptChanges() is not called. The aggregate retains the original events and any cascaded events so operators can inspect the in-memory state. Dispatch is not cancellable: it runs after the transaction has committed, so the caller's CancellationToken is not observed and CancellationToken.None is handed to each handler. A client disconnect mid-fan-out therefore cannot leave a durable write with only part of its events published. Non-cancellation handler exceptions are still swallowed by the default MediatorDomainEventPublisher; cascade detection is about handler-caused mutations of the aggregate's pending-event list, not handler-side failures.

Warning

Post-commit throw caveat. With TransactionalCommandBehavior registered, the database commit is already durable before domain-event dispatch starts. If cascade detection throws, the caller receives a failure-shaped response (via the outer exception behavior in the standard pipeline) even though the write committed; a retry may therefore hit normal "already committed" semantics. Durable at-least-once delivery requires the transactional outbox and integration-event translation pattern.

Constructors

Signature Description
public DomainEventDispatchBehavior(IDomainEventPublisher publisher, ILogger<DomainEventDispatchBehavior<TMessage, TResponse>> logger) Resolves the publisher used to fan out events to registered handlers.

Methods

Signature Returns Description
public async ValueTask<TResponse> Handle(TMessage message, MessageHandlerDelegate<TMessage, TResponse> next, CancellationToken cancellationToken) ValueTask<TResponse> Opens a dispatch batch before next; failure discards it without clearing events. A successful aggregate response contributes that aggregate. A deferred nested commit transfers its batch to the enclosing command for the same unit of work; a successful owning commit releases it even for an outer DTO/Unit. Snapshots all batch aggregates, publishes with CancellationToken.None, validates every queue by length and per-position reference identity, and only then calls AcceptChanges(). Any queue mutation throws DomainEventHandlerCascadedException before clearing any aggregate.

DomainEventDispatchServiceCollectionExtensions

Declaration

public static class DomainEventDispatchServiceCollectionExtensions

DI registration helpers for the dispatch behavior, default publisher, and per-event handler bindings.

Methods

Signature Returns Description
public static IServiceCollection AddDomainEventDispatch(this IServiceCollection services) IServiceCollection Registers DomainEventDispatchBehavior<,> (open generic, scoped) and the default publisher (MediatorDomainEventPublisher, scoped) under both IDomainEventPublisher and IReportingDomainEventPublisher. Calls AddTrellisBehaviors() first so the always-on behaviors are present. Yanks any prior open- or closed-generic TransactionalCommandBehavior registration and re-appends it last so dispatch sits outside the transaction and runs after commit. Idempotent. AOT-friendly (no scanning).
public static IServiceCollection AddDomainEventHandler<TEvent, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] THandler>(this IServiceCollection services) where TEvent : IDomainEvent where THandler : class, IDomainEventHandler<TEvent> IServiceCollection Registers a single IDomainEventHandler<TEvent> implementation as scoped, and ensures the dispatch behavior + publisher are wired. Use this for AOT/trim scenarios. Idempotent.
[RequiresUnreferencedCode("Assembly scanning requires unreferenced types. Use AddDomainEventHandler<TEvent, THandler> for AOT/trim scenarios.")] [RequiresDynamicCode("Constructs closed generic IDomainEventHandler<TEvent> at runtime.")] public static IServiceCollection AddDomainEventDispatch(this IServiceCollection services, params Assembly[] assemblies) IServiceCollection Scans the assemblies for concrete IDomainEventHandler<TEvent> implementations and registers each as scoped. A type implementing handlers for multiple event types is registered once per interface. Also wires the dispatch behavior + publisher (idempotent). Throws ArgumentNullException when services or assemblies is null and ArgumentException when the array is empty or contains null.

IntegrationEventDispatchServiceCollectionExtensions

Declaration

public static class IntegrationEventDispatchServiceCollectionExtensions

DI registration helpers for integration-event publishing. Unlike domain-event dispatch, integration events are not dispatched by a command-pipeline behavior; they are produced via the IIntegrationEventCollector during domain-event handling and published by the transactional outbox relay. These helpers register the default in-process publisher, the scoped collector, and any in-process consumers.

Methods

Signature Returns Description
public static IServiceCollection AddIntegrationEventDispatch(this IServiceCollection services) IServiceCollection Registers the default IIntegrationEventPublisher (MediatorIntegrationEventPublisher, scoped) and the scoped IIntegrationEventCollector (IntegrationEventCollector, scoped). AOT/trim-friendly. Idempotent. To deliver integration events to other services, replace the IIntegrationEventPublisher registration with a message-broker adapter after calling this method.
public static IServiceCollection AddIntegrationEventHandler<TEvent, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] THandler>(this IServiceCollection services) where TEvent : IIntegrationEvent where THandler : class, IIntegrationEventHandler<TEvent> IServiceCollection Registers a single IIntegrationEventHandler<TEvent> implementation as scoped, and ensures the publisher + collector are wired. Use this for AOT/trim scenarios. Idempotent.
[RequiresUnreferencedCode("Assembly scanning requires unreferenced types. Use AddIntegrationEventHandler<TEvent, THandler> for AOT/trim scenarios.")] [RequiresDynamicCode("Constructs closed generic IIntegrationEventHandler<TEvent> at runtime.")] public static IServiceCollection AddIntegrationEventDispatch(this IServiceCollection services, params Assembly[] assemblies) IServiceCollection Scans the assemblies for concrete IIntegrationEventHandler<TEvent> implementations and registers each as scoped. A type implementing handlers for multiple event types is registered once per interface. Also wires the publisher + collector (idempotent). Throws ArgumentNullException when services or assemblies is null and ArgumentException when the array is empty or contains null.

TrackedAggregateDomainEventDispatchBehavior

Declaration

public sealed partial class TrackedAggregateDomainEventDispatchBehavior<TMessage, TResponse>
    : global::Mediator.IPipelineBehavior<TMessage, TResponse>
    where TMessage : global::Mediator.ICommand<TResponse>
    where TResponse : IResult

Opt-in alternative to DomainEventDispatchBehavior<,>. Instead of extracting an aggregate from the response, it reads the unit-of-work's ITrackedAggregateSource and dispatches events from every aggregate that participated in the most recent successful commit. Use this when handlers return outcome DTOs (Result<DoneDto>, Result<Unit>, Result<(A, B)>) but mutate aggregates via the EF change tracker.

Shares the owning-commit rules above: deferred nested commands never read the previous CommittedAggregates snapshot or dispatch/clear events. Only the successful owning command reads the newly committed snapshot. Failed/throwing outer calls discard their pending dispatch without clearing aggregate events; outbox capture still owns clearing on successful persist-on-failure saves.

The tracked behavior snapshots the committed aggregate set and each aggregate's UncommittedEvents() at dispatch entry, publishes only those snapshots, then validates every snapshot aggregate before clearing anything. If dispatching aggregate A's events causes a handler to append events to aggregate B that was also in the snapshot, aggregate B is reported as a cascade offender too; the thrown DomainEventHandlerCascadedException lists every offending aggregate. AcceptChanges() is called on the snapshot aggregates only after the entire pass validates cleanly. The TrackedAggregateDispatchReentrancyGuard skips nested tracked dispatch; Mediator commands sent from inside a domain-event handler can therefore leave their own aggregate events stranded. Queue follow-up commands outside the handler instead.

Constructors

Signature Description
public TrackedAggregateDomainEventDispatchBehavior(ITrackedAggregateSource trackedAggregateSource, IDomainEventPublisher publisher, ILogger<TrackedAggregateDomainEventDispatchBehavior<TMessage, TResponse>> logger) Resolves the unit-of-work sidecar, publisher, and logger. Throws ArgumentNullException when any argument is null.

Methods

Signature Returns Description
public async ValueTask<TResponse> Handle(TMessage message, MessageHandlerDelegate<TMessage, TResponse> next, CancellationToken cancellationToken) ValueTask<TResponse> Awaits next. Skips dispatch on IsFailure (including Result.FailAfterCommit<T>(...)), when re-entered (AsyncLocal guard shared across all closed-generic instantiations), and when ITrackedAggregateSource.CommittedAggregates is empty. Otherwise snapshots every committed aggregate's events, publishes only those snapshots, validates that no snapshot aggregate accumulated additional events, and calls IChangeTracking.AcceptChanges() on every snapshot aggregate only after clean validation. Throws DomainEventHandlerCascadedException with all offending aggregates on same-aggregate or cross-aggregate cascade. Dispatch is post-commit and therefore not cancellable: the caller's token is not observed and handlers receive CancellationToken.None.

TrackedAggregateDomainEventDispatchServiceCollectionExtensions

Declaration

public static class TrackedAggregateDomainEventDispatchServiceCollectionExtensions

DI registration helper for the tracked dispatch behavior.

Methods

Signature Returns Description
public static IServiceCollection AddTrackedAggregateDomainEventDispatch(this IServiceCollection services) IServiceCollection Registers TrackedAggregateDomainEventDispatchBehavior<,> (open generic, scoped) and one scoped default publisher forwarded through both IDomainEventPublisher and IReportingDomainEventPublisher. Removes prior response-shape dispatch (mutually exclusive). Re-appends existing transaction behaviors last so dispatch runs outside the owning commit. Idempotent. Later AddDomainEventDispatch() / AddDomainEventHandler<,>() calls do not reintroduce response-shape dispatch.

Extension methods

Trellis.Mediator.TransactionalCommandBehaviorServiceCollectionExtensions

public static IServiceCollection AddTransactionalCommandBehavior(this IServiceCollection services)

Installs the open-generic TransactionalCommandBehavior<,> (slot 8 of the canonical pipeline order) independently of any persistence adapter. It is provider-neutral: any adapter that registers an IUnitOfWork can call it, and the shipped EF Core adapter does so from AddTrellisUnitOfWork<TContext>(). Because it is a leaf/adapter-author extension point rather than a composition-root feature, it deliberately has no TrellisServiceBuilder.UseXxx() slot — application authors reach it through UseEntityFrameworkUnitOfWork<TContext>().

Situation Result
The open-generic TransactionalCommandBehavior<,> is already registered No-op — the method is idempotent
Other IPipelineBehavior<,> registrations exist Inserted after the last one, so it runs innermost (closest to the handler); appended at the end when none exist
A closed-generic TransactionalCommandBehavior<TMessage,TResponse> is already registered Throws InvalidOperationException — the open generic would resolve alongside it and commit twice per command

Conflict detection inspects ServiceDescriptor.ImplementationType and ImplementationInstance. A closed behavior registered through ImplementationFactory cannot be detected without invoking the factory and is therefore not caught — if you register behaviors by factory, skip this method and own the wiring yourself.

Trellis.Mediator.ServiceCollectionExtensions

public static IServiceCollection AddTrellisBehaviors(this IServiceCollection services)
public static IServiceCollection AddTrellisBehaviors(this IServiceCollection services, Action<TrellisMediatorTelemetryOptions> configure)
public static IServiceCollection AddResourceAuthorization<TMessage, TResource, TResponse>(this IServiceCollection services) where TMessage : IAuthorizeResource<TResource>, global::Mediator.IMessage where TResource : class where TResponse : IResult, IFailureFactory<TResponse>
[RequiresUnreferencedCode("Assembly scanning requires unreferenced types. Use explicit registration for AOT/trimming scenarios.")]
[RequiresDynamicCode("Constructs closed generic types at runtime. Use explicit registration for AOT scenarios.")]
public static IServiceCollection AddResourceAuthorization(this IServiceCollection services, params Assembly[] assemblies)
[RequiresUnreferencedCode("Assembly scanning requires unreferenced types. Use explicit registration for AOT/trimming scenarios.")]
public static IServiceCollection AddResourceLoaders(this IServiceCollection services, Assembly assembly)
public static IServiceCollection AddSharedResourceLoader<TMessage, TResource, TId>(this IServiceCollection services) where TMessage : IIdentifyResource<TResource, TId>
public static IServiceCollection AddRelatedResourceAuthorization<TMessage, TLeaf, TLeafId, TOwner, TOwnerId, TResponse>(this IServiceCollection services, Func<TLeaf, TOwnerId?> extractOwnerId)
    where TMessage : IAuthorizeResourceVia<TOwner>, IIdentifyResource<TLeaf, TLeafId>, global::Mediator.IMessage
    where TLeaf : class
    where TOwner : class
    where TOwnerId : notnull
    where TResponse : IResult, IFailureFactory<TResponse>
public static IServiceCollection AddRelatedResourceAuthorization<TMessage, TLeaf, TOwner, TResponse>(this IServiceCollection services, ResolvedAuthorizationPath path)
    where TMessage : IAuthorizeResourceVia<TOwner>, global::Mediator.IMessage
    where TLeaf : class
    where TResponse : IResult, IFailureFactory<TResponse>

Both AddRelatedResourceAuthorization overloads have matching TrellisServiceBuilder slots — UseRelatedResourceAuthorization<TMessage, TLeaf, TLeafId, TOwner, TOwnerId, TResponse>(extractOwnerId) and UseRelatedResourceAuthorization<TMessage, TLeaf, TOwner, TResponse>(path) — so AOT/trim consumers can wire via-commands through the builder instead of dropping to the service collection. See trellis-api-servicedefaults.md.

Trellis.Mediator.DomainEventDispatchServiceCollectionExtensions

public static IServiceCollection AddDomainEventDispatch(this IServiceCollection services)
public static IServiceCollection AddDomainEventHandler<TEvent, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] THandler>(this IServiceCollection services)
    where TEvent : IDomainEvent
    where THandler : class, IDomainEventHandler<TEvent>
[RequiresUnreferencedCode("Assembly scanning requires unreferenced types. Use AddDomainEventHandler<TEvent, THandler> for AOT/trim scenarios.")]
[RequiresDynamicCode("Constructs closed generic IDomainEventHandler<TEvent> at runtime.")]
public static IServiceCollection AddDomainEventDispatch(this IServiceCollection services, params Assembly[] assemblies)

Trellis.Mediator.DomainEventPublisherExtensions

public static Task DispatchAggregateEventsAsync(
    this IDomainEventPublisher publisher,
    IAggregate aggregate,
    CancellationToken cancellationToken = default)

POST-COMMIT ONLY. Uses the same strict snapshot contract as DomainEventDispatchBehavior<,> for handlers whose TResponse is not an IResult<TAggregate> shape (Result<Unit>, Result<TDto>, Result<(A,B)>) and for non-Mediator call sites such as BackgroundService workers, and returns after the aggregate's current event snapshot has been published and accepted. See Behavioral notes: DispatchAggregateEventsAsync below.

Behavioral notes: DispatchAggregateEventsAsync

  • Snapshot + cascade semantics: Snapshots aggregate.UncommittedEvents() once, publishes only that snapshot sequentially, validates that no handler appended new events, and calls IChangeTracking.AcceptChanges() only after clean validation. Throws DomainEventHandlerCascadedException on cascade (AcceptChanges() is not called — original and cascaded events remain on the aggregate).
  • Cancellation: Not cancellable. The cancellationToken parameter is accepted for signature compatibility but not observed, and handlers receive CancellationToken.None — the helper is post-commit only, so aborting mid-fan-out would strand an already-durable write with a partially published event set.
  • Handler exceptions (publisher contract): Handler exceptions follow the publisher's contract: the default MediatorDomainEventPublisher logs and swallows non-cancellation handler exceptions so the helper continues; a custom publisher that propagates handler exceptions causes the helper to rethrow without calling AcceptChanges().
  • When to call: Must only be called after the underlying unit of work has committed — calling it inside a handler that relies on TransactionalCommandBehavior for its commit publishes events before the database transaction is durable. Durable at-least-once dispatch requires the transactional outbox and integration-event translation pattern.
  • Cross-doc link: See Dispatching events from non-aggregate response shapes for the integration article.

Trellis.Mediator.IntegrationEventDispatchServiceCollectionExtensions

public static IServiceCollection AddIntegrationEventDispatch(this IServiceCollection services)
public static IServiceCollection AddIntegrationEventHandler<TEvent, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] THandler>(this IServiceCollection services)
    where TEvent : IIntegrationEvent
    where THandler : class, IIntegrationEventHandler<TEvent>
[RequiresUnreferencedCode("Assembly scanning requires unreferenced types. Use AddIntegrationEventHandler<TEvent, THandler> for AOT/trim scenarios.")]
[RequiresDynamicCode("Constructs closed generic IIntegrationEventHandler<TEvent> at runtime.")]
public static IServiceCollection AddIntegrationEventDispatch(this IServiceCollection services, params Assembly[] assemblies)

Registers the default in-process integration-event publisher, the scoped collector used by translators, and optional in-process consumers. The transactional outbox relay drains collected integration events after each domain event dispatch and publishes them through IIntegrationEventPublisher; replace that publisher registration with a broker adapter when events must leave the process.

Trellis.Mediator.TrackedAggregateDomainEventDispatchServiceCollectionExtensions

public static IServiceCollection AddTrackedAggregateDomainEventDispatch(this IServiceCollection services)

Registers the tracked-aggregate pipeline behavior + default publisher. Mutually exclusive with AddDomainEventDispatch(): removes any prior response-shape DomainEventDispatchBehavior<,> registration so calling both no longer double-dispatches. Re-orders any prior open- or closed-generic TransactionalCommandBehavior registration so tracked dispatch sits just outside the transaction behavior and runs after commit (events are read from the unit-of-work's snapshot, taken at commit time). Idempotent. See TrackedAggregateDomainEventDispatchBehavior for the behavior contract and Auto-dispatching from outcome-DTO commands for the integration article.

Interfaces

public interface IValidate
public interface IMessageValidator<in TMessage> where TMessage : global::Mediator.IMessage
public interface IDomainEventHandler<in TEvent> where TEvent : IDomainEvent
public interface IDomainEventPublisher
public interface IReportingDomainEventPublisher
public interface IIntegrationEventHandler<in TEvent> where TEvent : IIntegrationEvent
public interface IIntegrationEventPublisher
public interface IIntegrationEventCollector
public interface IInboxDispatcher

Supporting types: OutboundIntegrationMessage (publish-side envelope carrying the wire identity) and IntegrationEventNameMap (wire name ↔ CLR type contract for broker transports).

IInboxDispatcher and InboxDispatchOutcome

The consume-side entry point. The contract lives here so transport adapters depend only on Trellis.Mediator; the shipped EF Core implementation and its wiring are documented in trellis-api-efcore-inbox.md.

Task<InboxDispatchOutcome> DispatchAsync(IntegrationEnvelope envelope, CancellationToken cancellationToken = default)

A transport adapter — a broker consumer or the in-process path — builds an IntegrationEnvelope and calls DispatchAsync. The dispatcher deduplicates on (ConsumerId, MessageId) so the integration-event handlers' side effects commit effectively once, atomically with the dedup record.

InboxDispatchOutcome Meaning
Processed The message was new: handlers ran and committed atomically with the dedup record.
SkippedDuplicate The pair was already processed and this call committed nothing — usually a redelivery caught before any handler runs; if a concurrent dispatch won the race, handlers ran but rolled back on the duplicate-key save.

Both outcomes mean the message is durably accounted for, so a pull consumer may advance its checkpoint on either. The distinction exists for metrics, logging, and overlap/anti-join bookkeeping.

Behavioral notes

Canonical pipeline order

The Trellis pipeline executes outermost → innermost in this order. AddTrellisBehaviors() registers slots 1-5 and 7; resource authorization, domain-event dispatch, tracked dispatch, and EF transactions are opt-in registrations that are inserted into the canonical slots shown below.

  1. ExceptionBehavior<,> — catches unhandled exceptions (except OperationCanceledException), logs them with a per-incident fault id, and converts them to a typed TResponse.CreateFailure(new Error.Unexpected("unhandled-exception", faultId) { Detail = "An unexpected error occurred while processing the request." }). Sits outermost so every other layer is wrapped.
  2. TracingBehavior<,> — opens an OpenTelemetry Activity per message under the "Trellis.Mediator" activity source. On failed results, tags error.code (Error.Code, matching the HTTP boundary) / error.type and sets ActivityStatusCode.Error; non-cancellation thrown exceptions do the same and also add the standard exception event (exception.type, exception.message, exception.stacktrace). Consumer-initiated cancellations (the thrown exception carries the canceled request token) record the exception event, tag otel.status_description = canceled, and leave the status Unset. Error.Detail is redacted from StatusDescription unless TrellisMediatorTelemetryOptions.IncludeErrorDetail is true.
  3. LoggingBehavior<,> — structured logging with start/end and elapsed-ms entries; emits the error type name and Error.Code on failure, matching the HTTP boundary and the span. Inherits the same correlation context propagated by the surrounding Activity. Error.Detail is redacted unless IncludeErrorDetail is true.
  4. AuthorizationContextBehavior<,> — authorization-marked messages get a fresh dispatch frame; it does not authenticate eagerly. Authorization stages share one Actor resolution and record successful gates; unwind restores the parent and expires orphan snapshots.
  5. AuthorizationBehavior<,> — runs for IAuthorize messages; resolves/reuses the dispatch actor, returns Error.AuthenticationRequired when absent, and rejects with new Error.Forbidden("authorization.insufficient.permissions") when RequiredPermissions are not satisfied.
  6. ResourceAuthorizationBehavior<,,> or ResourceAuthorizationViaBehavior<,,,> (opt-in) — resolves/reuses the actor before loader construction/I/O, then loads and authorizes the resource or related owners. Direct publishes the resource; via publishes the leaf. Runs immediately before validation; duplicate known closed registrations are ignored.
  7. ValidationBehavior<,> — unified validation stage. Runs IValidate.Validate() if implemented, then every IMessageValidator<TMessage> resolved from DI; aggregates all Error.InvalidInput failures into a single response. External validation sources (e.g., the Trellis.Mediator.FluentValidation adapter) participate here without occupying their own pipeline slot.
  8. DomainEventDispatchBehavior<,> (opt-in via AddDomainEventDispatch(...)) — commands with successful aggregate responses contribute to an owning dispatch batch. Deferred inner commands neither publish nor clear; the successful owning outer commit releases nested batches even when its response is DTO/Unit. All aggregate event queues are snapshotted and validated before any AcceptChanges(). Handlers receive CancellationToken.None, and default-publisher handler failures are logged/swallowed. Cascade detection throws after the durable commit and cannot roll it back. Mutually exclusive with tracked dispatch: registration removes response-shape dispatch and later response-shape helpers do not reintroduce it.
    • Or TrackedAggregateDomainEventDispatchBehavior<,> (opt-in via AddTrackedAggregateDomainEventDispatch(...)) — sits at the same slot but reads the aggregates from ITrackedAggregateSource.CommittedAggregates (populated by the unit-of-work at commit time), snapshots each aggregate's events, and throws DomainEventHandlerCascadedException when same-aggregate or cross-aggregate cascade is detected. Fires for any response shape, including outcome DTOs. See TrackedAggregateDomainEventDispatchBehavior.
  9. TransactionalCommandBehavior<,> (opt-in, lives in Trellis.Mediator, not registered by AddTrellisBehaviors()) — wraps the handler for ICommand<TResponse> messages and calls IUnitOfWork.CommitAsync on success. Install via AddTransactionalCommandBehavior() (or the EF Core adapter's AddTrellisUnitOfWork<TContext>(), which calls it). Registration is order-independent versus AddTrellisBehaviors() and domain-event dispatch helpers: open- and closed-generic transaction descriptors are rehomed to remain innermost (closest to the handler), keeping commit failures visible to outer logging/tracing/dispatch. Queries are skipped.

Native AOT registration

Native DI cannot close open behaviors over struct Result<T> responses. Configure Mediator 3.0.2 with literal typeof(...) expressions to generate closed applicable behaviors:

services.AddMediator(options =>
{
    options.ServiceLifetime = ServiceLifetime.Scoped;
    options.PipelineBehaviors =
    [
        typeof(ExceptionBehavior<,>),
        typeof(TracingBehavior<,>),
        typeof(LoggingBehavior<,>),
        typeof(AuthorizationContextBehavior<,>),
        typeof(AuthorizationBehavior<,>),
        typeof(ValidationBehavior<,>),
    ];
});

Do not also call AddTrellisBehaviors() or UseMediator() (including builder slots that imply it) in this shape. Keep resource registrations typed; they reuse the generated closed context. The generator discovers inherited command/query handler interfaces. ServiceCollectionExtensions.PipelineBehaviors.ToArray() is not evaluated as compile-time generator configuration.

Managed hosts can instead use AddMediator followed by AddTrellisBehaviors().

Code examples

Registering behaviors and shared resource authorization

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Mediator;
using Microsoft.Extensions.DependencyInjection;
using Trellis;
using Trellis.Authorization;
using Trellis.Mediator;

var services = new ServiceCollection();

services.AddScoped<IActorProvider, StaticActorProvider>();
services.AddScoped<SharedResourceLoaderById<Order, OrderId>, OrderResourceLoader>();
services.AddTrellisBehaviors();
services.AddSharedResourceAuthorization<GetOrderQuery, Order, OrderId, Result<Order>>();

var behaviorOrder = Trellis.Mediator.ServiceCollectionExtensions.PipelineBehaviors;
Console.WriteLine(string.Join(", ", behaviorOrder.Select(type => type.Name)));

public sealed partial class OrderId : RequiredGuid<OrderId>;

public sealed record Order(OrderId Id, ActorId OwnerId);

public sealed record GetOrderQuery(OrderId Id)
    : IQuery<Result<Order>>, IAuthorize, IAuthorizeResource<Order>, IIdentifyResource<Order, OrderId>, IValidate
{
    public IReadOnlyList<string> RequiredPermissions => ["orders:read"];

    public OrderId GetResourceId() => Id;

    public IResult Validate() => Result.Ok();

    public IResult Authorize(Actor actor, Order resource) =>
        Result.Ensure(
            resource.OwnerId == actor.Id,
            () => new Error.Forbidden("orders.read") { Detail = "Only the owner can view the order." });
}

public sealed class OrderResourceLoader : SharedResourceLoaderById<Order, OrderId>
{
    public override Task<Result<Order>> GetByIdAsync(OrderId id, CancellationToken cancellationToken) =>
        Task.FromResult(ActorId.TryCreate("user-1").Map(ownerId => new Order(id, ownerId)));
}

// Escape hatch: prefer IIdentifyResource<TResource, TId> + SharedResourceLoaderById<TResource, TId> in generated services.
// services.AddScoped<IResourceLoader<GetOrderQuery, Order>, GetOrderResourceLoader>();

public sealed class StaticActorProvider : IActorProvider
{
    public Task<Maybe<Actor>> GetCurrentActorAsync(CancellationToken cancellationToken = default) =>
        Task.FromResult(Maybe.From(Actor.Create("user-1", new HashSet<string> { "orders:read" })));
}

Assembly scanning registration

using System.Reflection;
using Microsoft.Extensions.DependencyInjection;
using Trellis.Mediator;

var services = new ServiceCollection();
Assembly[] assemblies = [typeof(SomeMessageInApplicationAssembly).Assembly];

services.AddTrellisBehaviors();
services.AddResourceAuthorization(assemblies);

public sealed class SomeMessageInApplicationAssembly { }

Domain event dispatch — order confirmation email handler

using System;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using Mediator;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Trellis;
using Trellis.Mediator;

// 1. Domain side: an aggregate raises an event during a state-changing method.
public sealed partial class OrderId : RequiredGuid<OrderId>;

public sealed record OrderSubmitted(OrderId OrderId, DateTimeOffset OccurredAt) : IDomainEvent;

public sealed class Order : Aggregate<OrderId>
{
    public Order(OrderId id) : base(id) { }

    public Result<Order> Submit(TimeProvider clock)
    {
        DomainEvents.Add(new OrderSubmitted(Id, clock.GetUtcNow()));
        return this;
    }
}

// 2. Command and handler return Result<Order>.
public sealed record SubmitOrderCommand(OrderId OrderId) : ICommand<Result<Order>>;

public sealed class SubmitOrderCommandHandler : ICommandHandler<SubmitOrderCommand, Result<Order>>
{
    private readonly TimeProvider _clock;
    public SubmitOrderCommandHandler(TimeProvider clock) => _clock = clock;

    public ValueTask<Result<Order>> Handle(SubmitOrderCommand command, CancellationToken cancellationToken)
        => new(new Order(command.OrderId).Submit(_clock));
}

// 3. Email handler runs after the command succeeds. Side effects are best-effort:
//    catch + log + return; never let an email failure roll back the user's order submission.
public sealed class OrderConfirmationEmailHandler : IDomainEventHandler<OrderSubmitted>
{
    private readonly ILogger<OrderConfirmationEmailHandler> _logger;
    public OrderConfirmationEmailHandler(ILogger<OrderConfirmationEmailHandler> logger) => _logger = logger;

    public ValueTask HandleAsync(OrderSubmitted domainEvent, CancellationToken cancellationToken)
    {
        // await _email.SendAsync(...) etc.
        _logger.LogInformation("Order {OrderId} submitted at {OccurredAt}", domainEvent.OrderId, domainEvent.OccurredAt);
        return ValueTask.CompletedTask;
    }
}

// 4. Composition root: register the dispatch behavior + the handler.
//    Use AddDomainEventDispatch(assemblies) for scanning, OR AddDomainEventHandler<,>() per handler for AOT.
var services = new ServiceCollection();
services.AddSingleton(TimeProvider.System);
services.AddLogging();
services.AddTrellisBehaviors();
services.AddDomainEventDispatch(typeof(OrderConfirmationEmailHandler).Assembly);
// AOT alternative:
//   services.AddDomainEventDispatch();
//   services.AddDomainEventHandler<OrderSubmitted, OrderConfirmationEmailHandler>();

Cross-references