Pipeline behaviors
A pipeline behavior wraps every Send. Mediant ships eleven of them in Mediant.Behaviors, each opted
into by an attribute on the request (a few are automatic), and each with a fixed position in the pipeline.
dotnet add package Mediant.Behaviorsbuilder.Services.AddMediantAllBehaviors();Order is explicit, not accidental
In MediatR, behaviors run in registration order — reorder two AddBehavior calls and your transaction
now commits inside your retry loop. Mediant assigns each behavior a number via IBehaviorOrder, so the
pipeline is the same no matter how you registered them.
| # | Behavior | Attribute | Order | What it does |
|---|---|---|---|---|
| 1 | Audit | [Auditable] | 100 | Async batched audit entries, store abstraction, sensitive-data masking |
| 2 | Logging | automatic | 200 | Structured logs, auto-masking, safe against circular references |
| 3 | UnhandledException | automatic | 300 | Catch-all safety net; logs, then always re-throws |
| 4 | Authorization | [Authorize] | 400 | Roles and policies, failures returned as a Result |
| 5 | Validation | automatic | 500 | FluentValidation, multi-validator, Result.ValidationFailure |
| 6 | Idempotency | [Idempotent] | 600 | Replay protection with per-key locking and a window |
| 7 | Transaction | [Transactional] | 700 | Commands only. Begin/commit/rollback via IUnitOfWork |
| 8 | Performance | [PerformanceThreshold] | 800 | Warn/critical thresholds, hard ceiling |
| 9 | Retry | [Retryable] | 900 | Exponential backoff with jitter |
| 10 | Caching | [Cacheable] | 1000 | Queries only. Stampede prevention via a bounded lock pool |
| 11 | Cache invalidation | [InvalidatesCache] | 1001 | Commands drop cache entries by key prefix |
Read that order top-down as “outermost first”. Authorization runs before validation — an unauthorized caller is rejected before you tell them their payload is malformed. Retry (900) sits inside Transaction (700), so a retried handler runs within the same transaction; caching (1000) is innermost, so a cache hit skips the handler but still gets audited and logged.
The attributes
[HttpEndpoint("POST", "/api/orders")][Auditable(IncludeResponseBody = true)][Authorize(Roles = "customer,admin")][Idempotent(windowSeconds: 600, KeyProperty = nameof(RequestId))][Transactional(TimeoutSeconds = 30)][Retryable(maxRetryCount: 3)][InvalidatesCache("orders:")]public record CreateOrder(string UserId, Guid RequestId) : ICommand<Result<Guid>>;[Auditable]
IncludeRequestBody (default true), IncludeResponseBody (default false), MaxResponseSize
(default 4096 bytes). Entries are buffered and flushed asynchronously through IAuditStore; a durable
EF Core store is available — see EF Core. Properties marked [SensitiveData] are masked.
[Authorize]
Roles (comma-separated) and/or Policy. A rejection becomes Error.Forbidden / Error.Unauthorized
in the Result — no exception.
[Idempotent]
windowSeconds (default 300). By default the key is a SHA-256 of the payload; set KeyProperty to use a
client-supplied key such as an idempotency header. With DetectPayloadMismatch = true, reusing a key with
a different payload is a conflict rather than a silent replay. Backed by IDistributedCache.
[Transactional]
Commands only — the analyzers reject it on a query. TimeoutSeconds defaults to 30. Nested Send calls
detect the outer scope and join it rather than opening a second transaction. SaveChangesAsync is called
for you before the commit. Requires an IUnitOfWork implementation.
[PerformanceThreshold]
WarningMs, CriticalMs, CeilingMs. Slow requests are logged with their timings.
[Retryable]
maxRetryCount (default 3), UseExponentialBackoff (default true, with jitter). Retries the handler,
not the whole pipeline.
[Cacheable]
Queries only. durationSeconds (default 300), optional CacheKeyPrefix. Concurrent misses on the same
key wait on one lock instead of all hitting the database.
[InvalidatesCache]
keyPrefix — the command clears everything under that prefix once it succeeds. Pair it with the prefix
you gave [Cacheable].
Configuration
builder.Services.AddMediantAllBehaviors(opts =>{ opts.ConfigureLogging = log => { log.MaskProperties.Add("CardNumber"); log.MaxSerializedLength = 4096; }; opts.ConfigurePerformance = perf => { perf.WarningThresholdMs = 500; perf.CriticalThresholdMs = 5000; };});Prefer to pick and choose? Register only the behaviors you want instead of calling
AddMediantAllBehaviors().
Your own behavior
public class TenantBehavior<TRequest, TResponse>(ITenantContext tenant) : IPipelineBehavior<TRequest, TResponse> where TRequest : IRequest<TResponse>{ public async ValueTask<TResponse> Handle( TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken ct) { using var _ = tenant.Scope(request); return await next(); }}Register it as an open generic — it wraps every request, and slots into the ordering:
builder.Services.AddMediant(cfg =>{ cfg.RegisterServicesFromAssembly(typeof(Program).Assembly); cfg.AddOpenBehavior(typeof(TenantBehavior<,>));});Implement IBehaviorOrder on it to place it precisely; otherwise it runs after the built-ins.
Streams get their own hook, IStreamPipelineBehavior<TRequest, TResponse>, so you can wrap an
IAsyncEnumerable without buffering it.
Compile-time safety
Mediant.Analyzers (diagnostics QM1001–QM1004) catches attribute misuse before you run anything —
[Transactional] on a query, [Cacheable] on a command, [InvalidatesCache] with an empty prefix. It
comes in transitively with Mediant.Behaviors.