Pipeline behaviors wrap handler execution, forming a chain:
Behavior1 → Behavior2 → … → Handler
Each behavior can run logic before and after the next step in the pipeline. The next parameter is an IRequestHandler — calling next.Handle(request, ct) advances the chain via interface dispatch (virtual call) instead of a delegate invocation, enabling zero-allocation behavior pipelines.
Allocations stay flat at 72 B regardless of pipeline depth:
xychart-beta
title "Allocation vs Pipeline Depth"
x-axis "Behaviors" [0, 1, 3, 5]
y-axis "Bytes" 0 --> 100
line [72, 72, 72, 72]
| Behaviors | DSoft (alloc) | MediatR (alloc) |
|---|---|---|
| 0 | 72 B | 272 B |
| 1 | 72 B | 544 B |
| 3 | 72 B | 800 B |
| 5 | 72 B | 1,088 B |
public class LoggingBehavior<TRequest, TResponse>
: IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
public async ValueTask<TResponse> Handle(
TRequest request,
IRequestHandler<TRequest, TResponse> next,
CancellationToken ct)
{
Console.WriteLine($"Handling {typeof(TRequest).Name}");
return await next.Handle(request, ct);
}
}
Register behaviors as open generics:
services.AddScoped(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>));
Scoped, not Transient: one Transient component registers the request’s whole pipeline chain as
Transient, so every dispatch re-resolves and re-links the chain instead of reusing the one already
built for the scope. Register a component Transient when it must be constructed per dispatch — a
Scoped instance is shared by every dispatch in the scope, including concurrent ones, so per-dispatch
state or a non-thread-safe field needs it. A behavior that keeps its state in locals, as the one above
does, needs nothing.
Behaviors execute in registration order. The first registered behavior is the outermost wrapper.
Returning without calling next.Handle stops the chain there: no later behavior runs and neither
does the handler, which is how a behavior serves a cached or rejected result. Post-processors are
not skipped by this — they sit outside the chain and receive whatever response the behavior
returned.
Register behaviors before
PrecompilePipelines(). That call decides whether a chain is built for each request/response pair at all; if none exists by then, behaviors added afterwards never run. See Registration Order.
PrecompilePipelines()