# 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); } ``` #### Failure types and codes `Result.Failure` carries a `Failure` with a `Code`, a message and optional metadata. The `UnambitiousFx.Functional` package provides typed variants that set a default code: | Failure type | Default code | | --- | --- | | `ValidationFailure` | `VALIDATION` | | `NotFoundFailure` | `NOT_FOUND` | | `UnauthorizedFailure` | `UNAUTHORIZED` | | `UnauthenticatedFailure` | `UNAUTHENTICATED` | | `TimeoutFailure` | `TIMEOUT` | | any other `Failure` | the code you pass, or `ERROR` | Codes are plain strings, so you can define your own (for example `orders.quantity_invalid`) and map them in one place, such as the HTTP mapper described in [ASP.NET Core](./aspnetcore). Prefixing custom codes with the module name keeps them unambiguous. ### 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. #### Sharing behaviors across projects `[PipelineBehavior]` propagates **downward** through the reference graph: the generator of the assembly that declares the behavior closes it over handlers in that assembly and in the assemblies it *references*. In a layered or modular solution the shared behaviors usually live in a library that the handler assemblies reference, so the library's generator never sees those handlers and the behavior is not applied to them. Wire the shared behaviors from the composition root (the host) instead: ```csharp // Host project (references the shared library and every module; runs the generator) [assembly: SynapseGlobalBehavior(typeof(Shared.AccessBehavior<,>))] [assembly: SynapseGlobalBehavior(typeof(Shared.ValidationBehavior<,>))] ``` ```csharp services.AddSynapse(cfg => { cfg.AddRegisterGroup(new Host.RegisterGroup()); // required: carries the global behaviors cfg.AddRegisterGroup(new ModuleA.RegisterGroup()); cfg.AddRegisterGroup(new ModuleB.RegisterGroup()); }); ``` :::warning The host's own group must be registered `[assembly: SynapseGlobalBehavior]` is emitted into the host assembly's generated `RegisterGroup` (or your `[RegisterGroup]` partial class). If that group is never passed to `AddRegisterGroup`, the attributes are accepted, **nothing fails, and the behaviors never run** ([`ValidateSynapse`](#validating-the-configuration) cannot detect this either — the [`SYN105`](./source-generator#diagnostics) analyzer catches it at build time instead). This matters most for security behaviors such as authorization. Add a test that dispatches one request from each module and asserts that the expected behaviors ran. ::: Ordering is unaffected by where a behavior is declared or registered: it is decided at runtime by [`IOrderedPipelineBehavior`](#ordering). See [Modular monoliths](./modular-monolith) for a complete layout. ### Applying a behavior to commands or queries only Constrain the open-generic behavior on a marker interface and it applies only to the requests that implement it. A behavior with `where TRequest : ICommand` (and `where TResponse : notnull`) runs for commands only: ```csharp [PipelineBehavior] public sealed class TransactionBehavior : IRequestPipelineBehavior where TRequest : ICommand where TResponse : notnull { public async ValueTask> HandleAsync( TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken = default) { // begin transaction, call next, commit or roll back return await next(request, cancellationToken); } } ``` The same works with `[assembly: SynapseGlobalBehavior(typeof(TransactionBehavior<,>))]` (source generator, Native-AOT safe) and with the runtime `AddOpenGenericRequestWithResponsePipelineBehavior(typeof(TransactionBehavior<,>))`: DI skips the closings whose constraints do not hold. The runtime path is not Native-AOT safe for value-type responses, so prefer `[PipelineBehavior]` under AOT. For requests without a response, use `where TRequest : ICommand` on an `IRequestPipelineBehavior`. There are no dedicated `AddCommandPipelineBehavior` helpers: the generic constraint is the scoping mechanism. The generator matches such a constraint against the handler's request type, so it works for any interface or base class, not only the markers. :::note Limit The `SYN102` analyzer does not evaluate constraints that reference the behavior's own type parameters (such as `ICommand`). A marker-scoped behavior that matches no handler is therefore silently never wired, and `SYN102` does not report it. ::: ### 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 the context's [flow identity](./context.mdx#flow-identity) and its [baggage](./context.mdx#baggage) 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<,>))] ``` Both attributes are needed: the one-parameter form covers requests with no response, the two-parameter form those that return one. `SynapseGlobalBehavior` is the general mechanism — see [Registering a behavior you don't own](#registering-a-behavior-you-dont-own-eg-from-a-nuget-package). :::note v1's `[assembly: EnableSynapseCqrsBoundaryEnforcement]` and `cfg.EnableCqrsBoundaryEnforcement()` were removed in v2 — see [Migrating to v2](./migration-v2.mdx#7-the-deprecated-cqrs-enforcement-opt-ins-are-removed). ::: #### Outbox discard on failure `OutboxDiscardOnFailureBehavior` / `OutboxDiscardOnFailureBehavior` take back the outbox events a request stored when it fails or throws. It is opt-in and also runs outermost; see [Discard the events of a failed request](./outbox.mdx#discard-the-events-of-a-failed-request). ### 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. ### Validating the configuration `ValidateSynapse()` inspects the registrations and reports mistakes that would otherwise surface only at the first request (or never). It never throws on findings (it throws `ArgumentNullException` for a null provider and `InvalidOperationException` if `AddSynapse` was never called): it returns a `SynapseValidationReport` and you decide what to do with it. | Code | Severity | What it catches | Fix | |---|---|---|---| | `SYN001` | Error | A closed pipeline behavior is registered for a request or event type that has no handler. | Register the handler, or remove the behavior. | | `SYN002` | Error | Two or more handlers are registered for one request type. Only the last registered one runs; the others are silently ignored. Events are exempt: several handlers per event is normal. | Keep one handler per request. | | `SYN003` | Error | The pipeline of a registered handler or event cannot be resolved (missing dependency, throwing constructor). The exception message is included. | Register the missing dependency or fix the constructor. | | `SYN004` | Warning | Two behaviors in one pipeline share an `Order`. Legal, they keep registration order. Only behaviors that declare an `Order` through `IOrderedPipelineBehavior` can tie, and ties made solely of Synapse's own built-in behaviors are not reported. | Give them distinct `Order` values if the relative position matters. | Warnings do not make `report.IsValid` false. Assert on it in a test: ```csharp using UnambitiousFx.Synapse; // ValidateSynapse() using UnambitiousFx.Synapse.Abstractions; // SynapseValidationReport var report = provider.ValidateSynapse(); Assert.True(report.IsValid, report.ToString()); ``` Or fail the host at startup instead of at the first request. `ValidateOnStart()` registers a hosted service that logs every warning and throws `SynapseValidationException` when the report has errors: ```csharp services.AddSynapse(cfg => { cfg.AddRegisterGroup(new Host.RegisterGroup()); cfg.ValidateOnStart(); }); ``` #### What it cannot catch - A request with neither a handler nor a behavior: nothing is registered, so there is nothing to inspect. - `[assembly: SynapseGlobalBehavior]` when the host's generated group is never registered (`cfg.AddRegisterGroup(new Host.RegisterGroup())`). The behaviors never reach the container, so the validator sees a valid configuration. The build-time [`SYN105`](./source-generator#diagnostics) analyzer catches this instead. - Stream requests: stream pipelines are not inspected yet. - An `IRequestPipelineBehavior` (void flavour) registered for an `IRequest` request: it never runs and is not reported. - Behaviors added to the `IServiceCollection` after `AddSynapse` returns. - Open-generic behaviors: DI closes them lazily over any request, so whether one applies to nothing is not decidable at runtime. Validation also resolves every registered handler and its behaviors once at startup (one throwaway scope per pipeline), so constructor side effects run at startup. ### 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(...)`. ### Inspecting a pipeline `IPipelineDescriber` reports which handler(s) and behaviors a request or event type resolves to, in the order they execute. Use it in an architecture test to prove a behavior can never be skipped: ```csharp var describer = provider.GetRequiredService(); var pipeline = describer.Describe(); Assert.NotNull(pipeline); Assert.Equal(typeof(PurgeCompletedTasksCommandHandler), Assert.Single(pipeline.Handlers)); Assert.Contains(pipeline.Behaviors, b => b.Type.IsGenericType && b.Type.GetGenericTypeDefinition() == typeof(AuthorizationBehavior<,>)); ``` `Behaviors` is outermost first; each entry carries the concrete behavior type and the `Order` it declared (`IOrderedPipelineBehavior.Last` when it declares none). Behaviors that share an `Order` keep their registration order. `Describe` returns `null` when nothing handles the message. To check every request in an assembly, use the non-generic overloads. `IsRequest` stands for your own predicate for request types (for example, concrete types that implement `IRequest` or `IRequest<>`). `Describe` returns `null` for a request nothing handles, so decide what that means for your test instead of suppressing it: ```csharp foreach (var requestType in typeof(PurgeCompletedTasksCommand).Assembly.GetTypes().Where(IsRequest)) { var pipeline = describer.Describe(requestType); Assert.NotNull(pipeline); // or skip request types that are deliberately unhandled Assert.Contains(pipeline.Behaviors, b => b.Type.IsGenericType && b.Type.GetGenericTypeDefinition() == typeof(AuthorizationBehavior<,>)); } ``` `PipelineDescription` has structural equality, so two descriptions with the same handlers and behaviors in the same order are equal, and `ToString()` prints them as `Order Name`. :::note `Describe(Type)` and `DescribeEvent(Type)` build a generic method at runtime, so they are not Native-AOT safe: use them in tests, and the generic overloads (which are AOT safe) in an application. ::: :::caution Describing resolves the handler and the behaviors from a fresh DI scope, because `Order` is an instance property, then disposes the scope. The handler's constructor dependencies are constructed too (a `DbContext`, an `HttpClient`, any warm-up side effect). A handler or behavior whose constructor needs something that only exists inside a real request (for example an `HttpContext`) can throw there, and a handler that only implements `IAsyncDisposable` makes disposing that scope throw. ::: --- ## 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.