Skip to content

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.

Terminal window
dotnet add package Mediant.Behaviors
builder.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.

#BehaviorAttributeOrderWhat it does
1Audit[Auditable]100Async batched audit entries, store abstraction, sensitive-data masking
2Loggingautomatic200Structured logs, auto-masking, safe against circular references
3UnhandledExceptionautomatic300Catch-all safety net; logs, then always re-throws
4Authorization[Authorize]400Roles and policies, failures returned as a Result
5Validationautomatic500FluentValidation, multi-validator, Result.ValidationFailure
6Idempotency[Idempotent]600Replay protection with per-key locking and a window
7Transaction[Transactional]700Commands only. Begin/commit/rollback via IUnitOfWork
8Performance[PerformanceThreshold]800Warn/critical thresholds, hard ceiling
9Retry[Retryable]900Exponential backoff with jitter
10Caching[Cacheable]1000Queries only. Stampede prevention via a bounded lock pool
11Cache invalidation[InvalidatesCache]1001Commands 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 QM1001QM1004) 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.