Mediator Pipeline
Trellis.Mediator registers result-aware pipeline behaviors around the Mediator library so handlers stay focused on business work while exception safety, tracing, logging, authorization, and validation run as composable pre/post stages.
Patterns Index
| Goal | Use | See |
|---|---|---|
| Register the standard Trellis behaviors | services.AddTrellisBehaviors() |
Quick start |
| Inspect or override the canonical behavior order | ServiceCollectionExtensions.PipelineBehaviors |
Pipeline order |
| Gate a message on static permissions | Implement IAuthorize on the message |
Permission authorization |
| Receive the checked actor/resource directly in business logic | One of the six Actor...Handler bases |
Checked parameters |
| Authorize against a loaded resource (ownership, tenancy) | Implement IAuthorizeResource<T> and register a loader |
Resource authorization |
| Reuse one loader across many commands for the same resource | IIdentifyResource<T, TId> + SharedResourceLoaderById<T, TId> |
Shared resource loaders |
| Self-validate a message | Implement IValidate.Validate() |
Validation |
| Plug FluentValidation into the same stage | services.AddTrellisFluentValidation() |
FluentValidation adapter |
Show Error.Detail in logs/traces (dev only) |
AddTrellisBehaviors(o => o.IncludeErrorDetail = true) |
Telemetry redaction |
| Convert thrown exceptions to typed failures | ExceptionBehavior (always-on) |
Exception safety net |
| Dispatch domain events that aggregates raised during a command | services.AddDomainEventDispatch() + IDomainEventHandler<TEvent> |
Domain event dispatch |
Manually dispatch an aggregate's events from a non-aggregate handler or BackgroundService |
IDomainEventPublisher.DispatchAggregateEventsAsync(aggregate, ct) |
Dispatching events from non-aggregate response shapes |
| Auto-dispatch domain events from every aggregate the unit-of-work tracked, regardless of response shape | services.AddTrellis(t => t.UseTrackedAggregateDomainEvents(...)) |
Auto-dispatching from outcome-DTO commands |
| Use a custom envelope response type around an aggregate | TResponse : IResult<TAggregate>, IFailureFactory<TResponse> |
Custom envelope response types |
Persist a permanently_failed row alongside a failure outcome (worker pattern) |
Result.FailAfterCommit<T>(error) |
Persisting failure state from a worker handler |
Use this guide when
- You are wiring
Trellis.Mediatorinto a Web API or Worker host and need the canonical behavior registration. - You want to move authorization or validation off your handlers and into the pipeline.
- You want consistent OpenTelemetry spans and structured logs for every command/query.
- You need to plug FluentValidation (or another validation library) into the same validation stage as
IValidate.
Surface at a glance
| Type / member | Kind | Purpose |
|---|---|---|
AddTrellisBehaviors() |
DI extension | Registers the six standard behaviors, including the authorization dispatch context (idempotent). |
AddTrellisBehaviors(Action<TrellisMediatorTelemetryOptions>) |
DI extension | Same, with telemetry options (e.g., IncludeErrorDetail). |
AddResourceAuthorization(params Assembly[]) |
DI extension | Scans assemblies for IAuthorizeResource<>, loaders, and shared loaders. |
AddResourceAuthorization<TMessage, TResource, TResponse>() |
DI extension | Explicit registration (AOT/trimming friendly). |
AddSharedResourceAuthorization<TMessage, TResource, TId, TResponse>() |
DI extension | Explicit behavior, accessor, and shared-loader adapter registration (AOT/trimming friendly); the shared-loader implementation is registered separately. |
AddSharedResourceLoader<TMessage, TResource, TId>() |
DI extension | Bridges an IIdentifyResource<T,TId> message to a SharedResourceLoaderById<T,TId>. |
IValidate |
Interface | Message-side hook; IResult Validate() runs before the handler. |
IMessageValidator<TMessage> |
Interface | DI-resolved async validator; aggregated by ValidationBehavior. |
TrellisMediatorTelemetryOptions.IncludeErrorDetail |
Property | Opt-in to include Error.Detail in logs/traces (default false). |
TracingBehavior<,>.ActivitySourceName |
const string |
"Trellis.Mediator" — add this to your OpenTelemetry config. |
ServiceCollectionExtensions.PipelineBehaviors |
Property | Ordered inspection list; source-generator configuration must use literal typeof(...) entries. |
AddDomainEventDispatch() / AddDomainEventDispatch(params Assembly[]) |
DI extension | Registers DomainEventDispatchBehavior<,> (open-generic) + the default IDomainEventPublisher; the assembly overload also scans for IDomainEventHandler<TEvent> implementations. Idempotent. |
AddDomainEventHandler<TEvent, THandler>() |
DI extension | AOT/trim-friendly per-handler registration (also wires up the dispatch behavior + publisher). |
IDomainEventHandler<TEvent> |
Interface | Side-effect handler invoked once per matching event after the command commits. |
IDomainEventPublisher |
Interface | Resolves IDomainEventHandler<TEvent> instances and fans events out; default impl is MediatorDomainEventPublisher (DI-resolved, scoped). |
DomainEventPublisherExtensions.DispatchAggregateEventsAsync(this IDomainEventPublisher, IAggregate, CancellationToken) |
Extension method | Post-commit-only helper that uses the strict snapshot-and-cascade-validation contract for handlers that do not return Result<TAggregate>. |
TrackedAggregateDomainEventDispatchBehavior<TMessage, TResponse> |
Pipeline behavior | Opt-in alternative to DomainEventDispatchBehavior<,> that auto-dispatches events from every aggregate the unit-of-work tracked at commit time, regardless of response shape. Registered via AddTrackedAggregateDomainEventDispatch() or TrellisServiceBuilder.UseTrackedAggregateDomainEvents(...). Mutually exclusive with the response-shape behavior. |
AddTrackedAggregateDomainEventDispatch() |
DI extension | Registers the tracked behavior + default publisher and replaces any prior response-shape dispatch registration. Idempotent. |
ITrackedAggregateSource |
Interface | Sidecar exposed by EfUnitOfWork<TContext> (or a custom IUnitOfWork) — returns the aggregates the unit-of-work committed on its most recent successful save. Empty after a failed commit or thrown save. |
Full signatures: trellis-api-mediator.md.
Installation
dotnet add package Trellis.Mediator
Quick start
Register Mediator with the scoped lifetime, add the Trellis behaviors, and your handlers immediately get exception safety, tracing, logging, authorization, and validation.
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Mediator;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Trellis;
using Trellis.Authorization;
using Trellis.Mediator;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddMediator(opts => opts.ServiceLifetime = ServiceLifetime.Scoped);
builder.Services.AddTrellisBehaviors();
var app = builder.Build();
app.Run();
public sealed record PublishDocumentCommand(string DocumentId)
: ICommand<Result<Unit>>, IAuthorize
{
public IReadOnlyList<string> RequiredPermissions => ["documents:publish"];
}
public sealed class PublishDocumentHandler : ICommandHandler<PublishDocumentCommand, Result<Unit>>
{
public ValueTask<Result<Unit>> Handle(PublishDocumentCommand command, CancellationToken cancellationToken) =>
ValueTask.FromResult(Result.Ok());
}
Important
Pass opts => opts.ServiceLifetime = ServiceLifetime.Scoped. The Trellis behaviors depend on per-request services (IActorProvider, IUnitOfWork, IMessageValidator<> adapters). Mediator's default lifetime is Singleton, which fails ASP.NET's root-scope validation as soon as a behavior tries to resolve a scoped dependency. AddTrellisBehaviors() fails fast with an actionable message if it sees a Singleton Mediator.
Note
A file that imports both the Trellis and Mediator namespaces has two Unit types in scope — Trellis.Unit (the success-without-payload value a command returns) and Mediator.Unit — so a bare Result<Unit> is ambiguous. The examples here abbreviate it; in real code either qualify it as Result<Trellis.Unit> (what the compiled samples do) or add a one-line alias to your GlobalUsings.cs so the short form keeps working:
global using TrellisUnit = Trellis.Unit; // then write Result<TrellisUnit>
// or, since Trellis consumers never reference Mediator.Unit directly:
global using Unit = Trellis.Unit; // then write Result<Unit>
Pipeline order
AddTrellisBehaviors() registers the six standard behaviors in this fixed order (outermost → innermost). The opt-in entries in rows 6, 8, and 9 slot in only when their registration helpers are called.
| # | Behavior | Runs for | What it does |
|---|---|---|---|
| 1 | ExceptionBehavior |
all messages | Catches everything except OperationCanceledException; returns Error.Unexpected. |
| 2 | TracingBehavior |
all messages | Opens an Activity under "Trellis.Mediator"; tags error.code / error.type on failure, records exception events for thrown handlers, and leaves consumer-initiated cancellations non-error. |
| 3 | LoggingBehavior |
all messages | Structured start/end with elapsed ms; emits Error.Code on failure. |
| 4 | AuthorizationContextBehavior |
authorization-marked messages | Creates a dispatch frame; authorization shares one Actor resolution, and handler reads require completed gates. |
| 5 | AuthorizationBehavior |
IAuthorize messages |
Resolves/reuses the actor and checks RequiredPermissions. |
| 6 | Direct/via resource authorization (opt-in) | IAuthorizeResource<T> / IAuthorizeResourceVia<T> messages |
Loads the resource/owners and applies the message's rule. Publishes the direct resource or via leaf, immediately before validation. |
| 7 | ValidationBehavior |
all messages | Runs IValidate.Validate() and every IMessageValidator<TMessage>; aggregates Error.InvalidInput. |
| 8 | DomainEventDispatchBehavior (opt-in) |
ICommand<TResponse> where TResponse : IResult |
After a successful response, extracts the aggregate via IResult<TAggregate> and publishes the events it raised. Inserted by AddDomainEventDispatch(...). See Domain event dispatch. |
| 9 | TransactionalCommandBehavior (opt-in, EFCore) |
ICommand<TResponse> |
IUnitOfWork.CommitAsync on success; wraps each command in using var scope = unitOfWork.BeginScope(); so nested commands defer commit to the outermost scope. Remains innermost in either registration order. See Nested commands and scope-aware commit. |
ServiceCollectionExtensions.PipelineBehaviors exposes the six standard types for
inspection. Mediator 3.0.2 does not evaluate PipelineBehaviors.ToArray() as generator
configuration. Native AOT hosts using struct Result<T> responses need literal closed
behavior configuration,
not the managed open-generic AddTrellisBehaviors() shape. Do not mix the two standard
registration paths; publish-only success does not prove native DI dispatch works.
Note
Rows 8 and 9 are designed to be registration-order-independent: AddDomainEventDispatch(...) and AddTrellisUnitOfWork<TContext>() both detect the other and shuffle so the canonical order (events fire after the transaction commits, so handlers see committed state) holds regardless of which services.Add* call comes first.
Permission authorization
Implement IAuthorize when a message always requires the same permission set. AuthorizationBehavior resolves the current Actor from IActorProvider and rejects with new Error.Forbidden("authorization.insufficient.permissions") { Detail = "Insufficient permissions." } when any required permission is missing.
using System.Collections.Generic;
using Mediator;
using Trellis;
using Trellis.Authorization;
public sealed record PublishDocumentCommand(string DocumentId)
: ICommand<Result<Unit>>, IAuthorize
{
public IReadOnlyList<string> RequiredPermissions => ["documents:publish"];
}
Permission evaluation reads the resolved Actor; the actor provider may itself perform
I/O. Use IAuthorizeResource<T> when the answer depends on the resource itself.
Checked actor and resource parameters
The optional six-base family removes actor/provider and resource/accessor constructor
plumbing. Concrete constructors retain business dependencies; override protected
Handle(message, actor[, resource/leaf], token), not the public two-argument entry.
| Business inputs | Command base | Query base |
|---|---|---|
| Checked actor | ActorCommandHandler |
ActorQueryHandler |
| Checked actor + directly authorized resource | ActorResourceCommandHandler |
ActorResourceQueryHandler |
| Checked actor + indirectly authorized leaf | ActorResourceViaCommandHandler |
ActorResourceViaQueryHandler |
One Actor reference is resolved per dispatch, shared by static/resource checks and the
business method even if the provider could return changed permissions under the same
actor ID. Via handlers receive the leaf: Cricket checks home OR away team ownership
but supplies the match; Document -> Folder checks the folder but supplies the document.
Resource bases do not force static permissions onto resource-only messages, preserving
configured existence hiding. They do not normalize differing NotFound bodies (TFR-18).
Nested and concurrent sends get separate frames. The normal entry fails before business logic for missing gates, wrong leaf/path, mismatched or expired state, or resources from another dispatch. Standard registration installs the context automatically; manually composed authorization pipelines must add it before their authorization stages.
Handler tests use actual Mediator dispatch with TestActorProvider and fake resource
loaders/business dependencies. Retain the production pipeline registrations and use fake
commit/event dependencies when those stages are enabled. The public two-argument entry
requires an authorized dispatch; the actor/resource overload is a protected hook, not a
public test seam. Unit-test aggregates, policies, or application services directly when
their logic needs pipeline-free isolation.
The existing IAuthorizedResource pattern remains valid when the business body does not
need an actor. RequireActorAsync remains a separate provider lookup, not a snapshot read.
Loaded-instance identity is not mutation-readiness or a database ownership guarantee.
See exact constraints, examples, and custom-base migration.
Resource authorization
Use IAuthorizeResource<TResource> when authorization depends on the resource (ownership, tenancy, state). The pipeline loads the resource first, then calls message.Authorize(actor, resource).
ResourceAuthorizationBehavior is opt-in: it is added only when you call AddResourceAuthorization(...). Without that call the behavior never runs even if the message implements IAuthorizeResource<T>.
Per-message loader
Use ResourceLoaderById<TMessage, TResource, TId> for the common "message has an id, repository loads by id" case.
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Mediator;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Trellis;
using Trellis.Authorization;
using Trellis.Mediator;
public sealed record Document(Guid Id, string OwnerId, string Title);
public interface IDocumentRepository
{
Task<Result<Document>> GetByIdAsync(Guid id, CancellationToken cancellationToken);
Task<Result<Document>> RenameAsync(Document document, string title, CancellationToken cancellationToken);
}
public sealed record RenameDocumentCommand(Guid DocumentId, string Title)
: ICommand<Result<Document>>, IAuthorize, IAuthorizeResource<Document>
{
public IReadOnlyList<string> RequiredPermissions => ["documents:edit"];
public IResult Authorize(Actor actor, Document resource) =>
actor.IsOwner(resource.OwnerId)
? Result.Ok()
: Result.Fail(new Error.Forbidden("documents.rename") { Detail = "Only the owner can rename this document." });
}
public sealed class RenameDocumentResourceLoader(IDocumentRepository repository)
: ResourceLoaderById<RenameDocumentCommand, Document, Guid>
{
protected override Guid GetId(RenameDocumentCommand message) => message.DocumentId;
protected override Task<Result<Document>> GetByIdAsync(Guid id, CancellationToken cancellationToken) =>
repository.GetByIdAsync(id, cancellationToken);
}
public static class Composition
{
public static void Configure(WebApplicationBuilder builder)
{
builder.Services.AddMediator(opts => opts.ServiceLifetime = ServiceLifetime.Scoped);
builder.Services.AddTrellisBehaviors();
builder.Services.AddResourceAuthorization(typeof(RenameDocumentCommand).Assembly);
}
}
For the RenameDocumentCommand above, the per-request order becomes: permission check → resource load + Authorize(actor, resource) → validation → handler.
Shared resource loaders
When several commands authorize against the same resource, register one SharedResourceLoaderById<TResource, TId> and let messages declare IIdentifyResource<TResource, TId>. Assembly scanning auto-bridges them; explicit registration uses AddSharedResourceAuthorization<TMessage,TResource,TId,TResponse>() for the behavior, accessor, and adapter together. The lower-level AddResourceAuthorization<,,> plus AddSharedResourceLoader<,,> combination remains available.
using System;
using System.Threading;
using System.Threading.Tasks;
using Mediator;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Trellis;
using Trellis.Authorization;
using Trellis.Mediator;
public sealed record Order(Guid Id, string OwnerId);
public interface IOrderRepository
{
Task<Result<Order>> GetByIdAsync(Guid id, CancellationToken cancellationToken);
}
public sealed class OrderResourceLoader(IOrderRepository repository)
: SharedResourceLoaderById<Order, Guid>
{
public override Task<Result<Order>> GetByIdAsync(Guid id, CancellationToken cancellationToken) =>
repository.GetByIdAsync(id, cancellationToken);
}
public sealed record CancelOrderCommand(Guid OrderId)
: ICommand<Result<Unit>>, IAuthorizeResource<Order>, IIdentifyResource<Order, Guid>
{
public Guid GetResourceId() => OrderId;
public IResult Authorize(Actor actor, Order resource) =>
actor.IsOwner(resource.OwnerId)
? Result.Ok()
: Result.Fail(new Error.Forbidden("orders.cancel") { Detail = "Only the owner can cancel this order." });
}
public static class Composition
{
public static void Configure(WebApplicationBuilder builder)
{
builder.Services.AddMediator(opts => opts.ServiceLifetime = ServiceLifetime.Scoped);
builder.Services.AddTrellisBehaviors();
builder.Services.AddScoped<SharedResourceLoaderById<Order, Guid>, OrderResourceLoader>();
// Explicit (AOT/trimming friendly):
builder.Services.AddSharedResourceAuthorization<CancelOrderCommand, Order, Guid, Result<Unit>>();
// Equivalent via assembly scan (not AOT-friendly):
// builder.Services.AddResourceAuthorization(typeof(CancelOrderCommand).Assembly);
}
}
Tip
Explicit IResourceLoader<TMessage, TResource> registrations always win over the shared-loader bridge.
Hide existence with AuthFailureExposurePolicy.HideAsNotFound
For sensitive resources, opt into HideAsNotFound to give missing, removed, and withheld resources the same public NotFound. Resource-stage root NotFound, Gone, Forbidden, and AuthenticationRequired normalize using the public type and request ID, without copying original code, detail, resource metadata, or cause. Both HideExistence forms accept optional fixed public code/detail:
builder.Services.AddTrellis(options => options
.UseResourceAuthorization<GetIncidentQuery, Incident, Result<IncidentDto>>()
.UseResourceAuthorization(o => o.HideExistence<Incident>(
code: "incident.not-found", detail: "Incident not found.")));
Parameterless HideExistence uses error.unspecified and the standard NotFound display message. The default policy is Propagate; DefaultExposurePolicy = HideAsNotFound enables service-wide hiding, while Propagate<TResource>() opts out individual resources. Each normalization emits private ExistenceHidden diagnostics with the original input kind/code.
Other direct-resource / via-leaf errors remain unchanged. Intermediate/owner load failures collapse to Forbidden("resource.authorization-via.load-failed") before normalization, so even owner Unavailable becomes a hidden NotFound. See Recipe 32.
Caveats.
AuthorizationBehaviorruns first. Commands implementing bothIAuthorizeandIAuthorizeResource<T>have static-permissionAuthenticationRequired/Forbiddensurfaced byAuthorizationBehavior— those are NOT translated, because the static-auth behavior has no concept of the resource. Commands needing existence-hiding to apply to anonymous probes must omitIAuthorize.- Cache safety. Use
Cache-Control: no-storeorprivateso a shared cache cannot serve an unauthorized actor's 404 to a later authorized actor. - Via commands key on the leaf.
HideExistence<Match>()coversIAuthorizeResourceVia<Team>+IIdentifyResource<Match,MatchId>and references the leaf, never the owner. - Boundary scope. Handler failures, aggregates, per-request trace IDs, application response customization, and timing are not normalized.
See cookbook Recipe 32 for the projection-loader overload, SIEM query examples, and the full worked example.
Validation
ValidationBehavior runs for every message and pulls violations from two sources.
| Source | Use it for |
|---|---|
IValidate.Validate() on the message |
Cross-field invariants and domain rules awkward to express as property checks. |
IEnumerable<IMessageValidator<TMessage>> from DI |
Property-level validation, FluentValidation adapter, or any custom validator package. |
Aggregation rules
- All
Error.InvalidInputfailures from both sources are merged into a singleError.InvalidInputwhoseFieldsandRulescollect every reported violation. The caller never gets "the first failure" — they get the full list in one round trip. - An
Error.InvalidInputwith emptyFieldsand emptyRulesstill short-circuits the handler. - A non-
Error.InvalidInputfailure (e.g.,Error.Conflict,Error.Forbidden) returned by any source short-circuits the stage immediately and is propagated as-is.
using System.Threading;
using System.Threading.Tasks;
using Mediator;
using Trellis;
using Trellis.Mediator;
public sealed record ArchiveDocumentCommand(string DocumentId, bool IsArchived)
: ICommand<Result<Unit>>, IValidate
{
public IResult Validate() =>
IsArchived
? Result.Ok()
: Result.Fail(new Error.Conflict(Resource: null, Code: "domain.violation") { Detail = "Only archived documents can be processed." });
}
public sealed class ArchiveDocumentHandler : ICommandHandler<ArchiveDocumentCommand, Result<Unit>>
{
public ValueTask<Result<Unit>> Handle(ArchiveDocumentCommand command, CancellationToken cancellationToken) =>
ValueTask.FromResult(Result.Ok());
}
Custom IMessageValidator<TMessage>
Implement IMessageValidator<TMessage> to plug an arbitrary async validator into the same stage as IValidate. Field-level violations should be wrapped in Error.InvalidInput so they aggregate with other validators' output.
using System.Threading;
using System.Threading.Tasks;
using Mediator;
using Microsoft.Extensions.DependencyInjection;
using Trellis;
using Trellis.Mediator;
public sealed record CreateUserCommand(string Email)
: ICommand<Result<Unit>>;
public interface IUserDirectory
{
Task<bool> IsEmailTakenAsync(string email, CancellationToken cancellationToken);
}
public sealed class UniqueEmailValidator(IUserDirectory directory)
: IMessageValidator<CreateUserCommand>
{
public async ValueTask<IResult> ValidateAsync(CreateUserCommand message, CancellationToken cancellationToken)
{
var taken = await directory.IsEmailTakenAsync(message.Email, cancellationToken).ConfigureAwait(false);
return taken
? Result.Fail(new Error.InvalidInput(EquatableArray.Create(
new FieldViolation(InputPointer.ForProperty(nameof(message.Email)), "email.taken") { Detail = "Email already in use." })))
: Result.Ok();
}
}
public static class Composition
{
public static void Register(IServiceCollection services) =>
services.AddScoped<IMessageValidator<CreateUserCommand>, UniqueEmailValidator>();
}
FluentValidation adapter
Add the optional Trellis.Mediator.FluentValidation package and call AddTrellisFluentValidation() to surface every registered IValidator<TMessage> through IMessageValidator<TMessage>. The adapter normalizes FluentValidation property paths (e.g., Lines[0].Memo) into RFC 6901 JSON Pointers (/lines/0/memo) so Error.InvalidInput.Fields has a consistent pointer shape regardless of which source produced each violation.
using System.Collections.Generic;
using FluentValidation;
using Mediator;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Trellis;
using Trellis.Mediator;
using Trellis.Mediator.FluentValidation;
public sealed record TransferLine(string TargetAccount, decimal Amount, string? Memo);
public sealed record SubmitBatchTransfersCommand(string SourceAccount, IReadOnlyList<TransferLine> Lines)
: ICommand<Result<Unit>>;
public sealed class SubmitBatchTransfersValidator : AbstractValidator<SubmitBatchTransfersCommand>
{
public SubmitBatchTransfersValidator()
{
RuleFor(x => x.SourceAccount).NotEmpty();
RuleForEach(x => x.Lines).ChildRules(line =>
{
line.RuleFor(l => l.TargetAccount).NotEmpty();
line.RuleFor(l => l.Amount).GreaterThan(0);
});
}
}
public static class Composition
{
public static void Configure(WebApplicationBuilder builder)
{
builder.Services.AddMediator(opts => opts.ServiceLifetime = ServiceLifetime.Scoped);
builder.Services.AddTrellisBehaviors();
builder.Services.AddTrellisFluentValidation();
builder.Services.AddScoped<IValidator<SubmitBatchTransfersCommand>, SubmitBatchTransfersValidator>();
}
}
See FluentValidation Integration for the AOT vs. assembly-scanning registration overloads.
Domain event dispatch
DomainEventDispatchBehavior<TMessage, TResponse> (registered by AddDomainEventDispatch(...)) closes the loop between the domain layer's DomainEvents.Add(...) calls and the outside world. It runs as an inner pipeline behavior — after the handler returns a successful response — and fans out the events the aggregate accumulated to every registered IDomainEventHandler<TEvent>. Dispatch is strict single-wave snapshot dispatch: the behavior snapshots the aggregate's UncommittedEvents() once, publishes only that snapshot, and throws DomainEventHandlerCascadedException if the pending-event list at the end of dispatch differs from the entry snapshot (length + reference equality — covers handlers that raise new events, clear via AcceptChanges, replace, or reorder). When Trellis.EntityFrameworkCore is also wired up, the behavior sits outside TransactionalCommandBehavior in the pipeline, so handlers see the post-commit state and a transaction failure suppresses dispatch automatically.
What gets dispatched, and when
Nested command dispatch is owned by the actual outer commit. Successful inner aggregate responses are retained without publishing or clearing until that owner commits and returns success, including when the owner returns DTO/Unit. A failure or exception at the owning boundary discards the batch without clearing aggregate events. Successful grandchildren belong directly to the actual owner, so an ignored intermediate failure does not lose their events when the owner deliberately commits their staged work.
| Aspect | Behavior |
|---|---|
| Message types covered | ICommand<TResponse> only — queries with the same response shape are skipped at the type-constraint level. |
| Aggregate discovery | Successful IResult<TAggregate> responses where TAggregate : IAggregate contribute aggregates. An outer DTO/Unit contributes none itself but can release nested aggregate responses after its owning commit. Custom envelopes also work — see Custom envelope response types. |
| When events fire | After the handler returns a successful response, before the response is returned up the pipeline. With AddTrellisUnitOfWork<TContext>() registered, that means after the transaction commits. |
| Failure path | If the handler returns Result.Fail, no events are dispatched and the aggregate retains them. |
| Per-event ordering | Events are dispatched sequentially in the order the aggregate raised them. |
| Multiple handlers per event | Each IDomainEventHandler<TEvent> registered for the runtime event type runs in turn (registration order). One handler's failure does not stop the next from running — see "Handler exceptions" below. |
| Cascade detection | Handler-raised events are not dispatched. If post-dispatch validation finds any new events on the aggregate, dispatch throws DomainEventHandlerCascadedException and does not call AcceptChanges(). Handlers must be side-effect-only. |
| Cancellation | Not cancellable. Dispatch runs after the transaction commits, so the caller's cancellationToken is not observed and handlers receive CancellationToken.None. A disconnect mid-fan-out cannot strand a durable write with a partially published event set. |
Warning
Post-commit throw caveat. With AddTrellisUnitOfWork<TContext>() registered, the database commit is already durable before dispatch starts. If cascade detection throws, the request returns a failure-shaped response even though the write committed; a client retry may encounter "already committed" semantics. Durable at-least-once delivery requires the transactional outbox, which captures the event in the same transaction as the write and re-dispatches it after the commit.
Registration
Three registration shapes; pick by composition style.
// 1. AOT/trim-friendly: register each handler explicitly. Implies AddDomainEventDispatch().
services.AddDomainEventHandler<UserRegistered, SendWelcomeEmailHandler>();
services.AddDomainEventHandler<UserRegistered, ProvisionTenantHandler>();
// 2. Assembly scanning: discovers every concrete IDomainEventHandler<TEvent> in the listed assemblies.
// Carries [RequiresUnreferencedCode] / [RequiresDynamicCode] — not for AOT.
services.AddDomainEventDispatch(typeof(SendWelcomeEmailHandler).Assembly);
// 3. Service-defaults builder (Trellis.ServiceDefaults). Order-safe with the other Use* slots.
builder.Services.AddTrellis(trellis => trellis
.UseEntraActorProvider()
.UseDomainEvents(typeof(SendWelcomeEmailHandler).Assembly)
.UseEntityFrameworkUnitOfWork<AppDbContext>());
AddDomainEventDispatch() is idempotent — calling it more than once registers the behavior and the default IDomainEventPublisher exactly once. Both the per-handler overload and the assembly-scan overload call it for you.
Handler shape
using System.Threading;
using System.Threading.Tasks;
using Trellis;
public sealed record UserRegistered(UserId UserId, DateTimeOffset OccurredAt) : IDomainEvent;
public sealed class SendWelcomeEmailHandler : IDomainEventHandler<UserRegistered>
{
private readonly IEmailSender _email;
public SendWelcomeEmailHandler(IEmailSender email) => _email = email;
public ValueTask HandleAsync(UserRegistered domainEvent, CancellationToken cancellationToken) =>
_email.SendWelcomeAsync(domainEvent.UserId, cancellationToken);
}
Handlers are registered as scoped services (one instance per request). Inject side-effect services — HttpClient factories, message-bus producers, mailers, projection writers — directly through the constructor.
Handlers must stay side-effect-only. Do not mutate the source aggregate, mutate another aggregate, raise a new domain event, or send a nested Mediator command from inside the dispatch loop. If a side effect needs more domain mutation, issue a follow-up command from the application layer after the originating command completes, or enqueue post-commit work that runs as a separate top-level command.
Handler exceptions
MediatorDomainEventPublisher (the default implementation) treats handler failures defensively:
- Non-cancellation exceptions thrown by a handler are logged at
Errorlevel and swallowed; the publisher continues with the next handler so a single misbehaving handler does not block other side effects of the same event. OperationCanceledExceptionmatching the supplied cancellation token propagates so the originating request can abort cleanly.- No handler resolved for a given runtime event type is logged at
Debugand treated as a no-op.
Cascade detection does not change handler-exception semantics. A swallowed handler failure can still be followed by clean snapshot validation and AcceptChanges(), so the default publisher is best-effort, not durable retry. Durable at-least-once side effects require the transactional outbox.
Event-to-handler matching uses domainEvent.GetType() exactly. Handlers registered against a base class or interface of the runtime event type are not invoked — register one handler per concrete event type (or one type implementing multiple IDomainEventHandler<TEvent> interfaces, each of which is wired up separately).
Custom envelope response types
The dispatch behavior walks TResponse.GetInterfaces() looking for an IResult<TValue> where TValue : IAggregate. The common case (Result<TAggregate>) is detected directly; less common shapes also work:
// A non-generic envelope that exposes an aggregate-valued result. Both interfaces are required:
// IResult<Order> → so the dispatch behavior can extract the aggregate
// IFailureFactory<TSelf> → so failure-projecting behaviors (e.g. ResourceAuthorizationBehavior)
// can construct a failure of this envelope type
public sealed class OrderEnvelope : IResult<Order>, IFailureFactory<OrderEnvelope>
{
private readonly Result<Order> _inner;
public OrderEnvelope(Result<Order> inner) => _inner = inner;
public bool IsSuccess => _inner.IsSuccess;
public bool IsFailure => _inner.IsFailure;
public Error? Error => _inner.Error;
public bool TryGetValue(out Order value) => _inner.TryGetValue(out value!);
public bool TryGetError(out Error? error) => _inner.TryGetError(out error);
public static OrderEnvelope CreateFailure(Error error) => new(Result.Fail<Order>(error));
}
Important
If a message implements IAuthorizeResource<TResource>, its TResponse must satisfy both IResult and IFailureFactory<TResponse>. Result<T> does both automatically. AddResourceAuthorization<TMessage, TResource, TResponse>() (and the assembly-scanning overload) fails fast at registration with InvalidOperationException if either interface is missing — the security-marked command will not silently ship without resource authorization.
Other response shapes pass through the dispatch behavior untouched:
TResponse |
Effect |
|---|---|
Result<TAggregate> where TAggregate : IAggregate |
Events extracted and dispatched. |
Custom type implementing IResult<TAggregate> (envelope) |
Same as above. |
Result<Unit>, Result<string>, Result<TDto> |
Contributes no aggregate itself, but an owning outer command still releases its successful nested aggregate responses. |
Result<(A, B)> (tuple) |
Same: no aggregate extracted from this response; successful nested aggregate responses are retained. |
Custom type with two distinct IResult<TAggregate1> / IResult<TAggregate2> interfaces |
Fails fast at startup with InvalidOperationException — the behavior cannot disambiguate which aggregate's events to dispatch. |
When a non-aggregate response has no nested aggregate responses to dispatch, use tracked-aggregate dispatch or explicit manual dispatch after the actual commit. Prefer Result<TAggregate> where it accurately expresses the command outcome.
Dispatching events from non-aggregate response shapes (post-commit safe)
For the manual case the cleanest entry point is IDomainEventPublisher.DispatchAggregateEventsAsync(aggregate) — an extension method on the publisher that uses the same strict snapshot contract as DomainEventDispatchBehavior<,> (same non-cancellable contract, same cascade exception, same final AcceptChanges() only after clean validation) so you do not have to re-implement the edge cases.
Warning
POST-COMMIT ONLY. The helper publishes events immediately. If you call it inside a handler whose commit is run by TransactionalCommandBehavior (i.e., the handler is chained behind AddTrellisUnitOfWork<TContext>()), the events fire before the database transaction commits — and if the commit then fails, the handlers have already observed state that was rolled back. AcceptChanges() has cleared the events off the aggregate, so the failure is non-replayable.
Wrong shape — helper called inside a transactional handler:
// AddTrellisUnitOfWork<AppDbContext>() is wired; TransactionalCommandBehavior owns the commit.
public async ValueTask<Result<MarkReadDto>> Handle(
MarkInboxReadCommand cmd,
CancellationToken cancellationToken)
{
var inbox = await _db.Inboxes.FirstAsync(i => i.Id == cmd.Id, cancellationToken).ConfigureAwait(false);
inbox.MarkRead(_clock.UtcNow); // raises InboxRead event
// ⚠️ Events fire NOW, before TransactionalCommandBehavior commits. If the commit fails after
// this handler returns, handlers have already seen InboxRead — and AcceptChanges() has erased
// it from the aggregate so nothing on the next retry will re-raise it.
await _publisher.DispatchAggregateEventsAsync(inbox, cancellationToken).ConfigureAwait(false);
return Result.Ok(new MarkReadDto(inbox.Id, inbox.ReadAt!.Value));
}
Right shape 1 — handler owns its commit, helper runs after it succeeds:
// No AddTrellisUnitOfWork<>() chain. The handler is responsible for saving and dispatching.
public async ValueTask<Result<MarkReadDto>> Handle(
MarkInboxReadCommand cmd,
CancellationToken cancellationToken)
{
var inbox = await _db.Inboxes.FirstAsync(i => i.Id == cmd.Id, cancellationToken).ConfigureAwait(false);
inbox.MarkRead(_clock.UtcNow);
await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
// Commit is durable. Safe to publish. If cancellation or cascade detection throws,
// AcceptChanges() will NOT have run; handlers must still be idempotent because some
// snapshot events may already have fired.
await _publisher.DispatchAggregateEventsAsync(inbox, cancellationToken).ConfigureAwait(false);
return Result.Ok(new MarkReadDto(inbox.Id, inbox.ReadAt!.Value));
}
Right shape 2 — BackgroundService tick after SaveChangesAsync:
public sealed class InboxCleanupWorker(
IServiceScopeFactory scopes,
TimeProvider clock,
ILogger<InboxCleanupWorker> log) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
await using var scope = scopes.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var publisher = scope.ServiceProvider.GetRequiredService<IDomainEventPublisher>();
var stale = await db.Inboxes
.Where(i => i.ReadAt == null && i.CreatedAt < clock.GetUtcNow().AddDays(-30))
.ToListAsync(stoppingToken)
.ConfigureAwait(false);
foreach (var inbox in stale)
inbox.Discard(clock.GetUtcNow()); // raises InboxDiscarded
await db.SaveChangesAsync(stoppingToken).ConfigureAwait(false);
// Commit is durable; now drain each aggregate's events.
foreach (var inbox in stale)
await publisher.DispatchAggregateEventsAsync(inbox, stoppingToken).ConfigureAwait(false);
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken).ConfigureAwait(false);
}
}
}
Failure / cancellation contract:
- Not cancellable. The
cancellationTokenparameter is retained for source compatibility but is not observed, and handlers receiveCancellationToken.None. The helper is post-commit only, so aborting mid-fan-out would strand an already-durable write with a partially published event set. - On
DomainEventHandlerCascadedException,AcceptChanges()is not called and the original plus cascaded events stay on the aggregate so the caller can inspect them. Domain event handlers must not raise new events on the same aggregate they were given; if you have a cascade requirement, model it with a separate top-level command after the original command completes. - On an exception thrown by an event handler, behavior follows the publisher's contract. The default
MediatorDomainEventPublisherlogs and swallows non-cancellation handler exceptions (same asDomainEventDispatchBehavior<,>), so the helper continues dispatching the remaining snapshot events and reachesAcceptChanges()after clean cascade validation. If you supply a customIDomainEventPublisherthat propagates handler exceptions, the helper rethrows andAcceptChanges()is not called. Use idempotent handlers and an outbox for durable downstream retries; the helper is not a retry buffer. - Re-entrant calls on the same aggregate are not supported. Do not call
DispatchAggregateEventsAsyncfrom inside anIDomainEventHandler<TEvent>that is currently draining the same aggregate: the nested call creates a second snapshot before the outer dispatch has validated or cleared the queue. Treat domain event handlers as side-effect-only and let exactly one outer call own the drain.
Note
An opt-in pipeline behavior — TrackedAggregateDomainEventDispatchBehavior — covers the common case where you want post-commit dispatch against aggregates the handler tracks via the EF change tracker (instead of returning them through Result<TAggregate>). See Auto-dispatching from outcome-DTO commands below.
Auto-dispatching from outcome-DTO commands (opt-in tracked behavior)
DomainEventDispatchBehavior<,> discovers aggregates from successful IResult<TAggregate> responses, including nested commands. It does not discover other aggregates directly mutated by a DTO-returning handler. For that case, TrackedAggregateDomainEventDispatchBehavior<,> reads the owning commit's tracked aggregate snapshot and dispatches automatically regardless of response shape. Deferred inner commands do not read stale committed snapshots or publish/clear events.
The behavior is opt-in and mutually exclusive with DomainEventDispatchBehavior<,> — pick one model per host.
// Outcome-DTO command. Handler returns Result<MarkReadDto>, not Result<Inbox>.
public sealed record MarkInboxReadCommand(Guid Id) : ICommand<Result<MarkReadDto>>;
public sealed record MarkReadDto(Guid Id, DateTimeOffset ReadAt);
public sealed class MarkInboxReadHandler(AppDbContext db, TimeProvider clock)
: ICommandHandler<MarkInboxReadCommand, Result<MarkReadDto>>
{
public async ValueTask<Result<MarkReadDto>> Handle(
MarkInboxReadCommand cmd,
CancellationToken cancellationToken)
{
var inbox = await db.Inboxes.FirstAsync(i => i.Id == cmd.Id, cancellationToken).ConfigureAwait(false);
inbox.MarkRead(clock.GetUtcNow()); // raises InboxRead — staged on the aggregate
// No SaveChangesAsync or DispatchAggregateEventsAsync needed:
// TransactionalCommandBehavior commits, then TrackedAggregateDomainEventDispatchBehavior
// reads ITrackedAggregateSource.CommittedAggregates and drains events from each.
return Result.Ok(new MarkReadDto(inbox.Id, inbox.ReadAt!.Value));
}
}
Registration via the TrellisServiceBuilder:
services.AddTrellis(t => t
.UseEntityFrameworkUnitOfWork<AppDbContext>()
.UseTrackedAggregateDomainEvents(typeof(MarkInboxReadHandler).Assembly));
Or via DI extensions directly:
services.AddTrackedAggregateDomainEventDispatch();
services.AddTrellisUnitOfWork<AppDbContext>();
services.AddDomainEventHandler<InboxRead, SendReadReceiptHandler>();
How it composes with the rest of the pipeline
- The tracked behavior sits at the same slot as
DomainEventDispatchBehavior<,>— just outsideTransactionalCommandBehavior, so dispatch runs after commit. Pipeline order is enforced byAddTrackedAggregateDomainEventDispatch()regardless of registration order withAddTrellisUnitOfWork<>(). - After the inner pipeline returns
IsSuccess, the behavior readsITrackedAggregateSource.CommittedAggregates(the snapshot the unit-of-work captured at commit time), snapshots each aggregate'sUncommittedEvents(), publishes only those snapshots, and then validates every snapshot aggregate before clearing anything. - Same-aggregate and cross-aggregate cascades both throw
DomainEventHandlerCascadedException. If dispatching aggregate A's event causes a handler to append events to aggregate B that was also in the committed snapshot, B is listed inOffenders; no aggregate events are cleared on throw. - The unit-of-work snapshot is cleared before save and only repopulated on success. A failed commit (or a thrown save) leaves it empty, so a subsequent successful commit never auto-dispatches events from a previously-failed handler.
Result.FailAfterCommit<T>(error)(a failure whoseIPersistOnFailure.PersistOnFailureflag is set) commits the staged state, but the tracked behavior skips dispatch because the result is a failure — the worker pattern in Persisting failure state from a worker handler continues to work as documented.- Re-entrant calls (a handler schedules another command via
IMediator.Send) are blocked by theTrackedAggregateDispatchReentrancyGuard, so the nested invocation of the tracked behavior is skipped for every closed-generic behavior shape. A nested command sent from inside a domain-event handler can leave its own aggregate events stranded. Queue follow-up commands from the application layer after the originating command completes instead.
When to pick which model
| Handler shape | Recommendation |
|---|---|
Returns Result<TAggregate> for one aggregate per command |
AddDomainEventDispatch() (the response-shape behavior). Most precise — the response identifies the aggregate. |
Returns an outcome DTO (Result<DoneDto>, Result<Unit>, Result<(A, B)>) and mutates one or more aggregates via the EF change tracker |
AddTrackedAggregateDomainEventDispatch(). The unit-of-work tells the behavior which aggregates committed. |
Hand-rolled BackgroundService tick without TransactionalCommandBehavior |
IDomainEventPublisher.DispatchAggregateEventsAsync(aggregate, ct) after your own SaveChangesAsync. |
Custom IUnitOfWork that does not implement ITrackedAggregateSource |
Throws at first resolve. Either implement the interface on your unit-of-work or use the response-shape model. |
Note
AddTrackedAggregateDomainEventDispatch() and AddDomainEventDispatch() are mutually exclusive. The tracked extension removes any prior response-shape registration, and AddDomainEventDispatch() short-circuits if the tracked behavior is already registered. The TrellisServiceBuilder slots UseDomainEvents(...) and UseTrackedAggregateDomainEvents(...) throw InvalidOperationException if you call both.
Persisting failure state from a worker handler
A worker handler often needs to mark a domain record as permanently_failed and return failure to the caller (so retries stop, alerts fire, the outbox doesn't redrive). The naive shape — handler stages the state change then returns Result.Fail<T>(error) — loses the persisted state because TransactionalCommandBehavior rolls back on failure.
Result.FailAfterCommit<T>(error) solves this. It is still a failure (IsFailure == true, dispatch is skipped, callers see the error), but it carries an IPersistOnFailure.PersistOnFailure flag that TransactionalCommandBehavior reads to decide whether to commit:
public async ValueTask<Result<Reminder>> Handle(
SendReminderCommand cmd,
CancellationToken cancellationToken)
{
var reminder = await _repo.GetAsync(cmd.ReminderId, cancellationToken).ConfigureAwait(false);
if (reminder is null)
return Result.Fail<Reminder>(new Error.NotFound(ResourceRef.For<Reminder>(cmd.ReminderId)));
var gatewayResult = await _gateway.SendAsync(reminder, cancellationToken).ConfigureAwait(false);
if (gatewayResult.IsSuccess)
{
reminder.MarkSent(_clock.UtcNow);
return Result.Ok(reminder);
}
// Fail-fast (e.g., AuthenticationRequired) → ordinary failure; nothing persists,
// and the caller halts the batch instead of treating the item as permanently failed.
if (gatewayResult.Error.IsFailFast())
return Result.Fail<Reminder>(gatewayResult.Error);
// Transient → ordinary failure: nothing persists, retry will re-enter the handler.
if (gatewayResult.Error.IsTransient())
return Result.Fail<Reminder>(gatewayResult.Error);
// Permanent failure → mark the row and persist that decision alongside the failure outcome.
// Without FailAfterCommit, MarkPermanentlyFailed's tracked changes roll back and the next
// tick re-enters this handler against the same row.
reminder.MarkPermanentlyFailed(gatewayResult.Error.Code, _clock.UtcNow);
return Result.FailAfterCommit<Reminder>(gatewayResult.Error);
}
The ErrorRetryExtensions helpers in the Trellis namespace (Error.IsTransient(), IsPermanent(), IsFailFast(), Classify()) are the canonical worker-side retry classifier. They cover Error.Unavailable and Error.RateLimited as transient, fold in Error.Unexpected (so an unanticipated exception in a downstream gateway doesn't permanently park the message), separate Error.AuthenticationRequired as fail-fast (halt the batch — not the same as a per-item permanent failure), and handle Error.Aggregate with max-severity semantics. The hand-written is Error.Unavailable or Error.RateLimited switch is incomplete on every axis and should not be repeated.
Pipeline behavior at this point:
TransactionalCommandBehaviorseesresult is IPersistOnFailure { PersistOnFailure: true }and runsCommitAsync. If the commit itself fails (e.g., DB unavailable), that commit error replaces the handler's gateway error in the returned response — there is no partial commit.DomainEventDispatchBehaviorskips the failed response and discards its pending dispatch batch without clearing aggregate events. Without outbox capture, those events remain in memory and are not a durable retry buffer. With the outbox interceptor, the successful persist-on-failure save captures and clears them, and the relay later delivers them despite the failed response.- Callers (worker tick loop, outbox processor, HTTP controller) see a plain failure result and react accordingly (log + alert + don't retry).
Guidance:
- Only the permanent branch uses
FailAfterCommit. Transient errors must stay asResult.Fail<T>(...)so the retry path re-runs against fresh state. Result.FailAfterCommit<T>(error)andResult.Fail<T>(error)with the sameErrorare not equal — equality discriminates on the per-instancePersistOnFailureflag.IFailureFactory<Result<T>>.CreateFailure(error)(the constructor used by upstream behaviors when projecting a failure into the response type) deliberately produces a plainResult.Fail<T>(error), not aFailAfterCommit. This prevents a commit-error from looping back into another commit attempt.
Exception safety net
ExceptionBehavior is the outermost behavior. It:
- Catches every unhandled exception except
OperationCanceledException(which propagates so cancellation flows correctly). - Logs the exception, then returns
TResponse.CreateFailure(new Error.Unexpected(FaultCodes.UnhandledException, faultId) { Detail = "An unexpected error occurred while processing the request." }).
The generated "N"-format Guid is passed as FaultId (the Code stays the stable unhandled-exception), so operators can join the failed response to the logged stack trace without giving every incident its own code.
Warning
Don't use exceptions for expected business outcomes — return Result<T> failures instead and let ExceptionBehavior handle only true surprises.
Telemetry
TracingBehavior opens an Activity per message under the activity source "Trellis.Mediator" (also exposed as the constant TracingBehavior<,>.ActivitySourceName). Add it to your OpenTelemetry tracing config or you will get no spans:
using Microsoft.Extensions.DependencyInjection;
using OpenTelemetry.Trace;
using Trellis.Mediator;
builder.Services.AddOpenTelemetry().WithTracing(tracing =>
tracing.AddTrellisMediatorInstrumentation());
On a failed result, both LoggingBehavior and TracingBehavior always emit:
Error.Code(operator-defined identifier, e.g.,"orders.cancel").- The stable
Errortype name (e.g.,Error.Forbidden) on the activity aserror.type.
LoggingBehavior writes Debug on success and Warning on failure; TracingBehavior sets ActivityStatusCode.Error on failed results and non-cancellation exceptions, records standard exception-event tags when a handler throws, and leaves request-token cancellations at the default Unset status. Per-call timing is at Debug to keep production logs quiet at the default Information minimum; raise via "Trellis.Mediator": "Debug" in logging configuration to surface every dispatch.
Telemetry redaction
The free-text Error.Detail string is redacted by default because it is frequently composed from user input or domain payloads (an order id, an email, a free-text validation message) and must not flow into log aggregators or distributed traces without explicit opt-in.
To opt in (typically development only, or environments verified PII-free):
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Trellis.Mediator;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddMediator(opts => opts.ServiceLifetime = ServiceLifetime.Scoped);
builder.Services.AddTrellisBehaviors(options => options.IncludeErrorDetail = true);
The error.code tag and the Error.Code value are operator-defined identifiers and are always emitted regardless of this setting.
Composition
Resource authorization, FluentValidation, and the EF Core unit-of-work behavior compose into one pipeline. Register Trellis behaviors first, then any extension validators, then the actor provider, and finally AddTrellisUnitOfWork<TContext>() so the transactional behavior lands innermost (closest to the handler) and commit failures stay visible to outer logging/tracing.
using Mediator;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Trellis.Asp.Authorization;
using Trellis.EntityFrameworkCore;
using Trellis.Mediator;
using Trellis.Mediator.FluentValidation;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddMediator(opts => opts.ServiceLifetime = ServiceLifetime.Scoped);
builder.Services.AddTrellisBehaviors();
builder.Services.AddTrellisFluentValidation();
builder.Services.AddResourceAuthorization(typeof(Program).Assembly);
if (builder.Environment.IsDevelopment())
builder.Services.AddDevelopmentActorProvider();
else
builder.Services.AddEntraActorProvider();
builder.Services.AddTrellisUnitOfWork<AppDbContext>();
var app = builder.Build();
app.Run();
Practical guidance
- Use the
ScopedMediator lifetime. All Trellis behaviors depend on per-request services;Singleton(the Mediator default) fails the root-scope check on first request. IAuthorizefor coarse gates,IAuthorizeResource<T>for fine rules. Static permissions (documents:edit) belong onIAuthorize; ownership / tenancy / state rules belong onIAuthorizeResource<T>.- Prefer shared resource loaders. Register one
SharedResourceLoaderById<TResource, TId>per resource and let messages implementIIdentifyResource<,>— avoids one loader class per command. - Don't forget
AddResourceAuthorization(...). ImplementingIAuthorizeResource<T>is not enough; the behavior must be registered or it never runs. - Keep
IValidate.Validate()synchronous and cheap. It runs on every request. Push I/O-bound checks into anIMessageValidator<TMessage>(which is async) or into the handler. - Return
Result<Unit>from commands (not bareResult), and never throw for expected business outcomes —ExceptionBehavioris for surprises only. - Leave
IncludeErrorDetail = falsein production.Error.Detailis free text and may contain PII. - Add the
"Trellis.Mediator"activity source to your OpenTelemetry config or you will not see mediator spans.
Cross-references
- API surface:
trellis-api-mediator.md Result<T>,Maybe<T>,Errorsemantics:trellis-api-core.md- Authorization primitives (
Actor,IAuthorize,IAuthorizeResource, loaders):trellis-api-authorization.md - FluentValidation integration article: integration-fluentvalidation.md
- ASP.NET integration (
IActorProviderwiring): integration-asp-authorization.md - Observability article: integration-observability.md