Bsft.Mediator
A CQRS dispatcher: commands, queries and notifications are objects, handlers are resolved from the container, and cross-cutting concerns are pipeline behaviors wrapped around the handler rather than code repeated in it.
Two packages:
| Package | Contents |
|---|---|
Bsft.Mediator.Abstractions |
The contracts only — IMediator, ICommand, IQuery, INotification, their handlers, IPipelineBehavior<,>, IRequestMetadataProvider, [Transactional]. |
Bsft.Mediator |
The dispatcher, the built-in behaviors and the DI entry point. |
A module that only defines commands and handlers depends on the abstractions
package; only the host needs the implementation. Since 1.3.5 the contracts live
in namespace Bsft.Mediator.Abstractions, matching the assembly they ship
in; they used to share Bsft.Mediator.Abstractions with the implementation package, and
two assemblies never share a namespace. The dispatcher and the four shipped
behaviors are in Bsft.Mediator.Services, and MediatorConfiguration and
AddBsftMediator are in Bsft.Mediator.
Contracts
public interface ICommand<TResult>;
public interface ICommand : ICommand<Unit>;
public interface IQuery<TResult>;
public interface INotification;
public interface ICommandHandler<in TCommand, TResult> where TCommand : ICommand<TResult>
{
Task<TResult> HandleAsync(TCommand command, CancellationToken ct = default);
}
public interface ICommandHandler<in TCommand> where TCommand : ICommand
{
Task HandleAsync(TCommand command, CancellationToken ct = default);
}
public interface IQueryHandler<in TQuery, TResult> where TQuery : IQuery<TResult>
{
Task<TResult> HandleAsync(TQuery query, CancellationToken ct = default);
}
public interface INotificationHandler<in TNotification> where TNotification : INotification
{
Task HandleAsync(TNotification notification, CancellationToken ct = default);
}
The dispatcher itself:
public interface IMediator
{
Task<TResult> SendAsync<TResult>(ICommand<TResult> command, CancellationToken ct = default);
Task SendAsync(ICommand command, CancellationToken ct = default);
Task<TResult> QueryAsync<TResult>(IQuery<TResult> query, CancellationToken ct = default);
Task PublishAsync<TNotification>(TNotification notification, CancellationToken ct = default)
where TNotification : INotification;
}
Commands go through SendAsync, queries through QueryAsync. The split is not
decoration: only the command path is wrapped by the transaction behavior, and
the two are reported separately in telemetry.
Setup
services.AddBsftMediator(mediator => mediator
.RegisterServicesFromAssembly(typeof(CreateOrderHandler).Assembly)
.EnableTracing());
AddBsftMediator takes a required configure delegate — passing null
throws. It registers IMediator as scoped, the configuration as a singleton,
the telemetry emitter, and MediatorStartupValidator as an IStartupValidator.
MediatorConfiguration has exactly six methods:
| Method | Effect |
|---|---|
RegisterServicesFromAssembly(Assembly) |
Scans the assembly and registers every ICommandHandler<,>, ICommandHandler<>, IQueryHandler<,> and INotificationHandler<> implementation as transient. Callable more than once. |
AddBehavior(Type) |
Adds an open-generic pipeline behavior (a generic type definition implementing IPipelineBehavior<,>). Anything else is rejected at registration with an ArgumentException. |
ForwardNotifications() |
Also hands every notification to the registered INotificationForwarder after the local handlers, so aggregator subscribers and sagas see it. |
EnableTracing() |
Registers TracingBehavior<,> as the outermost behavior and emits spans and metrics. |
UseDefaults() |
Registers the canonical cross-cutting chain from whichever behavior packages the application references, in the order below. Implies EnableTracing(). |
SuppressDefaultBehavior(string) |
Drops one behavior from that chain. A name matching none throws. |
Pipeline behaviors
public interface IPipelineBehavior<TRequest, TResponse>
{
Task<TResponse> HandleAsync(TRequest request, Func<Task<TResponse>> next, CancellationToken ct = default);
}
Registration order is outermost-first. The dispatcher resolves all behaviors
and folds them around the handler in reverse, so the first one registered is the
first one entered and the last one left. When EnableTracing() is used, the
tracing behavior is registered ahead of every user behavior deliberately, so its
span encloses the whole chain regardless of the order the application asked for.
A behavior applies to every request dispatched under the static contract type and is therefore always an open generic. Register it either through the configuration or directly against the service type — both end at the same registration:
services.AddBsftMediator(m => m.AddBehavior(typeof(MyBehavior<,>)));
// equivalent:
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(MyBehavior<,>));
The direct form is what every framework module does — caching, retry, audit and
org enforcement all register their behavior this way from their own AddBsft*
call.
There is deliberately no per-concrete-request registration: the dispatcher
resolves IPipelineBehavior<TRequest, TResponse> for the static type it
dispatches under (ICommand<TResult> or IQuery<TResult>), never the concrete
request class, so a behavior decides inside HandleAsync which requests it acts
on.
Passing a type that is not an open-generic pipeline behavior throws at registration with a message naming the type, rather than being accepted and silently never invoked. Behaviors registered as themselves alone were never resolved by the dispatcher; that dead path is gone.
Which behaviors apply to a request
Behaviors are resolved by the static generic arguments at the dispatch site,
not by the runtime type of the request object. SendAsync<TResult>(ICommand<TResult> command)
resolves IPipelineBehavior<ICommand<TResult>, TResult>, so a behavior closed
over a concrete command type is not consulted on that path.
The practical rule: a behavior meant to apply broadly must be an open generic
registered against IPipelineBehavior<,>. Use a closed behavior only where the
call site is itself typed to that request. This is why every cross-cutting
concern in the framework ships as an open generic.
The module ships four:
| Behavior | Purpose |
|---|---|
LoggingBehavior<,> |
Logs start, duration and outcome. |
TracingBehavior<,> |
Span plus metrics; outermost when tracing is enabled. |
ValidationBehavior<,> |
Runs the registered IValidator<TRequest> and throws ValidationException. |
TransactionBehavior<,> |
Wraps handlers marked [Transactional] in a database transaction. |
Attribute-driven behaviors: the request metadata seam
Because the pipeline is closed over the declared contract, a behavior that acts on
an attribute has to read it off request.GetType(). That works, and it is what
every shipped attribute-driven behavior does — but the reflection is per dispatch,
so each of them grew a ConcurrentDictionary<Type, …> of its own to keep it off
the hot path. Consumers hit the same wall and wrote the same cache again, once per
behavior. A behavior that skipped the cache and reflected on every call paid for
it; one that read typeof(TRequest) instead silently did nothing at all, because
the type parameter is ICommand<TResult> and never the concrete request.
The framework supplies that cache. AddBsftMediator registers
IRequestMetadataProvider as a singleton, and any behavior can take it:
public interface IRequestMetadataProvider
{
IRequestMetadata GetMetadata(object request); // by instance
IRequestMetadata GetMetadata(Type requestType); // by type
}
public interface IRequestMetadata
{
Type RequestType { get; } // the concrete request class
TAttribute? FindAttribute<TAttribute>() where TAttribute : Attribute;
IReadOnlyList<TAttribute> ListAttributes<TAttribute>() where TAttribute : Attribute;
bool HasAttribute<TAttribute>() where TAttribute : Attribute;
}
Both live in Bsft.Mediator.Abstractions, so a behavior satellite takes the seam
without taking the dispatcher into its closure. The descriptor is built once per
request type — one reflection pass capturing every attribute, inherit: true, the
same way the shipped behaviors read theirs — and the same instance is handed to
every later caller. It is immutable, and the provider is safe to call from several
threads at once.
Before — the consumer's own cache, written once per behavior:
public sealed class UsageRecordingBehavior<TRequest, TResponse>(IUsageRecorder recorder)
: IPipelineBehavior<TRequest, TResponse>
{
// One of these per attribute-driven behavior in the application.
private static readonly ConcurrentDictionary<Type, UsageAttribute?> Cache = new();
public async Task<TResponse> HandleAsync(TRequest request, Func<Task<TResponse>> next, CancellationToken ct = default)
{
var usage = Cache.GetOrAdd(
request!.GetType(),
static type => type.GetCustomAttribute<UsageAttribute>(inherit: true));
var response = await next().ConfigureAwait(false);
if (usage is not null)
await recorder.RecordAsync(usage.Feature, ct).ConfigureAwait(false);
return response;
}
}
After — the cache is deleted, and with it the ConcurrentDictionary, the
static field and the System.Reflection using:
public sealed class UsageRecordingBehavior<TRequest, TResponse>(
IRequestMetadataProvider metadata,
IUsageRecorder recorder)
: IPipelineBehavior<TRequest, TResponse>
{
public async Task<TResponse> HandleAsync(TRequest request, Func<Task<TResponse>> next, CancellationToken ct = default)
{
var usage = metadata.GetMetadata(request!).GetAttribute<UsageAttribute>();
var response = await next().ConfigureAwait(false);
if (usage is not null)
await recorder.RecordAsync(usage.Feature, ct).ConfigureAwait(false);
return response;
}
}
The behavior stays registered against the marker interface exactly as before —
AddBehavior(typeof(UsageRecordingBehavior<,>)) — and metadata.RequestType is
the concrete command, not the contract it was dispatched under. Testing a behavior
that takes the seam needs no container: IRequestMetadata is an interface, so a
fake describing one request type is enough.
Two things the seam deliberately does not do. It is a cache, not a policy: the
registration is TryAddSingleton, so a host that supplies its own provider keeps
it — and RequestMetadataProvider is public and constructible, so that provider
can wrap the shipped one instead of reimplementing the reflection behind it. And
it does not change what a behavior is handed — the IPipelineBehavior<,>
signature is unchanged, and surfacing the concrete type through a context
parameter instead would have broken every behavior in every consumer to save a
call that request.GetType() already answers. What was missing was never access
to the type; it was somewhere to put the reflection result.
Constrained open-generic marker behaviors still never bind. A behavior
declared where TRequest : IAuditable is not resolved for
IPipelineBehavior<ICommand<TResult>, TResult>, because the dispatch type does
not satisfy the constraint, so it is registered and never invoked. That is a
property of the resolution, not something the metadata provider changes: express
the opt-in as an attribute or a marker interface read inside HandleAsync, the
way the shipped behaviors do.
The default chain
services.AddBsftMediator(mediator => mediator
.RegisterServicesFromAssembly(appAssembly)
.UseDefaults());
UseDefaults() registers the cross-cutting behaviors of every behavior package
the application references, in the order the framework fixes. A package that
is not referenced contributes nothing and the host writes no conditional for it.
Registering the package's own services stays the host's job — AddBsftCachingMediator
still has to be called — and a behavior whose package is referenced but never
registered is reported by the mediator startup validator, naming the call to
make, instead of failing on the first dispatch.
Ordering is no longer something each host has to get right by reading a warning. It is load-bearing, and each adjacency is there for a reason:
| Position | Behavior | Package | Why here |
|---|---|---|---|
| 1 | Tracing | Bsft.Mediator |
Outermost, so its span encloses every other behavior and the handler. |
| 2 | (correlation) | — | Reserved. No correlation behavior ships yet; correlation reaches the pipeline through TracingBehavior's ICorrelationIdAccessor. |
| 3 | Authorization (tenant, then org) | Bsft.Auth.Mediator, Bsft.Organization.Mediator |
Before validation, so an unauthorized caller learns nothing about the shape of the payload. |
| 4 | Validation | Bsft.Mediator |
After authorization, before anything is recorded or written. |
| 5 | (idempotency) | — | Reserved. No idempotency behavior ships yet. |
| 6 | Audit | Bsft.Auditing.Mediator |
Inside authorization, so a rejected call is not recorded as an attempted business action; outside the transaction, so the audit write is not rolled back with it. |
| 7 | Retry | Bsft.Resilience.Mediator |
Outside the transaction, so each attempt begins its own; inside audit and validation, so a retried request is validated and recorded once. |
| 8 | Transaction | Bsft.Mediator |
Inside retry, so an attempt that failed never inherits a transaction the previous failure aborted. |
| 9 | Caching | Bsft.Caching.Mediator |
Innermost, so it can never serve past authorization. |
| — | handler |
The order is applied when the pipeline is folded, not when the behaviors are
registered: a behavior package registers its own behavior from its own AddBsft*
call, which a host may make before or after AddBsftMediator, so registration
order is not something the defaults could control. Behaviors the host adds — via
AddBehavior or directly against IPipelineBehavior<,> — keep the position they
were registered in, around the default ones.
A retry gets a fresh transaction. The transaction is opened inside the retry
loop, so each attempt runs its own BEGIN/COMMIT/ROLLBACK — matching EF Core's own
rule that a manually demarcated transaction belongs inside the retried delegate,
not around it. That costs one extra round trip per retry, and it requires
something of the handler: a request marked both [Transactional] and
[Retryable] must be safe to run twice. [Retryable] is still only for
idempotent requests — retrying a command with side effects duplicates them — and
work committed by an earlier attempt cannot leak into the next one, because a
failed attempt is rolled back whole before the next begins. What it does not
protect is the scoped DbContext shared across attempts: EF Core's change
tracker is not cleared between attempts, so a handler that mutates tracked
entities and calls SaveChangesAsync should re-read what it writes rather than
rely on state a previous attempt left tracked. Bsft.Auditing.EFCore's
AuditOutboxInterceptor is the framework's own example of discarding
still-tracked entities before a retried save, scoped to the outbox entity only.
What does not re-run: authorization, validation and audit sit outside retry, so a retried request is authorized, validated and audited exactly once; caching sits inside, so a cache write happens on the successful attempt only.
Reordering these two is all or nothing. SuppressDefaultBehavior plus
AddBehavior does not do it: the canonical rank is resolved over every slot
regardless of suppression, on purpose — a behavior the host registers itself
still belongs in its canonical position — so a re-registered TransactionBehavior
or RetryBehavior is sorted straight back. That is what keeps a host from
breaking the order by accident, and it means a host that wants one transaction
spanning every attempt — the pre-1.3.8.6 arrangement — does not call
UseDefaults() at all and registers the whole chain itself.
Both authorization behaviors decide on the dispatched instance, not on the
type parameter: the pipeline is closed over the declared contract
(ICommand<TResult>), so a marker or attribute on the concrete command is
invisible to the type parameter. AuthorizationBehavior<,> reads
[TenantAuthorize] off request.GetType() and OrganizationAuthorizationBehavior<,>
tests the request for IRequireOrganizationPermission; an unmarked request passes
through, and both hold their slot ahead of validation.
AuthorizationBehavior<,> reads a second attribute with it:
[RequirePermission("credentials.manage")], from Bsft.Mediator.Abstractions.
It is enforced identically — the same requirement, the same user, the same
refusal when there is no HttpContext — and exists only so that a package which
maps no endpoints can name a permission without acquiring Bsft.Auth and, with
it, the ASP.NET Core shared framework. Use [TenantAuthorize] where the package
already has a web dependency; use [RequirePermission] where taking one would be
a deployment change for the package's consumers.
A declared permission nothing enforces
Both declarations are inert on their own. [RequirePermission],
[TenantAuthorize] and IRequireOrganizationPermission say what a caller must hold; the
behavior in the pipeline is what checks it, and registering the behavior is the
host's call. A host that dispatches such requests without registering the
behavior therefore has a mediator that works and permissions that do nothing.
The mediator reports that at startup, as a warning on the validator's result,
which AddBsftStartupValidation() logs at warning level:
AuthorizationCoverageValidator: 2 dispatched request type(s) declare a permission
that no registered pipeline behavior enforces: Contoso.Billing.RotateCredentialCommand,
Contoso.Billing.RevokeCredentialCommand. The declaration is decorative until
'Bsft.Auth.Mediator.AuthorizationBehavior`2, Bsft.Auth.Mediator' runs in the
mediator pipeline: those requests reach their handlers unchecked. Call
AddBsftAuthMediator() (next to UseDefaults(), or AddBehavior for a hand-wired
chain), or remove the declaration from those requests if another gate already
enforces it. The check reads registrations only: a behavior wired through a
factory delegate cannot be seen and is reported here even though it runs, and
pipeline order is not verified at all — in a hand-wired chain a short-circuiting
behavior ahead of authorization keeps this check quiet while enforcement never
runs. UseDefaults() is what guarantees the order.
A notification carrying such a declaration is reported separately, because no registration closes that gap: publishing goes straight to the handlers and never enters the pipeline, so the permission has to be enforced where the notification is raised, or in the handler, and the declaration removed.
The check runs whether or not UseDefaults() was called: a hand-wired pipeline
leaves the same hole, and a host that calls UseDefaults() without referencing
Bsft.Auth.Mediator gets a slot that resolves to nothing, so the
referenced-but-unregistered check above has nothing to report either. It is a
warning rather than a failure because registration stays opt-in — a permission
enforced by an endpoint policy alone is a legitimate deployment — so the
framework names what is unenforced and leaves the decision with the host.
Request types are named in full, namespace included. Two requests of the same
simple name in different namespaces are two gaps, and a warning naming only
RotateCredentialCommand would leave the reader guessing which one to fix.
What the check does and does not verify. It verifies registration, and
only where a registration names a type: the open-generic
AddTransient(typeof(IPipelineBehavior<,>), typeof(AuthorizationBehavior<,>)),
a closed-generic registration over a single request type, and a pre-built
instance are all recognised. A behavior handed to the container as a factory
delegate names no implementation type and cannot be seen, so such a host gets a
warning that describes no real gap — the warning says so itself rather than
leaving the reader to chase it.
It says nothing about order. A hand-wired chain in which a short-circuiting
behavior — caching, an early idempotency return — runs ahead of authorization
passes this check while enforcement never runs on the short-circuited path.
Verifying order is not attempted; ordering stays the host's responsibility, and
UseDefaults() is what guarantees it, which is the reason the canonical order is
stated by the framework rather than by each host.
The two enforcing behaviors are matched by name, exactly as the default chain
resolves them, which is what keeps Bsft.Mediator free of a reference to
Bsft.Auth and Bsft.Organization. A host enforcing these declarations with a
behavior of its own is therefore warned about as well; register the framework
behavior, or carry an own marker instead of the framework's attribute.
Escape hatches
Two, and both are deliberate:
// One behavior out, the rest in canonical order.
services.AddBsftMediator(m => m
.UseDefaults()
.SuppressDefaultBehavior(MediatorDefaults.Caching));
// The whole chain the host's own: omit UseDefaults() and registration order is
// the pipeline order, exactly as before 1.3.8.
services.AddBsftMediator(m => m
.AddBehavior(typeof(MyAuthorizationBehavior<,>))
.AddBehavior(typeof(MyCachingBehavior<,>)));
Names come from MediatorDefaults (Tracing, Authorization,
OrganizationAuthorization, Validation, Audit, Retry, Transaction, Caching). A
name matching no default behavior throws at registration rather than being
ignored — the mistake a silent no-op hides is a host that believes it disabled a
security behavior, or that one is still in place.
Registering the same behavior twice — EnableTracing() next to a hand-written
tracing registration, say — is reported by the startup validator too, instead of
running the behavior twice per request. See caching.md for what the
caching position protects.
Notifications bypass the pipeline
PublishAsync invokes the handlers directly; no behavior wraps them. Each
handler is isolated, and if any threw, the failures are rethrown together as a
single AggregateException. One failing subscriber therefore cannot stop the
others from running, and none of them can be silently swallowed.
Forwarding notifications to the event aggregator
A mediator notification reaches only its INotificationHandler<T>
implementations. To let a saga — or anything else subscribed at runtime — react
to the same notification, forward it:
services.AddBsftEventAggregator();
services.AddBsftMediator(mediator => mediator
.RegisterServicesFromAssembly(assembly)
.ForwardNotifications());
services.AddBsftMediatorEventAggregator(); // package Bsft.Mediator.EventAggregator
With this configured, PublishAsync runs the notification handlers first
and then hands the same instance to the registered INotificationForwarder,
whose event-aggregator bridge publishes it, so aggregator subscribers and saga
flows see it. A forwarding failure is folded into the same AggregateException
as handler failures. Without the configuration, the mediator and the aggregator
are independent and a saga never observes a mediator notification.
Forwarding needs a target: AddBsftMediatorEventAggregator() supplies the
bridge over the aggregator that AddBsftEventAggregator() registers. Enabling
ForwardNotifications() without a registered forwarder fails startup validation
and, failing that, throws from PublishAsync instead of dropping notifications
silently. See event-aggregator.md.
Transactions
[Transactional] is applied to the handler class, not to the request:
[Transactional]
public sealed class CreateOrderHandler(IOrderDbContext db) : ICommandHandler<CreateOrder, Guid>
{
public async Task<Guid> HandleAsync(CreateOrder command, CancellationToken ct = default)
{
...
}
}
TransactionalAttribute lives in Bsft.Mediator.Abstractions, so a module can
mark its handlers without referencing the implementation.
The behavior looks for the marked handler by the runtime type of the dispatched
request, cached per type, and recognises all three handler shapes
(ICommandHandler<,>, ICommandHandler<>, IQueryHandler<,>). Reading the type
parameter instead found nothing on the dispatch path, because the pipeline is closed
over ICommand<TResult> — the same shape of defect the two authorization behaviors
had. A transaction is only opened when a DbContext resolves from the container;
otherwise the behavior logs a warning and runs the handler unwrapped.
Resolving that DbContext needs no extra wiring: a plain
services.AddDbContext<AppDbContext>() is enough. AddDbContext<TContext> registers
only the concrete TContext, never the base DbContext service type, so the behavior
first tries the base type — for a host that registered it explicitly — and otherwise
falls back to the concrete context named by the registered DbContextOptions, whose
ContextType is TContext. The resolved target type is cached on the resolver,
which is an application singleton, keyed by the DbContextOptions service type, so the
ContextType reflection runs at most once per application in the common case for both
AddDbContext<T>() and AddDbContextPool<T>() — not once per request. A concurrent
cold-start may compute it more than once, but the result settles to a single cached entry.
Only the type is cached; the concrete
DbContext instance is still resolved from the request scope on every dispatch.
When more than one DbContext is registered, that fallback is ambiguous: EF Core's
TryAdd binds the non-generic DbContextOptions to whichever context was registered
first, so the behavior could open and commit a transaction on the wrong context while a
handler writing to another one stays unprotected. Rather than infer silently, a startup
validator fails fast the moment TransactionBehavior is in the pipeline and more than
one DbContext is registered with no base DbContext to disambiguate — the same way
UseDefaults() fixes behavior order and the coverage check reports an unenforced
permission. The single-context case resolves silently; a host with several contexts
disambiguates by registering the base DbContext explicitly
(services.AddScoped<DbContext>(sp => sp.GetRequiredService<AppDbContext>())), which
both names the context the transaction wraps and clears the startup failure.
With UseDefaults() the transaction is opened inside the retry behavior, so a
[Transactional] handler on a [Retryable] request gets one transaction per
attempt, not one shared across every attempt. See "The default chain" above.
Telemetry
With EnableTracing(), the module reports on the instrumentation source
Bsft.Mediator: bsft.mediator.request.duration, bsft.mediator.requests and
bsft.mediator.failures. The outcome tag separates success, expected_error
(a deterministic BsftException) and error, so an alert on genuine faults is
not drowned by ordinary validation failures. See
observability.md.
Example
public sealed record GetOrder(Guid OrderId) : IQuery<OrderDto>;
public sealed class GetOrderHandler(IOrderDbContext db) : IQueryHandler<GetOrder, OrderDto>
{
public async Task<OrderDto> HandleAsync(GetOrder query, CancellationToken ct = default)
{
var order = await db.Orders.FindAsync([query.OrderId], ct)
?? throw new NotFoundException("Order", query.OrderId);
return new OrderDto(order.Id, order.Total);
}
}
// in an endpoint
var dto = await mediator.QueryAsync(new GetOrder(id), ct);
samples/Bsft.Sample.WebApi wires the mediator across three assemblies and
forwards notifications into the event aggregator.
See also
- event-aggregator.md — where forwarded notifications land.
- caching.md, resilience.md,
Bsft.Auditing— behaviors contributed by other modules. - the getting-started guide — the minimal wiring.