Skip to main content

Pipeline Behaviors

Pipeline behaviors let you wrap request, event, or stream handling with cross-cutting logic — logging, tracing, validation, or CQRS enforcement — without touching handler code. They follow the chain-of-responsibility (Russian-doll) pattern: each behavior calls next(...) to forward to the next behavior or to the handler.

All Synapse pipeline behaviors are typed: a behavior is bound to a specific request/response (or event, or stream-item) type through the generic interface it implements. The non-generic HandleAsync<T>(...) style does not exist — HandleAsync is a plain method and next is a strongly-typed delegate that takes the request and a CancellationToken.

How the pipeline works

Each behavior can inspect or modify the request and the result on both sides of next(...). Order is controlled explicitly by IOrderedPipelineBehavior — not by registration order.

Request pipeline behaviors

A request behavior implements one of two typed interfaces:

  • IRequestPipelineBehavior<TRequest, TResponse> — for requests that return a value (IRequest<TResponse>).
  • IRequestPipelineBehavior<TRequest> — for fire-and-forget requests (IRequest).
public sealed class AuditCreateTaskBehavior : IRequestPipelineBehavior<CreateTaskCommand, Guid>
{
public async ValueTask<Result<Guid>> HandleAsync(
CreateTaskCommand request,
RequestHandlerDelegate<CreateTaskCommand, Guid> next,
CancellationToken cancellationToken = default)
{
var result = await next(request, cancellationToken);
if (result.TryGetValue(out var id))
Audit.Log($"Task created: {id}");
return result;
}
}

// Register the behavior for its request/response pair
cfg.RegisterRequestPipelineBehavior<AuditCreateTaskBehavior, CreateTaskCommand, Guid>();

For a fire-and-forget command, implement IRequestPipelineBehavior<TRequest> and register with the two-type-argument overload:

public sealed class AuditDeleteTaskBehavior : IRequestPipelineBehavior<DeleteTaskCommand>
{
public ValueTask<Result> HandleAsync(
DeleteTaskCommand request,
RequestHandlerDelegate<DeleteTaskCommand> next,
CancellationToken cancellationToken = default)
=> next(request, cancellationToken);
}

cfg.RegisterRequestPipelineBehavior<AuditDeleteTaskBehavior, DeleteTaskCommand>();

Applying a behavior to every handler

Naming each request/response pair is fine for one-off behaviors, but cross-cutting concerns (logging, tracing, auth) usually apply to all handlers. Make the behavior open-generic and mark it [PipelineBehavior] — the Synapse source generator discovers it, infers the kind/arity from the implemented interface, and emits one closed (Native-AOT-safe) registration per matching handler:

[PipelineBehavior]
public sealed class TimingBehavior<TRequest, TResponse>
: IRequestPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
where TResponse : notnull
{
private readonly ILogger<TimingBehavior<TRequest, TResponse>> _logger;

public TimingBehavior(ILogger<TimingBehavior<TRequest, TResponse>> logger) => _logger = logger;

public async ValueTask<Result<TResponse>> HandleAsync(
TRequest request,
RequestHandlerDelegate<TRequest, TResponse> next,
CancellationToken cancellationToken = default)
{
var sw = Stopwatch.StartNew();
var result = await next(request, cancellationToken);
_logger.LogInformation("{Request} completed in {Ms}ms", typeof(TRequest).Name, sw.ElapsedMilliseconds);
return result;
}
}

By default an assembly's [PipelineBehavior] classes are applied to handlers in referenced assemblies too. Apply [assembly: DisableSynapseCrossAssemblyBehaviors] to limit the cross-product to handlers declared in the same assembly.

:::note Native AOT Registering an open-generic behavior at runtime (AddOpenGenericRequestPipelineBehavior(typeof(...))) is supported but is not Native-AOT safe for value-type responses. Prefer [PipelineBehavior] so the generator emits closed registrations. :::

Registering a behavior you don't own (e.g. from a NuGet package)

[PipelineBehavior] requires decorating the behavior class, so it only works for behaviors whose source you control. When a behavior ships in a referenced package — for example a company's shared pipeline library — opt into it from the composition root with [assembly: SynapseGlobalBehavior(typeof(...))]. The generator treats it exactly like a [PipelineBehavior] open generic: it closes the behavior over every matching handler and emits one closed, Native-AOT-safe registration per match.

[assembly: SynapseGlobalBehavior(typeof(CompanyShared.LoggingBehavior<,>))]

Pass the unbound generic definition (typeof(Foo<,>)) — a non-generic typeof is the only way to hand an open generic to an attribute. The behavior type must be public and implement one of the pipeline interfaces; its open-generic constraints (named-type and class/struct/unmanaged/notnull/new()) are honoured, and pipeline position is controlled by the behavior implementing IOrderedPipelineBehavior. The same cross-assembly and [assembly: DisableSynapseCrossAssemblyBehaviors] rules apply.

Built-in behaviors

SimpleLoggingBehavior<TRequest, TResponse> / SimpleLoggingEventBehavior<TEvent>

Logs the request or event name and elapsed time. Register the closed pair for each handler (SimpleLoggingBehavior<TRequest> for void requests):

cfg.RegisterRequestPipelineBehavior<
SimpleLoggingBehavior<CreateTaskCommand, Guid>, CreateTaskCommand, Guid>();
cfg.RegisterEventPipelineBehavior<
SimpleLoggingEventBehavior<TaskCreatedEvent>, TaskCreatedEvent>();

LoggingEnrichmentBehavior<TRequest, TResponse>

Enriches the ILogger scope with CorrelationId and all context metadata so every log line emitted during the request automatically includes them:

cfg.RegisterRequestPipelineBehavior<
LoggingEnrichmentBehavior<CreateTaskCommand, Guid>,
CreateTaskCommand,
Guid>();

CQRS boundary enforcement

CqrsBoundaryEnforcementBehavior<TRequest> / CqrsBoundaryEnforcementBehavior<TRequest, TResponse> detect when a command is dispatched from inside a query handler (or vice versa) and throw CqrsBoundaryViolationException. The behavior runs outermost (Order => IOrderedPipelineBehavior.First).

Enable it per request type:

cfg.RegisterCqrsBoundaryEnforcement<CreateTaskCommand, Guid>();
cfg.RegisterCqrsBoundaryEnforcement<DeleteTaskCommand>();

Or turn it on for the whole assembly by registering the built-in behavior globally — the source generator emits the closed (AOT-safe) registrations for every handler:

[assembly: SynapseGlobalBehavior(typeof(CqrsBoundaryEnforcementBehavior<>))]
[assembly: SynapseGlobalBehavior(typeof(CqrsBoundaryEnforcementBehavior<,>))]
note

[assembly: EnableSynapseCqrsBoundaryEnforcement] still works as an alias for the two registrations above, but is now [Obsolete]SynapseGlobalBehavior is the general mechanism (see Registering a behavior you don't own).

warning

The old runtime switch cfg.EnableCqrsBoundaryEnforcement() was removed — it is now [Obsolete(error: true)] and throws. Use RegisterCqrsBoundaryEnforcement<...>() or the assembly attribute instead.

Event pipeline behaviors

Implement IEventPipelineBehavior<TEvent> for a specific event type:

public sealed class EventLoggingBehavior : IEventPipelineBehavior<TaskCreatedEvent>
{
private readonly ILogger<EventLoggingBehavior> _logger;

public EventLoggingBehavior(ILogger<EventLoggingBehavior> logger) => _logger = logger;

public ValueTask<Result> HandleAsync(
TaskCreatedEvent @event,
EventHandlerDelegate<TaskCreatedEvent> next,
CancellationToken cancellationToken = default)
{
_logger.LogInformation("Dispatching event {Event}", typeof(TaskCreatedEvent).Name);
return next(@event, cancellationToken);
}
}

cfg.RegisterEventPipelineBehavior<EventLoggingBehavior, TaskCreatedEvent>();

To cover every event, make the behavior open-generic (EventLoggingBehavior<TEvent> : IEventPipelineBehavior<TEvent>) and mark it [PipelineBehavior].

Stream pipeline behaviors

Implement IStreamRequestPipelineBehavior<TRequest, TItem>. The next delegate (StreamRequestHandlerDelegate<TItem>) is parameterless and returns the downstream IAsyncEnumerable<Result<TItem>>:

public sealed class StreamLoggingBehavior : IStreamRequestPipelineBehavior<StreamTasksQuery, TaskDto>
{
public IAsyncEnumerable<Result<TaskDto>> HandleAsync(
StreamTasksQuery request,
StreamRequestHandlerDelegate<TaskDto> next,
CancellationToken cancellationToken = default)
{
Console.WriteLine($"Stream started: {typeof(StreamTasksQuery).Name}");
return next();
}
}

cfg.RegisterStreamRequestPipelineBehavior<StreamLoggingBehavior, StreamTasksQuery, TaskDto>();

Ordering

Behaviors run in the order given by IOrderedPipelineBehaviornot registration order. A behavior opts in by implementing the interface and exposing an Order (a uint); lower values run first (outermost), higher run last (innermost, closest to the handler). A behavior that does not implement IOrderedPipelineBehavior is treated as Last. Ties are broken by registration order (stable).

public sealed class LoggingBehavior<TRequest, TResponse>
: IRequestPipelineBehavior<TRequest, TResponse>, IOrderedPipelineBehavior
where TRequest : IRequest<TResponse>
where TResponse : notnull
{
public uint Order => IOrderedPipelineBehavior.First; // outermost

public ValueTask<Result<TResponse>> HandleAsync(
TRequest request,
RequestHandlerDelegate<TRequest, TResponse> next,
CancellationToken cancellationToken = default)
=> next(request, cancellationToken);
}

IOrderedPipelineBehavior exposes three convenience positions: First (uint.MinValue), Middle (uint.MaxValue / 2), and Last (uint.MaxValue). For example, register logging at First so it captures the full round-trip, and validation closer to the handler.

See also

  • ValidationRequestValidationBehavior, the built-in validation pipeline.
  • ObservabilitySimpleLoggingBehavior and LoggingEnrichmentBehavior in detail.
  • Source Generator[PipelineBehavior] discovery and closed-registration emission.
  • Error Handling — behaviors can short-circuit by returning Result.Failure(...) instead of calling next(...).