# Synapse Pipelines & Behaviors > Pipeline behaviors, validation, and error handling. This file contains all documentation content in a single document following the llmstxt.org standard. ## Error Handling Synapse embraces the principle that **errors are values**. Handlers return `Result` / `Result` from `UnambitiousFx.Functional` rather than throwing exceptions for domain errors. Exceptions are reserved for genuine programming errors (misconfiguration, missing handlers). ## Result at the call site Every `IInvoker.InvokeAsync` call returns a `Result` or `Result`. Use whichever consumption pattern fits the context: ### `TryGet` — out-variable style ```csharp var result = await invoker.InvokeAsync(new CreateTaskCommand("Buy milk"), ct); if (result.TryGet(out var id, out var error)) return Results.Created($"/tasks/{id}", id); else return Results.Problem(error.ToString()); ``` ### `Match` — functional style ```csharp return result.Match( success: id => Results.Created($"/tasks/{id}", id), failure: error => Results.Problem(error.ToString())); ``` ### `IsSuccess` guard `Result` / `Result` expose no `Value` or `Error` property — pull both out with `TryGet`, or use `IsSuccess` / `IsFailure` purely as a boolean check: ```csharp if (!result.TryGet(out var id, out var error)) { logger.LogError("Create task failed: {Error}", error); return; } Process(id); ``` ## Returning failures from handlers Return a failure directly from a handler — do not throw: ```csharp public async ValueTask> HandleAsync(CreateTaskCommand cmd, CancellationToken ct) { if (string.IsNullOrWhiteSpace(cmd.Title)) return Result.Failure("Title is required."); // ← return, not throw var id = Guid.NewGuid(); await _repo.SaveAsync(id, cmd.Title, ct); return Result.Success(id); } ``` ## Combining multiple results Combine a collection of `Result` / `Result` values into a single result. It succeeds only when all inputs succeed; otherwise all failures are aggregated: ```csharp var results = new[] { validator1.ValidateAsync(cmd), validator2.ValidateAsync(cmd), }; // Combine over ValueTask awaits all of them and returns ValueTask var combined = await results.Combine(); if (!combined.IsSuccess) return combined; // all individual failures merged ``` ## Propagation through pipelines Failures propagate transparently through the pipeline without any special handling: - A behavior that calls `next()` and gets back `Result.Failure(...)` can inspect it, transform it, or return it unchanged. - A behavior can short-circuit by returning `Result.Failure(...)` without calling `next()` at all (e.g., a validation behavior). ```csharp public async ValueTask> HandleAsync( TRequest request, RequestHandlerDelegate next, CancellationToken ct) { // Short-circuit example if (!IsAllowed(request)) return Result.Failure("Access denied."); return await next(); } ``` ## Exceptions Exceptions are thrown only for programming errors — situations that indicate misconfiguration, not expected domain failures: | Exception | When thrown | | -------------------------------- | ------------------------------------------------------------------------------------------- | | `MissingHandlerException` | No handler registered for the request type. This is a startup/configuration error. | | `CqrsBoundaryViolationException` | A command was dispatched from inside a query (or vice versa) with CQRS enforcement enabled. | | `MissingContextFeatureException` | `IContext.MustGetFeature()` called but the feature was never set. | These should never appear in normal application flow. If they do, fix the configuration rather than catching them. ## See also - `UnambitiousFx.Functional` — full `Result` API: `Bind`, `Map`, `Ensure`, `Recover`, LINQ support. - [Validation](./validation) — validation failures surface as `Result.Failure` before the handler runs. - [Pipeline Behaviors](./pipelines) — behaviors can inspect and transform failures. --- ## 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(...)` 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 ```mermaid flowchart LR Invoker["IInvoker"] B1["Behavior 1\n(outermost)"] B2["Behavior 2"] B3["Behavior N"] Handler["Handler\n(innermost)"] Invoker --> B1 B1 -- next() --> B2 B2 -- next() --> B3 B3 -- next() --> Handler Handler -- Result --> B3 B3 -- Result --> B2 B2 -- Result --> B1 B1 -- Result --> Invoker ``` Each behavior can inspect or modify the request and the result on both sides of `next(...)`. Order is controlled explicitly by [`IOrderedPipelineBehavior`](#ordering) — not by registration order. ## Request pipeline behaviors A request behavior implements one of two typed interfaces: - `IRequestPipelineBehavior` — for requests that return a value (`IRequest`). - `IRequestPipelineBehavior` — for fire-and-forget requests (`IRequest`). ```csharp public sealed class AuditCreateTaskBehavior : IRequestPipelineBehavior { public async ValueTask> HandleAsync( CreateTaskCommand request, RequestHandlerDelegate 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(); ``` For a fire-and-forget command, implement `IRequestPipelineBehavior` and register with the two-type-argument overload: ```csharp public sealed class AuditDeleteTaskBehavior : IRequestPipelineBehavior { public ValueTask HandleAsync( DeleteTaskCommand request, RequestHandlerDelegate next, CancellationToken cancellationToken = default) => next(request, cancellationToken); } cfg.RegisterRequestPipelineBehavior(); ``` ## 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: ```csharp [PipelineBehavior] public sealed class TimingBehavior : IRequestPipelineBehavior where TRequest : IRequest where TResponse : notnull { private readonly ILogger> _logger; public TimingBehavior(ILogger> logger) => _logger = logger; public async ValueTask> HandleAsync( TRequest request, RequestHandlerDelegate 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. ```csharp [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` / `SimpleLoggingEventBehavior` Logs the request or event name and elapsed time. Register the closed pair for each handler (`SimpleLoggingBehavior` for void requests): ```csharp cfg.RegisterRequestPipelineBehavior< SimpleLoggingBehavior, CreateTaskCommand, Guid>(); cfg.RegisterEventPipelineBehavior< SimpleLoggingEventBehavior, TaskCreatedEvent>(); ``` ### `LoggingEnrichmentBehavior` Enriches the `ILogger` scope with `CorrelationId` and all context metadata so every log line emitted during the request automatically includes them: ```csharp cfg.RegisterRequestPipelineBehavior< LoggingEnrichmentBehavior, CreateTaskCommand, Guid>(); ``` ### CQRS boundary enforcement `CqrsBoundaryEnforcementBehavior` / `CqrsBoundaryEnforcementBehavior` 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: ```csharp cfg.RegisterCqrsBoundaryEnforcement(); cfg.RegisterCqrsBoundaryEnforcement(); ``` 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: ```csharp [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](#registering-a-behavior-you-dont-own-eg-from-a-nuget-package)). ::: :::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` for a specific event type: ```csharp public sealed class EventLoggingBehavior : IEventPipelineBehavior { private readonly ILogger _logger; public EventLoggingBehavior(ILogger logger) => _logger = logger; public ValueTask HandleAsync( TaskCreatedEvent @event, EventHandlerDelegate next, CancellationToken cancellationToken = default) { _logger.LogInformation("Dispatching event {Event}", typeof(TaskCreatedEvent).Name); return next(@event, cancellationToken); } } cfg.RegisterEventPipelineBehavior(); ``` To cover every event, make the behavior open-generic (`EventLoggingBehavior : IEventPipelineBehavior`) and mark it `[PipelineBehavior]`. ## Stream pipeline behaviors Implement `IStreamRequestPipelineBehavior`. The `next` delegate (`StreamRequestHandlerDelegate`) is parameterless and returns the downstream `IAsyncEnumerable>`: ```csharp public sealed class StreamLoggingBehavior : IStreamRequestPipelineBehavior { public IAsyncEnumerable> HandleAsync( StreamTasksQuery request, StreamRequestHandlerDelegate next, CancellationToken cancellationToken = default) { Console.WriteLine($"Stream started: {typeof(StreamTasksQuery).Name}"); return next(); } } cfg.RegisterStreamRequestPipelineBehavior(); ``` ## Ordering Behaviors run in the order given by `IOrderedPipelineBehavior` — **not** 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). ```csharp public sealed class LoggingBehavior : IRequestPipelineBehavior, IOrderedPipelineBehavior where TRequest : IRequest where TResponse : notnull { public uint Order => IOrderedPipelineBehavior.First; // outermost public ValueTask> HandleAsync( TRequest request, RequestHandlerDelegate 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 - [Validation](./validation) — `RequestValidationBehavior`, the built-in validation pipeline. - [Observability](./observability) — `SimpleLoggingBehavior` and `LoggingEnrichmentBehavior` in detail. - [Source Generator](./source-generator) — `[PipelineBehavior]` discovery and closed-registration emission. - [Error Handling](./error-handling) — behaviors can short-circuit by returning `Result.Failure(...)` instead of calling `next(...)`. --- ## Validation Synapse provides a first-class validation mechanism that runs validators before the handler is invoked. If any validator fails, the handler is never called and the failure is returned to the caller. ## Define a validator Implement `IRequestValidator` and return `Result.Failure(...)` when the request is invalid: ```csharp using UnambitiousFx.Functional; using UnambitiousFx.Synapse.Abstractions; public class CreateTaskCommandValidator : IRequestValidator { public ValueTask ValidateAsync(CreateTaskCommand command, CancellationToken ct = default) { if (string.IsNullOrWhiteSpace(command.Title)) return ValueTask.FromResult(Result.Failure("Title is required.")); if (command.Title.Length > 200) return ValueTask.FromResult(Result.Failure("Title must be 200 characters or fewer.")); return ValueTask.FromResult(Result.Success()); } } ``` ## Register the validator and the validation behavior Validation is implemented as a pipeline behavior, so you register both the validator and the behavior: ```csharp services.AddSynapse(cfg => { cfg.AddValidator(); cfg.RegisterRequestPipelineBehavior< RequestValidationBehavior, CreateTaskCommand, Guid>(); }); ``` `AddValidator` registers `IRequestValidator` in DI. `RequestValidationBehavior` resolves all registered validators for the request type at runtime. :::tip If any validator returns `Result.Failure`, the **handler is never called** and the failure is returned directly to the caller. ::: ## Validation flow ```mermaid flowchart TD Request["Request arrives at pipeline"] Validators["IRequestValidator<TRequest> ×N\n(run in order)"] Combine["Combine results"] Pass{All valid?} Handler["IRequestHandler\n.HandleAsync()"] Failure["Return Result.Failure\n(handler never called)"] Success["Return Result from handler"] Request --> Validators Validators --> Combine Combine --> Pass Pass -- Yes --> Handler Pass -- No --> Failure Handler --> Success ``` ## Multiple validators You can register as many validators as you like for the same request type. All validators run and their results are combined via `.Combine()`. If any fail, all failures are aggregated: ```csharp cfg.AddValidator(); cfg.AddValidator(); // Both run; both failures are reported if both fail ``` ## External validation libraries `IRequestValidator` is just an interface — you can adapt any validation library. Here is an example adapting FluentValidation: ```csharp public class FluentCreateTaskValidator : IRequestValidator { private readonly IValidator _validator; public FluentCreateTaskValidator(IValidator validator) => _validator = validator; public async ValueTask ValidateAsync(CreateTaskCommand cmd, CancellationToken ct = default) { var validation = await _validator.ValidateAsync(cmd, ct); return validation.IsValid ? Result.Success() : Result.Failure(string.Join("; ", validation.Errors.Select(e => e.ErrorMessage))); } } ``` ## See also - [Pipeline Behaviors](./pipelines) — validation is a pipeline behavior; understand registration order. - [Error Handling](./error-handling) — how validation failures surface as `Result` at the call site.