# Synapse - UnambitiousFx > Synapse is a simple Native-AOT mediator This file contains all documentation content in a single document following the llmstxt.org standard. ## Synapse UnambitiousFx.Synapse is a lightweight, high-performance, in-process mediator for .NET. It routes commands, queries, and events through a structured pipeline with zero reflection at runtime. ### Key features - **Commands & queries** — send typed requests and get `Result` back via `IInvoker`. - **Events** — fan-out to multiple handlers with sequential or concurrent orchestration. - **Streaming** — return `IAsyncEnumerable>` for large or live data sets. - **Pipeline behaviors** — wrap any request or event in cross-cutting concerns (logging, validation, CQRS enforcement). - **NativeAOT-ready** — dispatch delegates are captured at startup; no `MakeGenericType`, no reflection at request time. - **Result-based errors** — handlers return `Result` / `Result` from `UnambitiousFx.Functional`; errors are values, not exceptions. ### Architecture overview ```mermaid graph TD Caller["Caller\n(Controller / Endpoint / Service)"] IInvoker["IInvoker"] Pipeline["Request Pipeline\n(IRequestPipelineBehavior[])"] Handler["IRequestHandler<TRequest, TResponse>"] Result["Result<TResponse>"] IEmitter["IEmitter"] Dispatcher["IEventDispatcher"] Orchestrator["IEventOrchestrator\n(Sequential / Concurrent)"] EventHandlers["IEventHandler<TEvent> ×N"] Caller --> IInvoker IInvoker --> Pipeline Pipeline --> Handler Handler --> Result Result --> Caller Handler -- "EmitAsync" --> IEmitter IEmitter --> Dispatcher Dispatcher --> Orchestrator Orchestrator --> EventHandlers ``` ### Packages **.NET CLI** ```bash dotnet add package UnambitiousFx.Synapse.Abstractions dotnet add package UnambitiousFx.Synapse dotnet add package UnambitiousFx.Synapse.AspNetCore # optional — web API integration dotnet add package UnambitiousFx.Synapse.Generator # optional — source generator ``` **PackageReference** ```xml ``` | Package | Purpose | | ------------------------------------ | -------------------------------------------------------------------- | | `UnambitiousFx.Synapse.Abstractions` | All public interfaces, delegates, and attributes. No dependencies. | | `UnambitiousFx.Synapse` | DI registration, invoker, dispatcher, context, outbox. | | `UnambitiousFx.Synapse.AspNetCore` | `IHttpInvoker`, `IMvcInvoker`, `UseSynapsePropagation` middleware. | | `UnambitiousFx.Synapse.Generator` | Roslyn source generator that eliminates manual handler registration. | ### Quick start ```csharp // 1. Register services.AddSynapse(cfg => cfg.RegisterRequestHandler()); // 2. Define public record CreateTaskCommand(string Title) : IRequest; public class CreateTaskHandler : IRequestHandler { public ValueTask> HandleAsync(CreateTaskCommand cmd, CancellationToken ct = default) { var id = Guid.NewGuid(); // ... persist ... return ValueTask.FromResult(Result.Success(id)); } } // 3. Invoke var result = await invoker.InvokeAsync(new CreateTaskCommand("Buy milk")); result.Match( success: id => Console.WriteLine($"Created: {id}"), failure: error => Console.WriteLine($"Error: {error}")); ``` ### Design principles - **Errors are values** — use `Result` / `Result` throughout; exceptions are reserved for programming errors. - **Zero runtime reflection** — dispatch delegates are compiled at DI registration time; safe for NativeAOT and trimming. - **Composable pipelines** — cross-cutting concerns (logging, validation, tracing) are behaviors, not framework magic. - **Explicit over implicit** — every handler and behavior is registered intentionally; nothing is auto-discovered by convention. ### Next steps Follow this path from fundamentals to advanced integration: 1. [Getting Started](./getting-started) — install, configure, and send your first command. 2. [Commands and Queries](./commands-and-queries) — typed requests and the `IInvoker` API. 3. [Events](./events) — fan-out event publishing with `IEmitter`. 4. [Streaming](./streaming) — async-enumerable responses with `IStreamRequest`. 5. [Context](./context) — per-request correlation ID, metadata, and feature bag. 6. [Pipeline Behaviors](./pipelines) — cross-cutting concerns and built-in behaviors. 7. [Validation](./validation) — request validation before the handler runs. 8. [Error Handling](./error-handling) — working with `Result` throughout the stack. 9. [Outbox Pattern](./outbox) — deferred event dispatch for transactional safety. 10. [ASP.NET Core Integration](./aspnetcore) — `IHttpInvoker`, `IMvcInvoker`, and correlation middleware. 11. [Source Generator](./source-generator) — eliminate registration boilerplate. 12. [Observability](./observability) — metrics, tracing, logging, and health checks. --- ## Getting Started This guide walks you through installing Synapse, wiring it up in a .NET application, and sending your first command end-to-end. ### Install **.NET CLI** ```bash dotnet add package UnambitiousFx.Synapse ``` **PackageReference** ```xml ``` For web API projects, also install the ASP.NET Core integration layer: **.NET CLI** ```bash dotnet add package UnambitiousFx.Synapse.AspNetCore ``` **PackageReference** ```xml ``` ### Register Synapse Call `AddSynapse` in your `Program.cs` or `Startup.cs`: ```csharp builder.Services.AddSynapse(cfg => { cfg.RegisterRequestHandler(); }); ``` :::note Every handler must be registered explicitly — Synapse does not auto-discover handlers by convention. Use the [Source Generator](./source-generator) to eliminate boilerplate in larger projects. ::: #### What gets registered automatically `AddSynapse` registers these services in the DI container (all scoped by default): | Service | Description | | ------------------ | -------------------------------------------- | | `IInvoker` | Send commands and queries. | | `IEmitter` | Publish in-process events. | | `IContext` | Per-scope correlation ID and metadata bag. | | `IContextAccessor` | Access the current `IContext` from anywhere. | | `IOutboxCommit` | Flush deferred (outbox) events. | ### Define a command A **command** is a plain record or class that implements `IRequest`: ```csharp using UnambitiousFx.Synapse.Abstractions; public record CreateTaskCommand(string Title) : IRequest; ``` If the command produces no result, use `IRequest` (no type parameter): ```csharp public record DeleteTaskCommand(Guid TaskId) : IRequest; ``` ### Implement the handler ```csharp using UnambitiousFx.Functional; using UnambitiousFx.Synapse.Abstractions; public class CreateTaskHandler : IRequestHandler { private readonly ITaskRepository _repository; public CreateTaskHandler(ITaskRepository repository) { _repository = repository; } public async ValueTask> HandleAsync( CreateTaskCommand command, CancellationToken ct = default) { if (string.IsNullOrWhiteSpace(command.Title)) return Result.Failure("Title cannot be empty."); var task = new TaskEntity { Id = Guid.NewGuid(), Title = command.Title }; await _repository.SaveAsync(task, ct); return Result.Success(task.Id); } } ``` :::tip Handlers receive the command and a `CancellationToken`. Return `Result` for domain errors — **never throw**. See [Error Handling](./error-handling) for patterns. ::: ### Send the command Inject `IInvoker` and call `InvokeAsync`: ```csharp public class TaskService { private readonly IInvoker _invoker; public TaskService(IInvoker invoker) { _invoker = invoker; } public async Task CreateAsync(string title, CancellationToken ct) { var result = await _invoker.InvokeAsync(new CreateTaskCommand(title), ct); result.Match( success: id => Console.WriteLine($"Created task {id}"), failure: error => Console.WriteLine($"Failed: {error}")); } } ``` #### Request dispatch flow ```mermaid sequenceDiagram participant Caller participant IInvoker participant Pipeline as Pipeline Behaviors participant Handler as IRequestHandler Caller->>IInvoker: InvokeAsync(command, ct) IInvoker->>Pipeline: pass through behaviors Pipeline->>Handler: HandleAsync(command, ct) Handler-->>Pipeline: "Result" Pipeline-->>IInvoker: "Result" IInvoker-->>Caller: "Result" ``` ### Read the result `Result` supports several consumption patterns: ```csharp // Option 1 — TryGet (out variables) if (result.TryGet(out var id, out var error)) Console.WriteLine($"Created: {id}"); else Console.WriteLine($"Error: {error}"); // Option 2 — Match (functional) var message = result.Match( success: id => $"Created: {id}", failure: error => $"Error: {error}"); // Option 3 — TryGetValue (value only, ignore the error) if (result.TryGetValue(out var id)) Process(id); ``` For more detail on `Result`, see the `UnambitiousFx.Functional` documentation. ### Complete example ```csharp // Program.cs using UnambitiousFx.Synapse; var builder = WebApplication.CreateBuilder(args); builder.Services.AddScoped(); builder.Services.AddSynapse(cfg => { cfg.RegisterRequestHandler(); }); var app = builder.Build(); app.MapPost("/tasks", async ( CreateTaskCommand cmd, IInvoker invoker, CancellationToken ct) => { var result = await invoker.InvokeAsync(cmd, ct); return result.Match( success: id => Results.Created($"/tasks/{id}", id), failure: error => Results.BadRequest(error.ToString())); }); app.Run(); ``` ### Next steps - [Commands and Queries](./commands-and-queries) — learn about queries, fire-and-forget commands, and more `IInvoker` patterns. - [Pipeline Behaviors](./pipelines) — add logging, validation, or any cross-cutting concern. - [Source Generator](./source-generator) — automate handler registration for larger projects. --- ## Commands and Queries In Synapse every operation that expects a response is modelled as a **request**. Commands and queries are both requests — the difference is a matter of intent and convention, not a technical distinction enforced by the framework. To make that intent visible in your types you can optionally use the marker interfaces `ICommand`, `ICommand` and `IQuery` (in `UnambitiousFx.Synapse.Abstractions`). They are pure markers: the library treats them like any other request. | Concept | Intent | Marker interface | | ----------- | -------------------------------------------------- | ------------------------------------------------------- | | **Command** | Mutates state, may return an ID or acknowledgement | `ICommand` or `ICommand` (still an `IRequest` underneath) | | **Query** | Reads state, always returns data | `IQuery` | For strict command/query separation at runtime, see [Pipeline Behaviors — CQRS enforcement](./pipelines#cqrs-boundary-enforcement). That behavior does not use the markers: it still blocks any request dispatched inside a handler. #### Marker interfaces and handler aliases ```csharp public record CreateTaskCommand(string Title) : ICommand; public record GetTaskQuery(Guid TaskId) : IQuery; [RequestHandler] public class CreateTaskCommandHandler : ICommandHandler { ... } ``` `ICommandHandler`, `ICommandHandler` and `IQueryHandler` are naming aliases of `IRequestHandler`: they add nothing at runtime. Registration still uses `[RequestHandler<...>]`, no marker is required, and existing `IRequest` code keeps working unchanged. The markers matter mainly for scoping behaviors, see [Applying a behavior to commands or queries only](./pipelines#applying-a-behavior-to-commands-or-queries-only). The markers are optional. Because they live in `UnambitiousFx.Synapse.Abstractions`, a file that imports that namespace and also your own `ICommand` or `IQuery` from another namespace gets an ambiguous-reference error (CS0104) after upgrading. Either keep your own markers and disambiguate with an alias such as `using ICommand = MyApp.Messaging.ICommand;`, or migrate to the built-in ones. ### Requests with a response — `IRequest` Use `IRequest` when the operation produces a typed result: ```csharp public record GetTaskQuery(Guid TaskId) : IRequest; public record CreateTaskCommand(string Title) : IRequest; ``` The handler interface is `IRequestHandler`: ```csharp public class GetTaskQueryHandler : IRequestHandler { private readonly ITaskRepository _repository; public GetTaskQueryHandler(ITaskRepository repository) => _repository = repository; public async ValueTask> HandleAsync( GetTaskQuery query, CancellationToken ct = default) { var task = await _repository.FindAsync(query.TaskId, ct); return task is null ? Result.Failure($"Task {query.TaskId} not found.") : Result.Success(TaskDto.From(task)); } } ``` ### Fire-and-forget commands — `IRequest` Use `IRequest` (without type parameter) when the command produces no value — only success or failure: ```csharp public record DeleteTaskCommand(Guid TaskId) : IRequest; public class DeleteTaskCommandHandler : IRequestHandler { private readonly ITaskRepository _repository; public DeleteTaskCommandHandler(ITaskRepository repository) => _repository = repository; public async ValueTask HandleAsync( DeleteTaskCommand command, CancellationToken ct = default) { var deleted = await _repository.DeleteAsync(command.TaskId, ct); return deleted ? Result.Success() : Result.Failure($"Task {command.TaskId} not found."); } } ``` ### Optional base class Both handler interfaces have an optional base class that adapts a synchronous `Handle` method to the async interface. Use it only when your handler has no async work: ```csharp public class EchoQueryHandler : RequestHandler { protected override Result Handle(EchoQuery query) => Result.Success(query.Message); } ``` ### Invoking requests Inject `IInvoker` and call `InvokeAsync`. The response type is inferred from the `IRequest` type argument — no need to specify it explicitly: ```csharp // Typed response — TResponse inferred as TaskDto Result result = await invoker.InvokeAsync(new GetTaskQuery(id), ct); // Typed response — TResponse inferred as Guid Result created = await invoker.InvokeAsync(new CreateTaskCommand("Buy milk"), ct); // No response Result deleted = await invoker.InvokeAsync(new DeleteTaskCommand(id), ct); ``` ### Registration Register each handler in `AddSynapse`: ```csharp services.AddSynapse(cfg => { // With response cfg.RegisterRequestHandler(); cfg.RegisterRequestHandler(); // Without response cfg.RegisterRequestHandler(); // Conditional — only registered when the predicate returns true cfg.RegisterRequestHandlerWhen(() => featureEnabled); }); ``` Each handler type is stored in an O(1) dispatch dictionary keyed on the request type, looked up once per `InvokeAsync` call. ### Modular registration with `IRegisterGroup` For larger projects, group related registrations into a class that implements `IRegisterGroup`: ```csharp public class TasksRegisterGroup : IRegisterGroup { public void Register(IDependencyInjectionBuilder builder) { builder.RegisterRequestHandler(); builder.RegisterRequestHandler(); builder.RegisterRequestHandler(); } } // In Program.cs services.AddSynapse(cfg => cfg.AddRegisterGroup(new TasksRegisterGroup())); ``` The [Source Generator](./source-generator) can generate this class automatically from handler attributes — either as the default `RegisterGroup` in your root namespace, or into a `partial class` of your own choosing via the [`[RegisterGroup]` attribute](./source-generator#customize-the-generated-class). ### See also - [Validation](./validation) — run validators before the handler is invoked. - [Pipeline Behaviors](./pipelines) — add cross-cutting concerns to all or specific request types. - [Error Handling](./error-handling) — work with `Result` and `Result` in call sites. --- ## Events Events let a handler notify the rest of the system that something happened, without knowing which components care. Multiple handlers can subscribe to the same event and all of them run. ### Define an event An event is a plain class or record that implements `IEvent`: ```csharp using UnambitiousFx.Synapse.Abstractions; public record TaskCreatedEvent(Guid TaskId, string Title) : IEvent; ``` ### Implement an event handler ```csharp public class SendWelcomeEmailOnTaskCreated : IEventHandler { private readonly IEmailService _email; public SendWelcomeEmailOnTaskCreated(IEmailService email) => _email = email; public async ValueTask HandleAsync( TaskCreatedEvent @event, CancellationToken ct = default) { await _email.SendAsync(@event.Title, ct); return Result.Success(); } } ``` ### Register event handlers ```csharp services.AddSynapse(cfg => { cfg.RegisterEventHandler(); cfg.RegisterEventHandler(); // second handler, same event }); ``` Multiple handlers for the same event type are all registered and all executed on every `EmitAsync` call. ### Publish an event Inject `IEmitter` and call `EmitAsync`: ```csharp await emitter.EmitAsync(new TaskCreatedEvent(task.Id, task.Title), ct); ``` Or from within a handler, by injecting `IEmitter`: ```csharp public class CreateTaskHandler : IRequestHandler { private readonly IEmitter _emitter; public CreateTaskHandler(IEmitter emitter) => _emitter = emitter; public async ValueTask> HandleAsync(CreateTaskCommand cmd, CancellationToken ct = default) { var id = Guid.NewGuid(); // ... persist ... await _emitter.EmitAsync(new TaskCreatedEvent(id, cmd.Title), ct); return Result.Success(id); } } ``` ### Emit modes `IEmitter.EmitAsync` accepts an optional `EmitMode` parameter: | Mode | Behaviour | | ------------------ | ------------------------------------------------------------------------------------------------------------- | | `EmitMode.Now` | Dispatches immediately. If any handler fails, the failure propagates back to the caller. | | `EmitMode.Outbox` | Stores the event in the outbox. Dispatched when `IOutboxCommit.CommitAsync` is called. | | `EmitMode.Default` | Resolved to the mode configured via `SetDefaultPublishingMode` (defaults to `Now`). | ```csharp // Dispatch immediately (default) await emitter.EmitAsync(new TaskCreatedEvent(id, title)); // Defer to outbox await emitter.EmitAsync(new TaskCreatedEvent(id, title), EmitMode.Outbox); // Use configured default await emitter.EmitAsync(new TaskCreatedEvent(id, title), EmitMode.Default); ``` See [Outbox Pattern](./outbox) for the full deferred-dispatch story. ### Event dispatch flow ```mermaid flowchart TD Emitter["IEmitter.EmitAsync(event, mode)"] ModeCheck{EmitMode?} Dispatcher["IEventDispatcher\n.DispatchAsync(event)"] Outbox["IEventOutboxStorage\n.AddAsync(event)"] Pipeline["IEventPipelineBehavior[]"] Orchestrator["IEventOrchestrator"] Sequential["SequentialEventOrchestrator\n(handlers run one after another)"] Concurrent["ConcurrentEventOrchestrator\n(handlers run in parallel)"] Handlers["IEventHandler<TEvent> ×N"] Emitter --> ModeCheck ModeCheck -- Now --> Dispatcher ModeCheck -- Outbox --> Outbox Dispatcher --> Pipeline Pipeline --> Orchestrator Orchestrator --> Sequential Orchestrator --> Concurrent Sequential --> Handlers Concurrent --> Handlers ``` ### Fan-out orchestration By default all handlers for an event run **sequentially** in registration order. You can switch to **concurrent** fan-out where all handlers run in parallel: ```csharp services.AddSynapse(cfg => { cfg.SetEventOrchestrator(); // all handlers run in parallel cfg.RegisterEventHandler(); cfg.RegisterEventHandler(); }); ``` :::tip Choosing an orchestrator Use `ConcurrentEventOrchestrator` when handlers are independent and latency matters. Use the default `SequentialEventOrchestrator` when order matters or handlers share mutable state. ::: ### Batch publishing Emit a collection of events in one call: ```csharp var events = new[] { new TaskCreatedEvent(id1, "A"), new TaskCreatedEvent(id2, "B") }; await emitter.EmitBatchAsync(events, ct); ``` Each event is dispatched individually through its own handler chain. ### See also - [Outbox Pattern](./outbox) — defer event dispatch for transactional safety. - [Pipeline Behaviors](./pipelines) — add cross-cutting concerns to event handling. - [Context](./context) — the flow state an event handler can read, and why publishing is not part of it. --- ## 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. ::: --- ## ASP.NET Core Integration `UnambitiousFx.Synapse.AspNetCore` provides two invoker wrappers that translate `Result` to HTTP responses automatically, so endpoint handlers stay free of mapping boilerplate. ### Install **.NET CLI** ```bash dotnet add package UnambitiousFx.Synapse.AspNetCore ``` **PackageReference** ```xml ``` ### Setup ```csharp builder.Services.AddSynapse(cfg => { /* ... */ }); builder.Services.AddSynapseAspNetCore(); var app = builder.Build(); app.UseSynapsePropagation(); // optional — reads traceparent, writes traceresponse + Trace-Id ``` ### HTTP request flow ```mermaid flowchart LR HTTP["HTTP Request"] Endpoint["Endpoint Handler"] IHttpInvoker["IHttpInvoker"] IInvoker["IInvoker\n(Synapse core)"] Handler["IRequestHandler"] Result["Result<T>"] Mapper["IFailureHttpMapper"] IResult["IResult\n(HTTP response)"] HTTP --> Endpoint Endpoint --> IHttpInvoker IHttpInvoker --> IInvoker IInvoker --> Handler Handler --> Result Result -- success --> IResult Result -- failure --> Mapper Mapper --> IResult IResult --> HTTP ``` ### `IHttpInvoker` — Minimal API `IHttpInvoker` wraps `IInvoker` and converts `Result` to `IResult` using `IFailureHttpMapper` for failures: #### Default success mapping — `200 OK` ```csharp app.MapGet("/tasks/{id}", async ( Guid id, [FromServices] IHttpInvoker invoker, CancellationToken ct) => await invoker.InvokeAsync(new GetTaskQuery(id), ct)); // Success → 200 OK with the value serialized as JSON // Failure → mapped by IFailureHttpMapper (default: ProblemDetails) ``` #### Custom success mapping Pass a delegate to control the success response: ```csharp app.MapPost("/tasks", async ( [FromBody] CreateTaskCommand cmd, [FromServices] IHttpInvoker invoker, CancellationToken ct) => await invoker.InvokeAsync( cmd, id => Results.Created($"/tasks/{id}", id), // ← custom success mapper ct)); ``` #### Fire-and-forget commands ```csharp app.MapDelete("/tasks/{id}", async ( Guid id, [FromServices] IHttpInvoker invoker, CancellationToken ct) => await invoker.InvokeAsync(new DeleteTaskCommand(id), ct)); // Success → 200 OK (no body) // Failure → ProblemDetails ``` #### Streaming ```csharp app.MapGet("/tasks/stream", ( [FromServices] IHttpInvoker invoker, CancellationToken ct) => invoker.InvokeStreamAsync(new StreamTasksQuery(), ct)); // Returns IAsyncEnumerable — ASP.NET Core streams the JSON array ``` `IHttpInvoker.InvokeStreamAsync` unwraps `Result` automatically: successful items are yielded, failures are silently skipped. ### `IMvcInvoker` — Controller-based API Same contract as `IHttpInvoker` but returns `IActionResult` for use in MVC controllers: ```csharp [ApiController] [Route("tasks")] public class TasksController : ControllerBase { private readonly IMvcInvoker _invoker; public TasksController(IMvcInvoker invoker) => _invoker = invoker; [HttpGet("{id}")] public ValueTask Get(Guid id, CancellationToken ct) => _invoker.InvokeAsync(new GetTaskQuery(id), ct); [HttpPost] public ValueTask Create([FromBody] CreateTaskCommand cmd, CancellationToken ct) => _invoker.InvokeAsync(cmd, id => Created($"/tasks/{id}", id), ct); } ``` ### Failure mapping — `IFailureHttpMapper` By default, failures are mapped to [RFC 9457 ProblemDetails](https://www.rfc-editor.org/rfc/rfc9457) responses. Replace the mapper with a custom implementation by registering it as a singleton **before** `AddSynapseAspNetCore`: ```csharp builder.Services.AddSingleton(); builder.Services.AddSynapseAspNetCore(); ``` `IFailureHttpMapper` (from `UnambitiousFx.Functional.AspNetCore.Mappers`) has a single method, `FailureHttpResponse? GetFailureResponse(IFailure failure)`. Return a `FailureHttpResponse` carrying the status code and body (or `null` to fall through). The invokers turn it into the appropriate `IResult` / `IActionResult`: ```csharp using UnambitiousFx.Functional.AspNetCore.Mappers; public class MyFailureHttpMapper : IFailureHttpMapper { public FailureHttpResponse? GetFailureResponse(IFailure failure) { return failure switch { NotFoundFailure nf => new FailureHttpResponse { StatusCode = 404, Body = new { nf.Message } }, ValidationFailure vf => new FailureHttpResponse { StatusCode = 422, Body = new { vf.Message } }, _ => new FailureHttpResponse { StatusCode = 500, Body = new { failure.Message } }, }; } } ``` :::tip To override only a few failure types, subclass `DefaultFailureHttpMapper` (its `GetFailureResponse` is `virtual`) and call `base.GetFailureResponse(failure)` for the cases you don't handle. ::: ### Propagation middleware `UseSynapsePropagation()` recovers the flow identity of an incoming request from W3C trace context, and writes the resulting trace id back onto the response so callers can correlate across service boundaries: ```csharp app.UseSynapsePropagation(); ``` Add it early, before any endpoint runs. Identity comes from the inbound `traceparent` header: its trace id becomes `IContext.TraceId` and its span id becomes `IContext.CausationId`. The `baggage` header is recovered alongside it for business values (falling back to the pre-W3C `Correlation-Context` name when `baggage` is absent, so traffic from older ASP.NET Core services keeps its values). Synapse reads **no identity header of its own** — a request without `traceparent` simply starts a new flow with a trace id minted by `IContextFactory`. #### Headers | Direction | Header | Carries | | --- | --- | --- | | in | `traceparent` | trace id → `TraceId`, span id → `CausationId` | | in | `tracestate` | vendor trace data, passed through | | in | `baggage`, or `Correlation-Context` | business values | | out | `traceresponse` | `00---` — W3C Trace Context Level 2 | | out | `Trace-Id` | the bare 32-hex trace id | Two response headers, because they serve different readers. `traceresponse` is the standards-track one, so conformant tooling can continue the trace from a response. `Trace-Id` is for people: a bare trace id that pastes straight into a tracing backend, which is what an operator or a support ticket needs. W3C defines no header for that, because it is an operational convenience rather than a protocol need. Note the absent `X-` prefix — **RFC 6648 deprecated that convention in 2012**. Set `TraceIdHeaderName` to `X-Correlation-Id` if existing clients depend on the old name. Both are written only when a context was actually created during the request, so routes that never touch the mediator — static files, health checks — do not get a trace id invented for them. `traceresponse` additionally requires a current W3C activity: it reports the response's span, and with no span there is nothing to report. #### Options ```csharp app.UseSynapsePropagation(options => { options.TraceIdHeaderName = "X-Request-Trace"; // default: Trace-Id (response only) options.EmitTraceResponse = false; // default: true options.TrustIncomingHeader = false; // default: true options.ClientTraceIdBaggageKey = "client.trace_id"; }); ``` `HeaderName` names the **response** header only. The inbound header is `traceparent`, whose name is fixed by the W3C specification. #### Trusted and untrusted callers `TrustIncomingHeader` defaults to `true`, which is right for **service-to-service traffic behind a gateway**: continuing the caller's trace is what makes a flow traceable across services, and is the reason this middleware exists. Set it to `false` on an **internet-facing** application. A `traceparent` is chosen by the caller and is exactly as forgeable as any other client input, so a hostile client can pick a trace id that collides with another flow's and make your log correlation misleading. With trust off: - the trace id is minted server-side; - the inbound trace context is discarded, so the caller cannot decide this flow's identity; - inbound baggage is discarded too — it is caller-controlled text that would otherwise land in your log scope and be forwarded onwards; - the caller's trace id is preserved as baggage under `ClientTraceIdBaggageKey`, so it stays queryable without being trusted. Discarding the header is not enough on its own, which is worth knowing if you write your own boundary adapter. ASP.NET Core parses `traceparent` and parents the request `Activity` to it **before any middleware runs**, and `IContext` otherwise takes its identity from `Activity.Current` when nothing was propagated — so clearing the inbound trace context alone would let the caller supply identity through the ambient activity instead. The middleware also sets `PropagatedContext.SuppressAmbientTrace`, which disqualifies the ambient activity as a source of identity; a non-HTTP adapter that refuses inbound trace context must set it too. One consequence: in this mode `IContext.TraceId` deliberately differs from `Activity.Current.TraceId`, because Synapse cannot un-parent an activity the host created before the pipeline started. Your spans still carry the caller's trace id unless you configure your tracing exporter to distrust the header as well. Everything Synapse controls carries the **server-minted** id: `IContext.TraceId`, the `Trace-Id` and `traceresponse` response headers, the log scope, and what leaves the process — the `traceparent` written onto outbound `HttpClient` calls and captured into outbox entries. Propagating the ambient activity's id there instead would forward onward exactly the value this mode exists to refuse, and re-parent later outbox work into a trace a client picked. :::warning The trace id is a label for correlating logs and spans. It is never an authorization input, in either mode. Requiring W3C format changes nothing here — a client-supplied trace id is as forgeable as any other client-supplied value. ::: #### Browser callers For a browser to send trace context and read the trace id back, your CORS policy needs both sides — this is the usual reason a SPA cannot read the id back: ```csharp policy.WithHeaders("traceparent", "tracestate", "baggage") // Access-Control-Allow-Headers .WithExposedHeaders("traceresponse", "Trace-Id"); // Access-Control-Expose-Headers ``` The frontend generates `traceparent` with an OpenTelemetry JS propagator (or by hand). See [Propagation](./propagation.mdx) for outbound calls, message transports and the outbox. :::caution NativeAOT / Trim compatibility When publishing with `PublishAot=true` or full trimming, use `WebApplication.CreateSlimBuilder` and configure a JSON serializer context. Also use the [Source Generator](./source-generator) — the generated `RegisterGroup` implements `IEventDispatcherRegistration` and wires AOT-safe dispatch delegates automatically. ::: ### NativeAOT / Trim compatibility When publishing with `PublishSingleFile=true` or `PublishAot=true`, use `WebApplication.CreateSlimBuilder` and configure the JSON serializer context so all request/response types are included: ```csharp var builder = WebApplication.CreateSlimBuilder(args); builder.Services.ConfigureHttpJsonOptions(opts => opts.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonContext.Default)); [JsonSerializable(typeof(CreateTaskCommand))] [JsonSerializable(typeof(Guid))] [JsonSerializable(typeof(TaskDto))] internal partial class AppJsonContext : JsonSerializerContext { } ``` Also use the [Source Generator](./source-generator) — the generated `RegisterGroup` implements both `IRegisterGroup` and `IEventDispatcherRegistration`, so a single `AddRegisterGroup(new RegisterGroup())` call covers NativeAOT-safe polymorphic event dispatch. ### See also - [Getting Started](./getting-started) — full end-to-end example with Minimal API. - [Streaming](./streaming) — `IHttpInvoker.InvokeStreamAsync` details. - [Source Generator](./source-generator) — NativeAOT-safe handler registration. - [Context](./context) — `IContext.TraceId` and HTTP propagation. --- ## Benchmarks Synapse is built for low latency and low allocations on the hot path. This page compares it against two popular in-process mediators: - **[MediatR](https://github.com/jbogard/MediatR)** — reflection-based, the de-facto standard. - **[Mediator (martinothamar)](https://github.com/martinothamar/Mediator)** — source-generated, MediatR-like API. All three run the same scenarios from a single [BenchmarkDotNet](https://benchmarkdotnet.org/) suite in `benchmarks/SynapseBenchmark`. Numbers are reproducible — see [Running the benchmarks](#running-the-benchmarks) below. :::note Benchmarks measure dispatch overhead only — handlers are no-ops (a sum, or `Task.CompletedTask`). Real applications are dominated by handler work (I/O, DB), so these differences matter most in high-throughput, fan-out, or allocation-sensitive paths. ::: ### Environment ``` BenchmarkDotNet v0.15.8, macOS Tahoe 26.6 (25G72) [Darwin 25.6.0] Apple M5 Pro, 18 logical / 18 physical cores .NET SDK 10.0.302, .NET 10.0.10, Arm64 RyuJIT ``` Lifetimes: Synapse and MediatR use their defaults; **Mediator is registered as `Transient`** to mirror MediatR's transient handlers for an apples-to-apples DI cost. Mediator's recommended `Singleton` lifetime would lower its numbers further. ### Results Lower is better. **Ratio** is relative to Synapse (baseline `1.00`); **Alloc** is bytes allocated per operation. #### Send — request with response | Library | Mean | Ratio | Allocated | |---|---:|---:|---:| | Mediator | 18.6 ns | 0.88 | 88 B | | **Synapse** | **21.2 ns** | **1.00** | **72 B** | | MediatR | 28.4 ns | 1.34 | 200 B | | _Synapse (direct handler)_ | _7.1 ns_ | _0.33_ | _72 B_ | #### Send — request without response (void) | Library | Mean | Ratio | Allocated | |---|---:|---:|---:| | **Synapse** | **17.6 ns** | **1.00** | **0 B** | | Mediator | 22.4 ns | 1.27 | 88 B | | MediatR | 30.2 ns | 1.71 | 128 B | | _Synapse (direct handler)_ | _3.6 ns_ | _0.20_ | _0 B_ | Synapse allocates **zero bytes** dispatching a void request. #### Publish — one event, 5 handlers | Library | Mean | Ratio | Allocated | |---|---:|---:|---:| | Mediator | 74.2 ns | 0.57 | 184 B | | **Synapse** | **130.6 ns** | **1.00** | **520 B** | | MediatR | 357.1 ns | 2.74 | 2088 B | Mediator's source-generated fan-out leads here; both beat MediatR by a wide margin. #### Pipeline — request through 1 behavior | Library | Mean | Ratio | Allocated | |---|---:|---:|---:| | **Synapse** | **22.0 ns** | **1.00** | **72 B** | | Mediator | 29.4 ns | 1.34 | 240 B | | MediatR | 58.6 ns | 2.66 | 440 B | #### Pipeline — request through 3 behaviors | Library | Mean | Ratio | Allocated | |---|---:|---:|---:| | **Synapse** | **26.3 ns** | **1.00** | **72 B** | | Mediator | 51.9 ns | 1.97 | 496 B | | MediatR | 89.3 ns | 3.40 | 728 B | Synapse's pipeline allocation stays **flat at 72 B** regardless of behavior count, thanks to its cached, pre-composed delegate chain — competitors grow per added behavior. ### Takeaways - **Pipelines** — Synapse is fastest and flat-allocating; the gap widens as behaviors stack. - **Send (response)** — Mediator edges ahead on raw speed; Synapse allocates less. - **Send (void)** — Synapse beats Mediator (17.6 vs 22.4 ns) and allocates nothing. - **Publish** — Mediator's generated fan-out wins; Synapse is ~2.74× faster than MediatR. - All three vastly outperform reflection-based MediatR on both time and allocations. Pick based on your workload: pipeline-heavy / allocation-sensitive favors Synapse; raw publish throughput favors Mediator. ### Running the benchmarks ```bash cd benchmarks/SynapseBenchmark dotnet run -c Release # full suite, all libraries dotnet run -c Release -- --filter '*Pipeline*' # one category ``` The suite lives in a single class, `SynapseVsMediatorsBenchmarks`. Results land in `BenchmarkDotNet.Artifacts/results/`. --- ## Changelog This page records issues discovered and resolved while hardening the library. All entries are fully resolved — none represent open bugs. The detailed engineering notes for each fix live in [`docs/known-issues/`](https://github.com/UnambitiousFx/Synapse/tree/feature/typed-pipeline-behaviors/docs/known-issues) (mirrors the GitHub bug-report template; kept as a developer reference). ### Core DI | ID | Summary | Severity | |---|---|---| | [001](https://github.com/UnambitiousFx/Synapse/blob/main/docs/known-issues/001-open-generic-pipeline-behavior-aot-value-type.md) | Open-generic pipeline behavior registrations throw at runtime under Native AOT when `TResponse` is a value type | **High** | | [002](https://github.com/UnambitiousFx/Synapse/blob/main/docs/known-issues/002-validateonbuild-does-not-suppress-aot-open-generic-check.md) | `ValidateOnBuild = false` does not suppress the issue-001 runtime error | Medium | | [004](https://github.com/UnambitiousFx/Synapse/blob/main/docs/known-issues/004-validators-never-run-without-manual-behavior-registration.md) | `AddValidator` registers a validator that is never wired into the pipeline — validation silently never runs | **High** | | [015](https://github.com/UnambitiousFx/Synapse/blob/main/docs/known-issues/015-enablecqrsboundaryenforcement-is-a-silent-noop.md) | `EnableCqrsBoundaryEnforcement()` was an `[Obsolete]` no-op; runtime-API callers silently lost all enforcement | **High** | | [019](https://github.com/UnambitiousFx/Synapse/blob/main/docs/known-issues/019-behavior-dedup-is-lifetime-and-factory-blind.md) | Behavior dedup compared `(ServiceType, ImplementationType)` only — lifetime-blind, skipped factory/instance registrations | Medium | | [029](https://github.com/UnambitiousFx/Synapse/blob/main/docs/known-issues/029-scoped-icontext-snapshot-diverges-from-accessor.md) | Scoped `IContext` cached a boxed snapshot while `WithCorrelationId` returned a new box, so a pre-middleware reader and the response header could report different correlation IDs; identity is now fixed at creation via `IInboundContextStore` | Medium | | [035](https://github.com/UnambitiousFx/Synapse/blob/main/docs/known-issues/035-shared-context-stores-are-not-thread-safe.md) | `Context` held baggage and features in plain dictionaries while the concurrent event orchestrator runs every handler against that one instance, so entries were lost, the byte counter drifted and enumeration threw; both stores are now copy-on-write | **High** | | [036](https://github.com/UnambitiousFx/Synapse/blob/main/docs/known-issues/036-concurrent-accessor-reads-mint-diverging-trace-ids.md) | `ContextHandler` built its context lazily without synchronization, so concurrent handlers in one scope each minted their own trace id and only one won the field; creation is now locked and double-checked | **High** | | [070](https://github.com/UnambitiousFx/Synapse/blob/main/docs/known-issues/070-seteventoutboxstorage-always-registers-singleton.md) | `SetEventOutboxStorage` always registered the storage as Singleton, breaking scoped dependencies | Medium | | [071](https://github.com/UnambitiousFx/Synapse/blob/fix/outbox-storage-forwarding/docs/known-issues/071-eventoutboxstorage-and-concrete-storage-are-independent-instances.md) | `SetEventOutboxStorage()` registered `IEventOutboxStorage` as an independent descriptor for `T`, so a caller who also self-registered `T` (e.g. `AddEfCoreEventOutbox`) got two separate instances per scope | Medium | ### Source Generator | ID | Summary | Severity | |---|---|---| | [005](https://github.com/UnambitiousFx/Synapse/blob/main/docs/known-issues/005-generator-drops-enclosing-type-chain-for-nested-handlers.md) | Generator emits an uncompilable type name for nested handler/behavior classes (drops enclosing-type chain) | **High** | | [006](https://github.com/UnambitiousFx/Synapse/blob/main/docs/known-issues/006-open-generic-behavior-closed-with-wrong-arity.md) | Open-generic behavior closed with the interface's arity, not the class's own → CS0305 | **High** | | [012](https://github.com/UnambitiousFx/Synapse/blob/main/docs/known-issues/012-globalizetype-breaks-on-tuple-and-pointer-types.md) | `GlobalizeType` emits invalid `global::` prefix for tuple / pointer / function-pointer types | Medium | | [017](https://github.com/UnambitiousFx/Synapse/blob/main/docs/known-issues/017-splittoplevelargs-ignores-tuple-parentheses.md) | `SplitTopLevelArgs` ignored tuple `()` — a tuple nested as a generic argument emitted uncompilable code | Medium | | [022](https://github.com/UnambitiousFx/Synapse/blob/main/docs/known-issues/022-validator-attribute-picks-first-irequest-response.md) | `[Validator]` derived the response type from the first `IRequest` found; multi-`IRequest` requests now emit diagnostic MDG011 | Low | | [024](https://github.com/UnambitiousFx/Synapse/blob/main/docs/known-issues/024-open-generic-behavior-special-constraints-ignored.md) | Open-generic behavior special constraints (`class`/`struct`/`unmanaged`/`notnull`/`new()`) were dropped, closing the behavior over non-conforming handlers → uncompilable code (CS0453); now captured as equatable flags and enforced in the cross-product | **High** | | [025](https://github.com/UnambitiousFx/Synapse/blob/main/docs/known-issues/025-registergroup-namespace-ignores-rootnamespace.md) | Generated `RegisterGroup` namespace was derived from the assembly name, ignoring MSBuild `RootNamespace`; it now reads `build_property.RootNamespace` first (and a new `[RegisterGroup]` partial class lets users pick the namespace/name explicitly) | Medium | | [030](https://github.com/UnambitiousFx/Synapse/blob/main/docs/known-issues/030-generic-event-declarations-break-dispatcher-emission.md) | A generic `IEvent`/`IEventHandler` declaration was collected for dispatcher registration, emitting `typeof(Event)` — a type parameter not in scope — so `RegisterGroup.g.cs` did not compile; generic definitions are now skipped and the last hand-rolled globalizer was deleted | Medium | | [034](https://github.com/UnambitiousFx/Synapse/blob/main/docs/known-issues/034-event-nested-in-generic-type-breaks-dispatcher-emission.md) | Issue 030's guard tested the event's own arity, so a non-generic event nested in a generic type still emitted `typeof(Box.Opened)` and did not compile; the containing-type chain is now inspected too | Medium | | [069](https://github.com/UnambitiousFx/Synapse/blob/main/docs/known-issues/069-generator-ignores-constraints-on-own-type-parameters.md) | The generator ignored every generic constraint that references the behavior's own type parameters (such as `where TRequest : ICommand`), so a `[PipelineBehavior]` / `SynapseGlobalBehavior` behavior was closed over every handler and the generated code failed with CS0311; such constraints are now matched by their unbound generic definition against the handler request's and response's interfaces and base types | Medium | | [072](https://github.com/UnambitiousFx/Synapse/blob/worktree-syn105-global-behavior/docs/known-issues/072-referencedhandlers-counts-internal-types-as-handlers.md) | SYN102's `ReferencedHandlers()` scanned every type in a referenced assembly with no accessibility filter, so `Synapse.dll`'s own internal `ProxyRequestHandler<,>` registration plumbing was picked up as a real handler — silently defeating SYN102 for any open-generic behavior constrained to bare `IRequest`; the scan is now restricted to public types | Medium | ### Pipeline & CQRS | ID | Summary | Severity | |---|---|---| | [008](https://github.com/UnambitiousFx/Synapse/blob/main/docs/known-issues/008-cross-assembly-behavior-registered-and-runs-twice.md) | Cross-assembly open-generic behavior is registered (and runs) twice — no dedup on the user-behavior path | **High** | | [009](https://github.com/UnambitiousFx/Synapse/blob/main/docs/known-issues/009-behavior-order-not-honored-across-registration-sources.md) | `Order` is only a generator-local sort; runtime order follows DI registration across sources | Medium | | [013](https://github.com/UnambitiousFx/Synapse/blob/main/docs/known-issues/013-invoker-overloads-resolve-handlers-inconsistently.md) | `Invoker` overloads resolve handlers inconsistently (static `TRequest` vs runtime `request.GetType()`) | Medium | | [016](https://github.com/UnambitiousFx/Synapse/blob/main/docs/known-issues/016-cqrs-boundary-marker-leaks-on-handler-exception.md) | CQRS boundary marker leaked when a handler threw — spurious violation on later send in the same scope | Medium | | [018](https://github.com/UnambitiousFx/Synapse/blob/main/docs/known-issues/018-cqrs-enforcement-skips-generator-invisible-handlers.md) | CQRS enforcement was emitted per-discovered-handler only — manually registered handlers were not covered | Medium | | [021](https://github.com/UnambitiousFx/Synapse/blob/main/docs/known-issues/021-eventdispatcher-resorts-behaviors-every-publish.md) | `EventDispatcher` re-sorted behaviors via `OrderBy` on every publish (per-publish allocation) | Low | ### Outbox | ID | Summary | Severity | |---|---|---| | [010](https://github.com/UnambitiousFx/Synapse/blob/main/docs/known-issues/010-outbox-lookup-uses-reference-equality.md) | Outbox event lookup narrowed to `ReferenceEquals` — broke the public contract for equal-but-distinct events | Medium | | [014](https://github.com/UnambitiousFx/Synapse/blob/main/docs/known-issues/014-outbox-lookup-matches-value-equal-event-across-scopes.md) | Outbox lookup matched the first value-equal event across all scopes; stored items now carry a stable `Guid` identity via `OutboxEntry` | **High** | | [020](https://github.com/UnambitiousFx/Synapse/blob/main/docs/known-issues/020-outbox-failed-count-counts-retrying-events.md) | Outbox "failed count" counted retrying events (`Attempts > 0`), tripping the health check on transient retries | Medium | | [023](https://github.com/UnambitiousFx/Synapse/blob/main/docs/known-issues/023-recordoutbox-methods-are-dead-noops.md) | `RecordOutbox*` metric methods were dead no-ops on `ISynapseMetrics` (superseded by observable gauges); removed | Low | | [040](https://github.com/UnambitiousFx/Synapse/blob/main/docs/known-issues/040-no-traceparent-captured-without-an-activity.md) | `Inject` wrote trace context only when `Activity.Current` existed, so on a host with no tracing an outbox entry was stored with no `traceparent` and its dispatch became a disconnected root; the header is now derived from `IContext.TraceId` | Medium | | [041](https://github.com/UnambitiousFx/Synapse/blob/main/docs/known-issues/041-duplicate-stored-header-keys-abort-the-outbox-batch.md) | Rebuilding an entry's headers with `ToDictionary` threw on keys differing only by case, from outside the `try`, so one badly-shaped row aborted the whole batch with nothing marked processed or failed | Low | | [042](https://github.com/UnambitiousFx/Synapse/blob/feat/context-propagation/docs/known-issues/042-restored-outbox-flow-never-reaches-the-handlers-context.md) | Dispatch used the entry's restored flow only to parent the activity: the stored baggage was dropped and handlers read the committing scope's `IContext`, so entries were dispatched under whichever request called `CommitAsync`; each entry now gets its own scope built from its own state | **High** | | [068](https://github.com/UnambitiousFx/Synapse/blob/main/docs/known-issues/068-in-memory-outbox-leaks-events-of-failed-commands.md) | The in-memory outbox storage is not enlisted in any transaction, so events emitted by a command that then failed were dispatched by the next unrelated `CommitAsync`; an opt-in `OutboxDiscardOnFailureBehavior` now takes them back and a Production host logs a startup warning | Medium | ### Observability | ID | Summary | Severity | |---|---|---| | [007](https://github.com/UnambitiousFx/Synapse/blob/main/docs/known-issues/007-outbox-metrics-emit-nothing.md) | Outbox observable gauges are never registered; `Record*` are no-ops so outbox metrics emit nothing | **High** | | [011](https://github.com/UnambitiousFx/Synapse/blob/main/docs/known-issues/011-tracing-capture-stores-zero-trace-ids.md) | Tracing capture stores zero/invalid trace IDs (empty-string guard never fires) and stringifies twice | Medium | | [028](https://github.com/UnambitiousFx/Synapse/blob/main/docs/known-issues/028-cqrs-boundary-markers-leak-into-every-log-entry.md) | `LoggingEnrichmentBehavior` copied all context metadata into the log scope, so the internal `__CQRSBoundaryEnforcement` markers appeared in every log entry; the markers are now a context feature and enrichment reads typed identity plus baggage | Medium | | [031](https://github.com/UnambitiousFx/Synapse/blob/main/docs/known-issues/031-zero-ambient-trace-id-accepted-as-identity.md) | `ContextIdentity.ForUnitOfWork` read `Activity.Current.TraceId` without checking it was set, and an all-zeros trace id stringifies non-empty, so contexts carried `000…0`; the ambient id is now taken only when non-default | Low | | [033](https://github.com/UnambitiousFx/Synapse/blob/main/docs/known-issues/033-propagation-handler-never-sees-the-request-context.md) | `SynapsePropagationHandler` injected a scoped `IContextAccessor`, but `IHttpClientFactory` caches message handlers in a scope of its own, so outbound propagation silently stamped nothing; the context is now mirrored onto the execution context | **High** | | [037](https://github.com/UnambitiousFx/Synapse/blob/main/docs/known-issues/037-activity-baggage-leaks-onto-the-wire.md) | `Inject` let the platform propagator write its own baggage header from `Activity.Baggage`, so inbound baggage untrusted mode had dropped was forwarded anyway; the platform's baggage output is now discarded and the context is the only source | **High** | | [038](https://github.com/UnambitiousFx/Synapse/blob/main/docs/known-issues/038-comma-check-on-decoded-baggage-values.md) | `IsValidValue` refused commas in decoded values although values are percent-encoded, so `SetBaggage("k", "a,b")` was rejected and a conformant peer's `Acme%2C%20Inc` was silently dropped inbound; only control characters are refused now | Medium | | [039](https://github.com/UnambitiousFx/Synapse/blob/main/docs/known-issues/039-baggage-byte-accounting-is-wrong-twice.md) | `Parse` charged the byte budget for every repetition of a key even though a repetition overwrites, dropping valid entries after it, and `MeasureEntry` measured the decoded value so escaped-heavy baggage exceeded the limit on the wire | Medium | | [043](https://github.com/UnambitiousFx/Synapse/blob/feat/context-propagation/docs/known-issues/043-inject-propagates-the-activitys-trace-id-not-the-contexts.md) | `Inject` wrote the ambient activity's trace id rather than `IContext.TraceId`, so an untrusted edge propagated the caller's forged trace id onto outbound calls and into outbox entries; the context now wins when the two diverge | Medium | ### ASP.NET Core | ID | Summary | Severity | |---|---|---| | [003](https://github.com/UnambitiousFx/Synapse/blob/main/docs/known-issues/003-authorization-failure-maps-to-http-500.md) | Pipeline short-circuit via `Result.Failure()` mapped to HTTP 500; behavior now returns a typed `UnauthorizedFailure` → 401 | Medium | | [026](https://github.com/UnambitiousFx/Synapse/blob/main/docs/known-issues/026-empty-guid-accepted-as-inbound-correlation-id.md) | `Guid.Empty` was accepted as an inbound correlation ID, colliding with the "not set" sentinel and the uninitialized outbox partition; extraction now requires a non-empty Guid and rejects duplicated headers | Medium | | [027](https://github.com/UnambitiousFx/Synapse/blob/main/docs/known-issues/027-correlation-id-fabricated-on-non-mediator-routes.md) | The response writer read `IContextAccessor.Context`, which creates a context, so non-mediator routes got a fabricated correlation ID and the guarding `catch` was unreachable; a new `IsInitialized` lets it skip instead | Low | | [032](https://github.com/UnambitiousFx/Synapse/blob/main/docs/known-issues/032-untrusted-mode-still-adopts-caller-trace-id.md) | `TrustIncomingHeader = false` cleared the inbound trace context but the host had already parented the request `Activity` to the caller, so the forged trace id was read back from `Activity.Current`; a new `SuppressAmbientTrace` flag disqualifies the ambient activity | **High** | --- > **Discovery context:** Issues 001–003 were found while building the pipeline-behavior showcase in > `examples/MinimalApi` on branch `feature/typed-pipeline-behaviors` against .NET 10 with > `PublishAot=true`. Issues 004–023 were found in successive high-effort code reviews of the same > branch (diff + working-tree, multiple finder angles). All are resolved. Line numbers in the > individual files are approximate and may drift as the branch evolves. --- ## Context `IContext` is a per-DI-scope object that flows through every step of a request's lifecycle — the handler, all pipeline behaviors, and any events published during that request. It carries three separate surfaces, and which one you reach for is decided by a single question: **does this value need to leave the process?** | Surface | Type | Crosses process boundaries | Use for | | ------------ | -------------------------- | -------------------------- | ------------------------------------------------ | | **Identity** | typed properties | yes, always | the flow ids and timestamp | | **Baggage** | `string` → `string`, capped | yes, always | small business values (`tenant.id`, `user.id`) | | **Features** | `IContextFeature` | never | process-local typed state | The split is deliberate. A single untyped `string → object` bag cannot answer that question — anything in it might or might not be serializable, might or might not be safe to send to another service — so the three surfaces make the answer a property of *where you put the value* rather than a convention you have to remember. ### Lifecycle One `IContext` instance is created per DI scope. In an ASP.NET Core application, one HTTP request equals one DI scope, so each request gets its own context: ```mermaid flowchart LR Scope["DI Scope\n(HTTP request)"] Factory["IContextFactory\n.Create()"] Accessor["IContextAccessor\n.Context"] Handler["Handler / Behavior"] Event["Event Handler"] Scope --> Factory Factory --> Accessor Accessor --> Handler Accessor --> Event ``` The context is created lazily on first access via `IContextFactory`, then cached on `IContextAccessor` for the lifetime of the scope. ### Flow identity Identity **is** W3C trace context — not a parallel scheme that sits beside it. `TraceId` is the 32-character hex trace id, byte for byte what your tracing backend displays, so one search string works in both your application logs and Jaeger, Tempo or Application Insights. | Property | Type | Lifetime | Purpose | | ------------ | ----------------- | ----------------------------------- | --------------------------------------------------------------------------- | | `TraceId` | `string` | constant for the whole business flow | groups every request, message and retry stemming from one originating action | | `CausationId` | `string?` | the immediate predecessor's span id | builds the causality **tree**; `null` at the root of a flow | | `OccurredAt` | `DateTimeOffset` | fixed at creation | when this unit of work entered the system | ```csharp public class MyHandler : IRequestHandler { private readonly IContext _context; public MyHandler(IContext context) => _context = context; public ValueTask> HandleAsync(MyCommand cmd, CancellationToken ct = default) { var traceId = _context.TraceId; // the whole flow — paste this into Jaeger var causedBy = _context.CausationId; // the span that caused this unit of work return ValueTask.FromResult(Result.Success(traceId)); } } ``` #### Where `TraceId` comes from Three sources, in order: 1. the inbound `traceparent` header, parsed at the boundary; 2. `Activity.Current.TraceId`, when an activity exists; 3. a freshly minted trace id. Step 3 is why `AddSynapse` works with **no** OpenTelemetry configured. With no registered `ActivityListener`, `ActivitySource.StartActivity` returns `null` and `Activity.Current` stays null, so a purely computed property would leave you with no id at all — no correlation in the log scope and an empty outbox partition key. `TraceId` is therefore never null or empty. :::note It equals the ambient activity Whenever an activity exists, `context.TraceId == Activity.Current.TraceId.ToHexString()` — because that is where the value came from. It is captured once at context creation rather than read on each access, so it also stays stable if an unrelated root activity starts mid-request, and it avoids re-allocating the hex string on every read. ::: :::note When is `CausationId` set? It is the **sender's span id**, taken from the inbound `traceparent`. Two consequences: - It is `null` when nothing upstream was recording. Causation is a diagnostic nicety; `TraceId` is always present. - One context exists per DI scope, and that context *is* the unit of work. An event published with `EmitMode.Now` is dispatched inside the same scope, so nothing was crossed and `CausationId` does not change. It becomes meaningful when a **boundary** is crossed and a new context is built — an event dispatched from the outbox, a message consumed from a broker, or an inbound HTTP call. ::: Identity is recovered at an inbound boundary and stamped onto outbound calls automatically — see [Propagation](./propagation.mdx). ### Baggage Baggage is the only free-form state that crosses process boundaries. It is propagated as the W3C `baggage` header, so it is string-keyed, string-valued and size-limited. ```csharp // Returns false if the entry was rejected — see the limits below context.SetBaggage("tenant.id", "acme"); if (context.TryGetBaggage("tenant.id", out var tenantId)) Console.WriteLine(tenantId); var tenantId = context.GetBaggage("tenant.id"); // null if missing ``` `SetBaggage` returns `bool` rather than throwing. W3C caps baggage at **64 entries and 8192 bytes** (`BaggageLimits`), and an entry that would exceed either cap, or whose key or value cannot be serialized into a header, is refused. Rejection is reported rather than raised so that oversized *inbound* baggage from a peer degrades instead of failing the request. If you care whether an entry made it, check the return value and log it. The byte budget counts the **percent-encoded** form, because that is what the header carries: a value full of commas or accented characters costs up to three bytes per byte. Keys must be header tokens — no commas, equals signs, whitespace or control characters — while values may contain anything but control characters, since they are escaped on the way out. :::warning Baggage travels to **every** downstream service, including third parties outside your control, and it persists across hops Synapse does not manage. Never put confidential values in it — no tokens, no personal data, no internal identifiers you would not put in a URL. ::: For state that must stay in this process, use a [feature](#feature-bag) instead. Features are never serialized. ### Context factories Two built-in factories are available. Configure them in `AddSynapse`: | Factory | What it creates | | ----------------------- | ---------------------------------------------------------------------- | | `DefaultContextFactory` | Flow identity from trace context, plus any inbound baggage. | | `SlimContextFactory` | The same identity, but inbound baggage is **discarded**. | Both take the inbound state as a parameter — `IContextFactory.Create(PropagatedContext inbound)` — so identity is decided **when the context is created**, never patched onto it afterwards. See [Propagation](./propagation.mdx#why-extraction-happens-at-creation) for why that matters. `SlimContextFactory` differs only in dropping baggage: there is no cheaper trace id to generate, so identity is identical. Choose it when the application does not propagate business values and does not want to pay for parsing and holding them. It has to be selected explicitly, so dropping baggage is a stated decision rather than a surprise. :::note The context does not snapshot trace or span ids. Distributed tracing state lives on `Activity.Current`, which is ambient (`AsyncLocal`) and always reflects the *current* span — a snapshot taken at context creation would go stale as pipeline stages start child activities. Read `Activity.Current` directly, or use the `Unambitious.Synapse` `ActivitySource` (see [Observability](./observability.mdx)). Trace context still travels across boundaries; it is just not stored on the context. See [Propagation](./propagation.mdx#one-identity-w3c-trace-context). ::: ```csharp services.AddSynapse(cfg => { cfg.UseDefaultContextFactory(); // default — no need to call if this is what you want // or cfg.UseSlimContextFactory(); // or cfg.UseContextFactory(); }); ``` ### Feature bag `IContextFeature` provides a typed extension point for attaching domain-specific data to the context without modifying `IContext` itself. #### Define a feature ```csharp public class UserFeature : IContextFeature { public string Name => "User"; public string UserId { get; init; } = ""; public string[] Roles { get; init; } = []; } ``` #### Set the feature Set it in a pipeline behavior before the handler runs, using `SetFeature` (the feature bag — distinct from the metadata bag). Make the behavior open-generic and mark it `[PipelineBehavior]` so the source generator applies it to every matching handler: ```csharp [PipelineBehavior] public sealed class AuthenticationBehavior : IRequestPipelineBehavior where TRequest : IRequest where TResponse : notnull { private readonly IContextAccessor _contextAccessor; private readonly ICurrentUser _currentUser; public AuthenticationBehavior(IContextAccessor contextAccessor, ICurrentUser currentUser) { _contextAccessor = contextAccessor; _currentUser = currentUser; } public ValueTask> HandleAsync( TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken = default) { _contextAccessor.Context.SetFeature(new UserFeature { UserId = _currentUser.Id, Roles = _currentUser.Roles }); return next(request, cancellationToken); } } ``` See [Pipeline Behaviors](./pipelines) for the full behavior model, registration, and ordering. #### Read the feature ```csharp // Try to get feature (returns false if not set) if (context.TryGetFeature(out var user)) Console.WriteLine(user.UserId); // Get or null var user = context.GetFeature(); // Get or throw MissingContextFeatureException var user = context.MustGetFeature(); ``` ### Publishing events from handlers The context carries flow state; it does not publish. Inject `IEmitter` to publish and `IOutboxCommit` to commit: ```csharp public class CreateTaskHandler(IEmitter emitter, IOutboxCommit outboxCommit) { ... } await emitter.EmitAsync(new TaskCreatedEvent(id, cmd.Title), ct); // With explicit emit mode await emitter.EmitAsync(new TaskCreatedEvent(id, cmd.Title), EmitMode.Outbox, ct); // Dispatch what the outbox is holding await outboxCommit.CommitAsync(ct); ``` `IContext` used to expose `PublishEventAsync` and `CommitEventsAsync` as sugar over these two. They were removed in v2: the sugar made the context depend on the publish stack, which in turn forced the outbox to reach the current flow through ambient `AsyncLocal` state instead of taking the accessor as a dependency. See [Migrating to v2](./migration-v2.mdx#5-icontext-no-longer-publishes-events). ### Injecting the context `IContext` and `IContextAccessor` are both registered in DI, and both resolve to the same object for the scope: ```csharp // The usual case — inject IContext directly public class MyHandler(IContext context) : IRequestHandler { ... } // Inject IContextAccessor when you need to know whether a context exists yet public class MyMiddleware(IContextAccessor contextAccessor) { ... } ``` `IContextAccessor` adds one thing over `IContext`: `IsInitialized`. Reading `Context` is what *creates* the context, so a component that only wants to observe a context that already exists — a response-header writer, an outbound HTTP handler — must ask `IsInitialized` first. Reading `Context` to find out would always answer "yes". The context cannot be replaced once created. Inbound identity is supplied to the factory instead, via `IInboundContextStore` — see [Propagation](./propagation.mdx#why-extraction-happens-at-creation). ### See also - [Propagation](./propagation.mdx) — carrying flow identity across HTTP calls, messages and the outbox. - [ASP.NET Core Integration](./aspnetcore) — `UseSynapsePropagation()` middleware. - [Pipeline Behaviors](./pipelines) — set context features in behaviors before the handler runs. - [Events](./events) — `IEmitter.EmitAsync` and `EmitMode`. --- ## 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. --- ## Examples Runnable projects in the [`examples/`](https://github.com/UnambitiousFx/Synapse/tree/main/examples) directory demonstrate Synapse features in context. Each project builds and runs stand-alone from the repo root. ### Overview | Project | Type | What it demonstrates | |---------|------|----------------------| | [`GettingStarted`](https://github.com/UnambitiousFx/Synapse/tree/main/examples/GettingStarted) | Console (step-by-step tutorial) | Commands, queries, events, streaming, pipelines, context, error handling, validation — organized as Steps 1–7 | | [`MinimalApi`](https://github.com/UnambitiousFx/Synapse/tree/main/examples/MinimalApi) | ASP.NET Core + Native AOT | Canonical integration tour: all core features + pipeline ordering + CQRS enforcement + cross-assembly composition | | [`MinimalApi.Modules.Contracts`](https://github.com/UnambitiousFx/Synapse/tree/main/examples/MinimalApi.Modules.Contracts) | Class library (shared contract) | Shared `IEvent` contract — the decoupling seam between Orders and Notifications | | [`MinimalApi.Modules.Orders`](https://github.com/UnambitiousFx/Synapse/tree/main/examples/MinimalApi.Modules.Orders) | Class library (module) | Emitter module using `[RequestHandler<>]` + source-generated `RegisterGroup`; publishes `OrderPlacedEvent` | | [`MinimalApi.Modules.Notifications`](https://github.com/UnambitiousFx/Synapse/tree/main/examples/MinimalApi.Modules.Notifications) | Class library (module) | Subscriber module using **manual** `cfg.RegisterEventHandler<>()` — no source generator | --- ### Running an example ```bash # Console tutorial cd examples/GettingStarted && dotnet run # ASP.NET Core (full feature tour) cd examples/MinimalApi && dotnet run ``` All examples reference Synapse via local project references, so they always build against the current source. --- ### Modular monolith: decoupled cross-assembly events The `MinimalApi.Modules.*` projects demonstrate a **modular-monolith** pattern where two feature modules communicate through domain events without knowing about each other. #### Topology ```mermaid graph LR H[MinimalApi host] -->|AddRegisterGroup| O[Orders module] H -->|AddNotificationsHandlers| N[Notifications module] O -->|references| C[Contracts\nOrderPlacedEvent] N -->|references| C O -. no reference .-> N ``` Neither `Orders` nor `Notifications` references the other. Both depend only on `Contracts` which holds `OrderPlacedEvent : IEvent`. #### Registration: source-gen vs. manual — side by side The two modules use different registration styles to highlight that both are first-class options. **Orders (source generator)** ```csharp // In MinimalApi.Modules.Orders — handler carries the attribute [RequestHandler] public sealed class PlaceOrderCommandHandler : IRequestHandler { public async ValueTask> HandleAsync(PlaceOrderCommand request, CancellationToken ct) { var orderId = _store.Place(request.Product); await _emitter.EmitAsync(new OrderPlacedEvent { OrderId = orderId, ... }, ct); return Result.Success(orderId); } } // In Program.cs (host) — one call wires everything the generator discovered cfg.AddRegisterGroup(new global::UnambitiousFx.Examples.MinimalApi.Modules.Orders.RegisterGroup()); ``` **Notifications (manual registration)** ```csharp // In MinimalApi.Modules.Notifications — NO [EventHandler] attribute public sealed class OrderPlacedNotificationHandler : IEventHandler { public ValueTask HandleAsync(OrderPlacedEvent @event, CancellationToken ct) { _log.Add(new NotificationEntry { OrderId = @event.OrderId, ... }); return ValueTask.FromResult(Result.Success()); } } // In NotificationsModule.cs — explicit extension method the host calls public static ISynapseConfig AddNotificationsHandlers(this ISynapseConfig cfg) { // Registers the DI service AND the AOT-safe dispatch delegate in one call. cfg.RegisterEventHandler(); return cfg; } // In Program.cs (host) cfg.AddNotificationsHandlers(); ``` #### Event flow ``` POST /orders { "product": "Widget", "quantity": 2 } └─ PlaceOrderCommandHandler [Orders module] └─ IEmitter.EmitAsync(new OrderPlacedEvent { ... }) └─ OrderPlacedNotificationHandler [Notifications module] └─ NotificationLog.Add(entry) GET /notifications └─ Returns [ { orderId, product, quantity, receivedAt }, ... ] ``` #### Key files | File | Role | |------|------| | [`MinimalApi.Modules.Contracts/OrderPlacedEvent.cs`](https://github.com/UnambitiousFx/Synapse/blob/main/examples/MinimalApi.Modules.Contracts/OrderPlacedEvent.cs) | Shared `IEvent` record | | [`MinimalApi.Modules.Orders/Handlers/PlaceOrderCommandHandler.cs`](https://github.com/UnambitiousFx/Synapse/blob/main/examples/MinimalApi.Modules.Orders/Handlers/PlaceOrderCommandHandler.cs) | Source-gen handler, emits event | | [`MinimalApi.Modules.Orders/OrdersModule.cs`](https://github.com/UnambitiousFx/Synapse/blob/main/examples/MinimalApi.Modules.Orders/OrdersModule.cs) | `AddOrdersModule()` + `MapOrdersEndpoints()` | | [`MinimalApi.Modules.Notifications/Handlers/OrderPlacedNotificationHandler.cs`](https://github.com/UnambitiousFx/Synapse/blob/main/examples/MinimalApi.Modules.Notifications/Handlers/OrderPlacedNotificationHandler.cs) | Manual handler, no attribute | | [`MinimalApi.Modules.Notifications/NotificationsModule.cs`](https://github.com/UnambitiousFx/Synapse/blob/main/examples/MinimalApi.Modules.Notifications/NotificationsModule.cs) | `AddNotificationsHandlers(ISynapseConfig)` + endpoint | | [`MinimalApi/Http/modules.http`](https://github.com/UnambitiousFx/Synapse/blob/main/examples/MinimalApi/Http/modules.http) | HTTP test file — place order, then verify notification | :::tip Open `examples/MinimalApi/Http/modules.http` in your IDE's HTTP client, run the requests top-to-bottom, and observe the cross-module log lines in the terminal to confirm the event was delivered. ::: --- ## Migrating to v2 Synapse v2 reshapes `IContext` so that flow identity can travel out of the application and be recovered on the way in. See [Propagation](./propagation.mdx) for the feature itself; this page is the mechanical upgrade. Seven changes affect existing code: 1. the metadata bag is gone, replaced by typed identity, baggage and features; 2. **`CorrelationId` is gone** — identity is now the W3C trace id; 3. a context's identity is fixed at creation — `WithCorrelationId` and `IContextSetter` are gone; 4. `IEventOutboxStorage.AddAsync` takes propagation headers; 5. **`IContext` no longer publishes events** — `PublishEventAsync` and `CommitEventsAsync` are gone, and so is `CorrelationContext`; 6. `UseCorrelationId` is now `UseSynapsePropagation`, and reads `traceparent` instead of `X-Correlation-Id`; 7. the two deprecated CQRS-enforcement opt-ins are gone — `[assembly: EnableSynapseCqrsBoundaryEnforcement]` and `cfg.EnableCqrsBoundaryEnforcement()`. ### 1. The metadata bag is gone `IContext.Metadata`, `SetMetadata`, `GetMetadata`, `TryGetMetadata` and `RemoveMetadata` have been removed. A single `string → object` bag could not answer the one question that matters at a process boundary — *is this value safe and possible to serialize?* — so it has been split into three surfaces with fixed propagation semantics. See [Context](./context.mdx). | v1 | v2 | | --- | --- | | `context.SetMetadata("TenantId", "acme")` | `context.SetBaggage("tenant.id", "acme")` | | `context.GetMetadata("TenantId")` | `context.GetBaggage("tenant.id")` | | `context.TryGetMetadata(k, out v)` | `context.TryGetBaggage(k, out v)` | | `context.RemoveMetadata(k)` | `context.RemoveBaggage(k)` | | `context.GetMetadata("OccuredAt")` | `context.OccurredAt` — now typed, and the spelling is fixed | | metadata holding process-local state | an [`IContextFeature`](./context.mdx#feature-bag) | Two behaviour changes to be aware of: - **Baggage is `string → string`, not `string → object`.** Serialize richer values yourself, or use a feature if the value never leaves the process. - **`SetBaggage` can refuse an entry.** Where `SetMetadata` was `void` and always stored, `SetBaggage` returns `bool`: it refuses entries that would exceed the W3C limits of 64 entries / 8192 bytes, or whose key or value cannot go in a header. Check the result if you care. If a value was in metadata purely so it would show up in logs, baggage is the right home — `LoggingEnrichmentBehavior` surfaces baggage as `Baggage_`, replacing v1's `Metadata_`. It no longer dumps arbitrary metadata into the log scope, which also means internal framework markers — the `Tracing.*` entries and the CQRS boundary flags — stop appearing in every log entry. ### 2. Identity is the W3C trace id `Guid CorrelationId` is gone. `IContext` now exposes: | v1 | v2 | | --- | --- | | `Guid CorrelationId` | `string TraceId` — the 32-character hex W3C trace id | | *(nothing)* | `string? CausationId` — the caller's 16-character hex span id, new in v2 | | *(nothing)* | `DateTimeOffset OccurredAt` — was the `"OccuredAt"` metadata entry | The rename is not cosmetic. The value **is** the trace id your tracing backend shows, so one search string works in both your logs and Jaeger, Tempo or Application Insights. Keeping the name `CorrelationId` for it would have been a synonym for something the platform already names. `CausationId` has no v1 counterpart: v1 tracked only the flat flow id, so nothing recorded which specific caller produced a unit of work. `traceparent` already carries the sender's span id, so v2 surfaces it rather than minting an identifier of its own. ```csharp // v1 _logger.LogInformation("Handling {CorrelationId}", context.CorrelationId); // v2 — paste the logged value straight into your tracing backend _logger.LogInformation("Handling {TraceId}", context.TraceId); ``` Consequences worth knowing: - **`CausationId` is `null` when nothing upstream was recording.** It comes from the inbound `traceparent`, so a caller with no tracing wired supplies none. Causation is a diagnostic nicety; `TraceId` is always populated. - **`TraceId` is never null or empty**, even with no OpenTelemetry configured. It is sourced from the inbound `traceparent`, then `Activity.Current`, then minted — so the zero-configuration host still correlates. Whenever an activity exists, `TraceId == Activity.Current.TraceId.ToHexString()`. - **`CorrelationContext` is gone entirely** — with it the `AsyncLocal` its only member, `CurrentCorrelationId`, exposed. Read identity from an injected `IContext`, and ask `IContextAccessor.IsInitialized` when you need to know whether one exists. See [section 5](#5-icontext-no-longer-publishes-events) for why. If you implemented `IEventOutboxStorage` with a `Guid`-keyed partition — as the built-in in-memory storage did, keyed on `CorrelationContext.CurrentCorrelationId` — drop the partitioning: entries carry their own `Headers`, which is what ties each one back to the flow that produced it. - **Synapse no longer copies tracing state into the context bag.** v1 wrote `Tracing.TraceId`, `Tracing.SpanId`, `Tracing.ParentSpanId` and `Tracing.Baggage.` into metadata. `TraceId` and `CausationId` are now first-class, and baggage carries business values only. The one entry Synapse writes is `client.trace_id`, and only at an untrusted edge (see [section 6](#6-usecorrelationid-is-now-usesynapsepropagation)). ### 3. Identity is fixed at creation Removed: `IContext.WithCorrelationId(Guid)`, and the whole `IContextSetter` interface. `IContextAccessor.Context` is now get-only. In v1 a boundary adapter would create a context and then patch the correlation id onto it. Because `IContext` is a scoped DI registration, whichever component resolved it first pinned the instance — so patching left earlier readers holding the old identity while later ones saw the new one. Handlers could log one correlation id while the response header reported another. In v2, inbound state goes into `IInboundContextStore` and the factory consumes it: ```csharp // v1 var setter = services.GetRequiredService(); setter.Context = setter.Context.WithCorrelationId(incomingId); // v2 var store = services.GetRequiredService(); store.Inbound = propagator.Extract(carrier); // trace context + baggage ``` The store only has to be populated before the first component *reads* the context, so ordering against other middleware no longer matters. #### Custom context factories `IContextFactory.Create()` is now `Create(PropagatedContext inbound)`. `PropagatedContext` is new in v2 — a readonly record struct holding the inbound trace context plus baggage, with `PropagatedContext.None` for a unit of work that starts in this process: ```csharp public sealed class MyContextFactory : IContextFactory { public IContext Create(PropagatedContext inbound) { // ForUnitOfWork applies the sourcing rules: inbound traceparent, then Activity.Current, then minted var identity = ContextIdentity.ForUnitOfWork(inbound); // ... build your context, then copy inbound.Baggage onto it } } ``` Use `ContextIdentity.ForUnitOfWork(inbound)` rather than deriving the trace id yourself — it is what guarantees the id is never empty in a host with no `ActivityListener`, and that it matches `Activity.Current` when one exists. A custom factory also no longer needs `IEmitter` or `IOutboxCommit` constructor dependencies — see [section 5](#5-icontext-no-longer-publishes-events). If yours took them only to hand to the context, delete them. #### New: `IContextAccessor.IsInitialized` Custom `IContextAccessor` implementations must add `IsInitialized`. It reports whether a context exists *without* creating one — reading `Context` is what creates it, so a component that only wants to observe an existing context has no other way to ask. ### 4. Outbox storage takes headers `IEventOutboxStorage.AddAsync` gained a `headers` parameter, and `OutboxEntry` gained a `Headers` property: ```csharp // v1 ValueTask AddAsync(TEvent @event, CancellationToken ct = default) // v2 ValueTask AddAsync(TEvent @event, IReadOnlyDictionary headers, CancellationToken ct = default) ``` :::warning A custom `IEventOutboxStorage` **must persist `headers` alongside the payload** and return them on `OutboxEntry`. They hold the trace context and flow identity captured when the event was stored — by dispatch time the producing request is gone, so this is the only record of what caused the entry. Dropping them silently breaks the chain at exactly the boundary propagation exists to cross. ::: `OutboxEntry` keeps a two-argument constructor that supplies empty headers, for tests and storage implementations that genuinely have nothing to record. ### 5. `IContext` no longer publishes events Removed: `IContext.PublishEventAsync` (both overloads), `IContext.CommitEventsAsync`, and the `CorrelationContext` static class. Inject `IEmitter` and `IOutboxCommit` instead: | v1 | v2 | | --- | --- | | `context.PublishEventAsync(evt, ct)` | `emitter.EmitAsync(evt, ct)` | | `context.PublishEventAsync(evt, EmitMode.Outbox, ct)` | `emitter.EmitAsync(evt, EmitMode.Outbox, ct)` | | `context.CommitEventsAsync(ct)` | `outboxCommit.CommitAsync(ct)` | | `CorrelationContext.CurrentCorrelationId` | `context.TraceId` on an injected `IContext` | The two methods were only ever sugar — they forwarded straight to `IEmitter` and `IOutboxCommit` — but the sugar cost more than it saved. Holding those two dependencies put the context *downstream of the publish stack*, which closed a dependency cycle: `IContext → IContextFactory → IEmitter → IOutboxManager`. The outbox needs the current flow to capture propagation headers when it stores an event, so with that cycle in place it could not take `IContextAccessor` as a dependency, and reached for ambient `AsyncLocal` state instead. That is what `CorrelationContext` was. Ambient state is not equivalent to the scoped context, and the difference is a real bug rather than a stylistic one: an `AsyncLocal` flows across DI scopes, so a nested or sibling scope could read the correlation id of a *different* unit of work and store an event stamped with the wrong flow. In v2 the context is a plain data holder, the cycle does not exist, and the outbox injects the accessor like anything else. :::note `IEmitter` was always the documented way to publish, and is what the examples already used, so most code needs no change. If a handler injected `IContext` *only* to publish, swap the dependency for `IEmitter`. ::: ### 6. `UseCorrelationId` is now `UseSynapsePropagation` The whole "correlation" vocabulary is gone from the API, because the value it named is the W3C trace id and one value should not answer to two names: | v1 | v2 | | --- | --- | | `app.UseCorrelationId()` | `app.UseSynapsePropagation()`, optionally `UseSynapsePropagation(o => …)` | | *(no options — everything hardcoded)* | `PropagationOptions`, new in v2 | | header name hardcoded to `X-Correlation-Id` | `options.TraceIdHeaderName`, defaulting to `PropagationOptions.DefaultTraceIdHeaderName` (`"Trace-Id"`) | | response header `X-Correlation-Id` | response header `Trace-Id`, plus `traceresponse` | The behaviour changed materially too: - **The inbound `X-Correlation-Id` header is no longer read.** Identity comes from `traceparent` only. Callers that relied on sending `X-Correlation-Id` must send `traceparent` instead, or they will start a new flow. This is the one change likely to need coordination with clients. - **The response header is now `Trace-Id`**, not `X-Correlation-Id`. Anything reading the old name must be updated, or set `options.TraceIdHeaderName = "X-Correlation-Id"` to keep it. - **A second response header, `traceresponse`, is now emitted** — the W3C Trace Context Level 2 form, `00---`. Disable it with `options.EmitTraceResponse = false`. It is written only when a W3C activity exists, since it reports the response's span. - The bare-trace-id response header now carries a 32-hex string rather than a dashed `Guid`. Anything parsing it as a `Guid` needs updating. - **Inbound baggage falls back to the pre-W3C `Correlation-Context` header** when `baggage` is absent, so business values from older ASP.NET Core services are no longer silently dropped. Only the W3C name is written. - The response header is written **only** when a context was created during the request. In v1 every static-file and health-check response got a freshly invented correlation id. - Baggage and trace context are recovered, and the outbox dispatch now **opens a span parented on the stored trace context** — v1 opened no span for an outbox dispatch at all — so the request that stored an entry and the work that results from it read as one trace. New options are available for the response header name and untrusted-edge behaviour — see [ASP.NET Core Integration](./aspnetcore#options). ### 7. The deprecated CQRS enforcement opt-ins are removed Both v1 ways of turning on CQRS boundary enforcement carried `[Obsolete]` and are now deleted: | v1 | v2 | | --- | --- | | `[assembly: EnableSynapseCqrsBoundaryEnforcement]` | `[assembly: SynapseGlobalBehavior(typeof(CqrsBoundaryEnforcementBehavior<>))]` **and** `[assembly: SynapseGlobalBehavior(typeof(CqrsBoundaryEnforcementBehavior<,>))]` | | `cfg.EnableCqrsBoundaryEnforcement()` | `cfg.RegisterCqrsBoundaryEnforcement()` / `()` | The behaviour itself is unchanged — `CqrsBoundaryEnforcementBehavior<…>` still runs outermost and still throws `CqrsBoundaryViolationException`. Only the opt-in spelling changed. See [Pipelines](./pipelines.mdx#cqrs-boundary-enforcement). **The assembly attribute** was already documented as an alias in v1: it did nothing the two `SynapseGlobalBehavior` attributes do not, but it forced the generator to carry a second, CQRS-only path alongside the general one. Removing it is source-breaking — the type no longer exists, so an assembly still carrying it fails to compile. That is the intended failure: a silently-ignored attribute would drop all enforcement, which is the bug [known issue 015](https://github.com/UnambitiousFx/Synapse/blob/main/docs/known-issues/015-enablecqrsboundaryenforcement-is-a-silent-noop.md) was about. Note that the two attributes are **not** interchangeable one-for-one: you need both, the one-generic-parameter form for requests with no response and the two-parameter form for requests that return one. The single v1 attribute covered both. **The runtime method** was `[Obsolete(error: true)]` in v1 and its body threw `NotSupportedException`, so no code that compiled could call it — nothing that works today breaks. It is replaced by the per-request `RegisterCqrsBoundaryEnforcement<…>()`, which emits a closed (Native-AOT safe) registration; the removed method registered an open generic, which cannot close over a value-type response under Native AOT ([known issue 001](https://github.com/UnambitiousFx/Synapse/blob/main/docs/known-issues/001-open-generic-pipeline-behavior-aot-value-type.md)). Use it for handlers the generator cannot see — those registered manually at runtime, or living in an assembly it does not scan. ### Checklist - [ ] Replace `*Metadata` calls with baggage, typed identity, or a feature. - [ ] Rename `OccuredAt` reads to the `OccurredAt` property. - [ ] Replace `CorrelationId` reads with `TraceId`. - [ ] Update anything that treated the correlation id as a `Guid` — it is now a 32-hex `string`. - [ ] Make browser and service callers send `traceparent`; allow it in CORS. - [ ] Rename `UseCorrelationId` → `UseSynapsePropagation`. - [ ] Update anything reading `X-Correlation-Id` off a response to `Trace-Id`, or set `options.TraceIdHeaderName` back to the old value. - [ ] Expose `traceresponse` and `Trace-Id` in CORS if a browser needs to read them. - [ ] Replace `IContextSetter` / `WithCorrelationId` with `IInboundContextStore`. - [ ] Replace `context.PublishEventAsync` with `emitter.EmitAsync`, and `context.CommitEventsAsync` with `outboxCommit.CommitAsync`. Where `IContext` was injected only to publish, inject `IEmitter` instead. - [ ] Replace `CorrelationContext.CurrentCorrelationId` with `TraceId` on an injected `IContext`, using `IContextAccessor.IsInitialized` where you must not create one. - [ ] Update custom `IContextFactory` implementations to use `ContextIdentity.ForUnitOfWork(inbound)`, and drop any `IEmitter` / `IOutboxCommit` constructor dependencies they only passed to the context. - [ ] Add `IsInitialized` to custom `IContextAccessor` implementations. - [ ] Update custom `IEventOutboxStorage` implementations to store and return `headers`, and drop any partitioning keyed on the correlation id — entries carry their own `Headers` now. - [ ] Replace `[assembly: EnableSynapseCqrsBoundaryEnforcement]` with **both** `[assembly: SynapseGlobalBehavior(typeof(CqrsBoundaryEnforcementBehavior<>))]` and `[assembly: SynapseGlobalBehavior(typeof(CqrsBoundaryEnforcementBehavior<,>))]`. - [ ] Replace `cfg.EnableCqrsBoundaryEnforcement()` with `cfg.RegisterCqrsBoundaryEnforcement()` (or the `` overload) for each request that needs it. - [ ] On internet-facing apps, set `TrustIncomingHeader = false`. - [ ] Check that anything you moved into baggage is not confidential — baggage reaches every downstream service. --- ## Modular Monoliths A common solution layout has one assembly per module, a shared library of cross-cutting behaviors, and a host that wires everything together. ```text Shared/ behaviors: access checks, validation, tracing (references Synapse) ModuleA/ requests and handlers (references Shared) ModuleB/ requests and handlers (references Shared) Host/ composition root (references all of the above) ``` ### Packages Every project that declares handlers or behaviors references `UnambitiousFx.Synapse.Abstractions` and the `UnambitiousFx.Synapse.Generator` analyzer. The host also references `UnambitiousFx.Synapse` and `Microsoft.Extensions.Logging` (the container needs `AddLogging()`), and runs the generator too, because it emits the global behaviors. ### Shared behaviors Write the behaviors once, open-generic, and give them an `Order` with [`IOrderedPipelineBehavior`](./pipelines#ordering): ```csharp public sealed class AccessBehavior : IRequestPipelineBehavior, IOrderedPipelineBehavior where TRequest : IRequest where TResponse : notnull { public uint Order => 30; public ValueTask> HandleAsync( TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken = default) => IsAllowed(request) ? next(request, cancellationToken) : ValueTask.FromResult(Result.Failure(new UnauthorizedFailure("forbidden", null))); } ``` Do not rely on `[PipelineBehavior]` here: it only reaches handlers in assemblies referenced by the one that declares the behavior, and `ModuleA` references `Shared`, not the other way round. ### The host Opt the behaviors in at the composition root and register **every** generated group, the host's own included: ```csharp [assembly: SynapseGlobalBehavior(typeof(Shared.AccessBehavior<,>))] [assembly: SynapseGlobalBehavior(typeof(Shared.ValidationBehavior<,>))] services.AddLogging(); services.AddSynapse(cfg => { cfg.AddRegisterGroup(new Host.RegisterGroup()); cfg.AddRegisterGroup(new ModuleA.RegisterGroup()); cfg.AddRegisterGroup(new ModuleB.RegisterGroup()); }); ``` :::warning If `Host.RegisterGroup` is not registered, the `SynapseGlobalBehavior` attributes have no effect and no error is raised — `SYN105` catches this at build time once the host also calls `AddSynapse(...)`; see [Diagnostics](./source-generator#diagnostics) and [Sharing behaviors across projects](./pipelines#sharing-behaviors-across-projects). ::: With behaviors at orders 10 (trace), 20 (scope), 30 (access), 40 (validation), a request from either module executes as: ```text trace > scope > access > validation > handler < validation < access < scope < trace ``` A behavior that returns a `Result.Failure` without calling `next` (for example `access`) skips everything inside it and the handler. ### Events across modules An event emitted in `ModuleA` reaches an `IEventHandler` declared in `ModuleB` as long as both groups are registered. With [`EmitMode.Outbox`](./outbox), the event is delivered on `IOutboxCommit.CommitAsync`, so commit only after your transaction succeeds. ### Test that the chain runs Because a missing registration is silent, add one test per module that dispatches a request and asserts the behaviors ran, for example by recording each behavior's execution in a test-only log and comparing it with the expected order. --- ## Observability Synapse exposes structured logging, distributed tracing, metrics, and health checks out of the box. All hooks integrate with the standard .NET observability stack (Microsoft.Extensions.Logging, System.Diagnostics.ActivitySource, System.Diagnostics.Metrics, and `IHealthCheck`). ### Structured logging with built-in behaviors #### `SimpleLoggingBehavior` / `SimpleLoggingEventBehavior` Logs the name of each request or event and the elapsed time after the handler completes. The behaviors are typed; register a closed pair per handler (`SimpleLoggingBehavior` for void requests): ```csharp services.AddSynapse(cfg => { cfg.RegisterRequestPipelineBehavior< SimpleLoggingBehavior, CreateTaskCommand, Guid>(); cfg.RegisterEventPipelineBehavior< SimpleLoggingEventBehavior, TaskCreatedEvent>(); }); ``` :::tip To apply logging across every handler without naming each pair, make an open-generic behavior and mark it `[PipelineBehavior]` so the source generator emits the closed registrations for you. See [Pipeline Behaviors](./pipelines#applying-a-behavior-to-every-handler). ::: Sample output: ``` info: Synapse[0] Handling CreateTaskCommand info: Synapse[0] CreateTaskCommand completed in 4ms ``` #### `LoggingEnrichmentBehavior` Enriches the `ILogger` scope with the context's [flow identity](./context.mdx#flow-identity) and its [baggage](./context.mdx#baggage), so every log entry emitted during the request automatically includes them — no manual `BeginScope` calls needed: ```csharp cfg.RegisterRequestPipelineBehavior< LoggingEnrichmentBehavior, CreateTaskCommand, Guid>(); ``` Every `ILogger` call inside the handler or any downstream service will include: ```json { "TraceId": "0af7651916cd43dd8448eb211c80319c", "CausationId": "b7ad6b7169203331", "OccurredAt": "2026-04-08T10:00:00Z", "Baggage_tenant.id": "acme" } ``` `TraceId` is the W3C trace id, so this value is exactly what your tracing backend shows for the same flow — one string searches both. `CausationId` is the caller's span id and is omitted at the root of a flow or when nothing upstream was recording. Context **features** are deliberately not surfaced: they hold process-local state, and putting them in the log scope would mean internal markers appearing in every log entry. :::note Duplicate `TraceId` field A host that enables `ActivityTrackingOptions.TraceId` gets a `TraceId` field from `ILogger` itself. The two agree whenever both are set, because Synapse takes its value from the same activity. Synapse's is additionally present when no activity exists at all. ::: ### Distributed tracing Synapse creates an `ActivitySource` named `Unambitious.Synapse`. Any pipeline behavior or handler can start a child span using it: ```csharp using System.Diagnostics; using UnambitiousFx.Synapse.Observability; [PipelineBehavior] public sealed class TracingBehavior : IRequestPipelineBehavior where TRequest : IRequest where TResponse : notnull { public async ValueTask> HandleAsync( TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken = default) { using var activity = SynapseActivitySource.Source.StartActivity(typeof(TRequest).Name); var result = await next(request, cancellationToken); activity?.SetStatus(result.IsSuccess ? ActivityStatusCode.Ok : ActivityStatusCode.Error); return result; } } ``` `[PipelineBehavior]` lets the source generator close this open-generic behavior over every matching handler. See [Pipeline Behaviors](./pipelines#applying-a-behavior-to-every-handler). To export traces to an OpenTelemetry collector, register the activity source when configuring OpenTelemetry: ```csharp builder.Services.AddOpenTelemetry() .WithTracing(tracing => tracing .AddSource("Unambitious.Synapse") .AddOtlpExporter()); ``` ### Metrics `ISynapseMetrics` is backed by `System.Diagnostics.Metrics` and works with any OpenTelemetry-compatible metrics exporter. All instruments are published on the `Unambitious.Synapse` meter. Counters/histograms carry an `event.type` tag (and a `success` tag where applicable). | Instrument | Type | Description | | -------------------------------------- | --------------- | -------------------------------------- | | `mediator.events.dispatched` | Counter | Events dispatched (`event.type`, `success`) | | `mediator.events.dispatch_failures` | Counter | Event dispatch failures (`event.type`) | | `mediator.events.dispatch.duration` | Histogram (ms) | Dispatch duration (`event.type`) | | `mediator.outbox.events.processed` | Counter | Events processed from the outbox (`event.type`, `success`) | | `mediator.outbox.events.dead_lettered` | Counter | Events moved to the dead-letter queue (`event.type`) | | `mediator.outbox.queue_depth` | Gauge | Pending events in the outbox | | `mediator.outbox.processing_lag` | Gauge (seconds) | Age of the oldest pending event | | `mediator.outbox.retrying_count` | Gauge | Events that failed at least once and await retry | | `mediator.outbox.dead_letter_count` | Gauge | Events that exhausted retries | Export to Prometheus + OpenTelemetry: ```csharp builder.Services.AddOpenTelemetry() .WithMetrics(metrics => metrics .AddMeter("Unambitious.Synapse") .AddPrometheusExporter()); ``` Replace the default implementation to customise how the recorded operations are emitted. `ISynapseMetrics` exposes four record methods — `RecordEventDispatched`, `RecordDispatchLatency`, `RecordOutboxEventProcessed`, and `RecordOutboxDeadLettered` (the outbox gauges are observed internally by `SynapseMetrics`): ```csharp builder.Services.AddSingleton(); ``` ### Outbox health check `OutboxHealthCheck` implements `IHealthCheck` and reports the health of the event outbox. It monitors pending event count, dead-lettered event count, and queue lag (the age of the oldest pending event). #### Register ```csharp builder.Services.AddHealthChecks() .AddCheck("outbox", tags: ["ready"]); ``` #### Configure thresholds `OutboxHealthCheck` takes its `OutboxHealthCheckOptions` directly through its constructor (not via `IOptions`), so register a configured instance as a singleton — `AddCheck` then resolves it through the optional constructor parameter: ```csharp builder.Services.AddSingleton(new OutboxHealthCheckOptions { DegradedPendingThreshold = 50, CriticalPendingThreshold = 200, DegradedDeadLetterThreshold = 5, CriticalDeadLetterThreshold = 20, DegradedLagThreshold = TimeSpan.FromMinutes(5), CriticalLagThreshold = TimeSpan.FromMinutes(15), }); ``` | Threshold | Default | Effect when reached | | ----------------------------- | ------- | ------------------------- | | `DegradedPendingThreshold` | 1000 | Health status → Degraded | | `CriticalPendingThreshold` | 5000 | Health status → Unhealthy | | `DegradedDeadLetterThreshold` | 10 | Health status → Degraded | | `CriticalDeadLetterThreshold` | 50 | Health status → Unhealthy | | `DegradedLagThreshold` | 5 min | Health status → Degraded | | `CriticalLagThreshold` | 15 min | Health status → Unhealthy | #### Expose the health endpoint ```csharp app.MapHealthChecks("/health/ready", new HealthCheckOptions { Predicate = hc => hc.Tags.Contains("ready") }); ``` ### See also - [Pipeline Behaviors](./pipelines) — `SimpleLoggingBehavior` and `LoggingEnrichmentBehavior` registration. - [Outbox Pattern](./outbox) — outbox configuration and storage replacement. --- ## ADO.NET Outbox Storage `UnambitiousFx.Synapse.Outbox.AdoNet` is a transactional `IEventOutboxStorage` for applications that talk to PostgreSQL or SQL Server directly, without Entity Framework Core. A stored entry joins the `DbTransaction` your business writes run in, so it commits or rolls back with them. That is the guarantee `EmitMode.Outbox` exists for. The package depends only on `System.Data.Common`. You bring the driver (Npgsql or Microsoft.Data.SqlClient) and register its `DbDataSource`. ### Install ```bash dotnet add package UnambitiousFx.Synapse.Outbox.AdoNet ``` ### Register it Call `AddAdoNetEventOutbox` **before** `AddSynapse`, then make it the active storage. #### PostgreSQL ```csharp services.AddNpgsqlDataSource(connectionString); // Npgsql.DependencyInjection services.AddAdoNetEventOutbox(o => o.Dialect = OutboxSqlDialect.PostgreSql); services.AddSynapse(cfg => { cfg.SetEventOutboxStorage(); }); ``` #### SQL Server ```csharp services.AddSingleton(SqlClientFactory.Instance.CreateDataSource(connectionString)); services.AddAdoNetEventOutbox(o => o.Dialect = OutboxSqlDialect.SqlServer); services.AddSynapse(cfg => { cfg.SetEventOutboxStorage(); }); ``` `Schema` (default `outbox`) and `Table` (default `outbox_events`) set where the table lives. They are embedded in the SQL, so only plain identifiers are accepted. Anything else throws at registration. ### Create the table There are no migrations. The options produce the script, and you run it with whatever tool already manages your schema, or once at startup: ```csharp var options = new AdoNetOutboxOptions { Dialect = OutboxSqlDialect.PostgreSql }; string script = options.GetCreateTableScript(); ``` The script is idempotent. It creates the schema, the table, and a filtered index over the pending entries, and skips anything that already exists. ### Store an event in your transaction Hand the transaction your business writes run in to the scope's `AdoNetOutboxTransaction` before emitting: ```csharp public sealed class CreateOrderHandler( DbDataSource dataSource, AdoNetOutboxTransaction outboxTransaction, IEmitter emitter) : IRequestHandler { public async ValueTask HandleAsync(CreateOrderCommand command, CancellationToken ct = default) { await using var connection = await dataSource.OpenConnectionAsync(ct); await using var transaction = await connection.BeginTransactionAsync(ct); outboxTransaction.Enlist(transaction); // ... your INSERT/UPDATE commands on connection, with Transaction = transaction ... await emitter.EmitAsync(new OrderCreated(command.OrderId), EmitMode.Outbox, ct); // same transaction await transaction.CommitAsync(ct); // the order and its event commit together return Result.Success(); } } ``` Without an enlisted transaction, each stored entry is written on its own connection and committed immediately, like the in-memory storage but durable. Only storing enlists. Reading, claiming and marking entries always use connections of their own, because they happen after your transaction has committed, usually from the [background dispatcher](./outbox#dispatch-in-the-background) or a later `CommitAsync`. Call `CommitAsync` after committing your transaction: entries that are not committed yet are not visible to it, so it would have nothing to dispatch. ### Multiple instances `AdoNetEventOutboxStorage` implements `IClaimableOutboxStorage` with each database's native row-skipping lock: `FOR UPDATE SKIP LOCKED` on PostgreSQL, and `UPDLOCK, READPAST, ROWLOCK` on SQL Server. A single statement locks a batch of due entries, skips the ones another instance holds, stamps a lease on the rest and returns them. Instances running the dispatcher against one table therefore split the backlog between them rather than contend for it, and no entry is claimed twice. See [Running several instances](./outbox#running-several-instances) for the lease semantics. Set a `BatchSize` in `ConfigureOutbox`. It defaults to unlimited, and with a database-backed storage an unlimited claim is one statement that locks and returns the entire backlog, all read into memory at once. A bounded batch keeps each claim short, and the dispatcher polls again straight away after a full one. ### Limitations Rows store their event's assembly-qualified CLR type name, which is resolved again with `Type.GetType(..., throwOnError: true)` when entries are read. If you rename, move or delete an event type while rows referencing it are still pending, reading fails for the whole batch until an operator fixes the stored `event_type` or deletes the row. Keep a type-forwarding shim, or update the stored names, before renaming or removing an event type that may still have outbox rows. ### See also - [Outbox Pattern](./outbox): the general outbox contract, retries, dead-lettering, the background dispatcher and the health check. - [EF Core Outbox Storage](./outbox-entityframeworkcore): the same guarantees for applications on EF Core. --- ## EF Core Outbox Storage `UnambitiousFx.Synapse.Outbox.EntityFrameworkCore` is a transactional `IEventOutboxStorage` implementation backed by EF Core. It writes outbox entries through whatever `DbContext` it is given, so a stored entry lands in that context's ambient transaction — the same guarantee `EmitMode.Outbox` exists for. ### Install ```bash dotnet add package UnambitiousFx.Synapse.Outbox.EntityFrameworkCore ``` ### Choose a schema story #### Standalone `OutboxDbContext` (the common case) ```csharp services.AddDbContext(o => o.UseNpgsql(connectionString)); services.AddEfCoreEventOutbox(); services.AddSynapse(cfg => { cfg.SetEventOutboxStorage>(); }); ``` Generate its migrations like any other context, in your own project: ```bash dotnet ef migrations add InitialOutbox --context OutboxDbContext ``` #### Embed the table in your own `DbContext` If you'd rather keep the outbox table under your existing context's migration history instead of a separate one, apply `OutboxEntityTypeConfiguration` yourself: ```csharp public sealed class AppDbContext(DbContextOptions options) : DbContext(options) { protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.ApplyConfiguration(new OutboxEntityTypeConfiguration()); // ... your own entities } } ``` ```csharp services.AddEfCoreEventOutbox(); services.AddSynapse(cfg => { cfg.SetEventOutboxStorage>(); }); ``` `OutboxEntityTypeConfiguration`'s constructor takes an optional `schema` parameter (default `"outbox"`), so the table never collides with your own tables regardless of which context it lives in. :::note Call `AddEfCoreEventOutbox()` before `AddSynapse` `SetEventOutboxStorage>()` forwards `IEventOutboxStorage` to whatever `AddEfCoreEventOutbox()` already registered, so both resolve to the **same** instance per scope — but only if `AddEfCoreEventOutbox()` runs first, as shown above. Call it after `AddSynapse` and the forwarding has nothing to find, so `IEventOutboxStorage` falls back to constructing its own independent instance instead. ::: ### Multiple instances `EfCoreEventOutboxStorage` implements `IClaimableOutboxStorage`, so several instances of your application can run the [background dispatcher](./outbox#dispatch-in-the-background) (or call `CommitAsync`) against one outbox table without dispatching an entry twice. A claim is two statements and needs no provider-specific SQL. The storage first selects candidate rows. It then runs one conditional `UPDATE` that stamps a lease (`ClaimedUntil`) and a per-call `ClaimToken` only on candidates that are still unclaimed, and reads back the rows carrying its token. The database re-checks that condition against each row as it updates it, so when two instances race for the same row, exactly one wins it. Unlike `FOR UPDATE SKIP LOCKED`, instances under heavy contention can pick overlapping candidates and end up with smaller batches, but no row is ever claimed twice. :::note Upgrading Claiming adds two nullable columns, `ClaimedUntil` (UTC ticks, like the other timestamps) and `ClaimToken`. Run `dotnet ef migrations add` for the context that owns the outbox table. The change is additive, so existing rows need no conversion. ::: ### Timestamp storage `OutboxEntityTypeConfiguration` stores every `DateTimeOffset` column (`CreatedAt`, `ProcessedAt`, `NextAttemptAt`) as a 64-bit integer of UTC ticks (`DateTimeOffset.UtcTicks`), not as the provider's native date/time type. Two things follow from that: - The pending-entry query (the `NextAttemptAt` back-off filter and the `CreatedAt` ordering) and the oldest-pending-age lookup run entirely in SQL on every provider, SQLite included, so entries still backing off never leave the database and `ix_outbox_events_pending` can serve the filter. - Values read back through EF Core come back with a zero offset. The instant is preserved; the original offset is not. If you query the table with raw SQL, the columns hold ticks, not dates. :::caution Upgrading from an earlier version Earlier versions mapped those columns to the provider's native type (`TEXT` on SQLite, `datetimeoffset` on SQL Server, `timestamp with time zone` on PostgreSQL). After upgrading, run `dotnet ef migrations add` for the context that owns the outbox table. EF Core generates a column type change, but it does not convert the values already stored. The simplest path is to drain the outbox first, so no pending rows are left. Otherwise, edit the migration to convert the data, as shown below. Test the migration against a copy of your data first. ::: #### Converting existing rows A stored value is .NET ticks: 100-nanosecond intervals since `0001-01-01T00:00:00Z`, which is **not** the Unix epoch. The conversion is `unix_seconds × 10,000,000 + 621,355,968,000,000,000`. The snippets below assume the default `outbox` schema and EF Core's default column names. Repeat them for `ProcessedAt` and `NextAttemptAt`. **PostgreSQL:** the generated `AlterColumn` fails, because PostgreSQL cannot cast `timestamptz` to `bigint` on its own. Replace each call with a `USING` clause. PostgreSQL rebuilds the indexes on the column itself. ```csharp migrationBuilder.Sql(""" ALTER TABLE outbox.outbox_events ALTER COLUMN "CreatedAt" TYPE bigint USING (EXTRACT(EPOCH FROM "CreatedAt") * 10000000)::bigint + 621355968000000000; """); ``` **SQL Server:** `datetimeoffset` cannot be altered to `bigint` in place. Add a `bigint` column, fill it, drop `ix_outbox_events_pending` (it covers `NextAttemptAt`) and the old column, rename the new column, then recreate the index. The value to fill it with is: ```sql DATEDIFF_BIG(microsecond, CAST('0001-01-01' AS datetime2(7)), CAST(SWITCHOFFSET([CreatedAt], '+00:00') AS datetime2(7))) * 10 + DATEPART(nanosecond, [CreatedAt]) / 100 % 10 ``` **SQLite:** the generated migration rebuilds the table and copies the old text values across unchanged. Add an `UPDATE` after the rebuild. SQLite's date functions keep millisecond precision, so converted values are rounded to the nearest millisecond. That is enough to keep the drain order. ```csharp migrationBuilder.Sql(""" UPDATE "outbox_events" SET "CreatedAt" = CAST(ROUND((julianday("CreatedAt") - julianday('0001-01-01')) * 86400000) AS INTEGER) * 10000 WHERE typeof("CreatedAt") = 'text'; """); ``` ### Multi-`DbContext` (modular monolith) When a business `DbContext` and the outbox context must commit or roll back together — the point of the outbox pattern — share one `DbTransaction` across them with `Database.UseTransactionAsync`: ```csharp await using var tx = await businessContext.Database.BeginTransactionAsync(ct); await outboxContext.Database.UseTransactionAsync(tx.GetDbTransaction(), ct); businessContext.Tasks.Add(task); await businessContext.SaveChangesAsync(ct); // business save await emitter.EmitAsync(new TaskCreatedEvent(task.Id), EmitMode.Outbox, ct); // outbox write, // same DbTransaction await tx.CommitAsync(ct); // both commit together; either rolls back together ``` See also [Modular Monolith](./modular-monolith) for the broader module-boundary picture this fits into. ### Limitations Outbox rows store their event's assembly-qualified CLR type name, and `GetPendingEventsAsync`, `ClaimPendingEventsAsync` and `GetDeadLetterEventsAsync` resolve it back with `Type.GetType(..., throwOnError: true)`. If you rename, move or delete an event type while rows referencing it are still pending, that resolution throws and the whole call fails — blocking dispatch of *every* pending entry, not just the offending one, and there is no in-library recovery: an operator has to fix the stored `EventType` value or delete the row by hand. Keep a type-forwarding shim, or migrate the stored type names, before renaming or removing an event type that may still have outbox rows. ### See also - [Outbox Pattern](./outbox) — the general outbox contract, retry/dead-letter behavior, health check. - [Modular Monolith](./modular-monolith) — module boundaries this storage's multi-`DbContext` support is designed for. --- ## Outbox Pattern The outbox pattern decouples event publishing from handler execution. Instead of dispatching events immediately when `EmitAsync` is called, events are stored in an **outbox** and dispatched later in a controlled batch. This is useful when you need to guarantee that events are only dispatched after a database transaction commits successfully. ### How it works ```mermaid stateDiagram-v2 [*] --> Pending : EmitAsync (EmitMode.Outbox) Pending --> Processing : IOutboxCommit.CommitAsync Processing --> Completed : all event handlers succeed Processing --> Failed : handler fails after max retries Failed --> [*] Completed --> [*] ``` 1. `EmitAsync(event, EmitMode.Outbox)` stores the event in `IEventOutboxStorage`. No dispatch occurs. 2. Calling `IOutboxCommit.CommitAsync()` moves pending events through the event pipeline and handlers. 3. If a handler fails, the outbox retries with exponential backoff up to `MaxRetryAttempts`. 4. After all retry attempts are exhausted, the event is marked `Failed` (dead-lettered). ### Using the outbox #### Store and defer an event ```csharp // Store event in outbox — nothing dispatched yet await emitter.EmitAsync(new TaskCreatedEvent(id, title), EmitMode.Outbox, ct); ``` #### Flush after your side-effects commit ```csharp public class CreateTaskHandler : IRequestHandler { private readonly ITaskRepository _repository; private readonly IEmitter _emitter; private readonly IOutboxCommit _outboxCommit; public CreateTaskHandler(ITaskRepository repository, IEmitter emitter, IOutboxCommit outboxCommit) { _repository = repository; _emitter = emitter; _outboxCommit = outboxCommit; } public async ValueTask> HandleAsync(CreateTaskCommand cmd, CancellationToken ct = default) { var id = Guid.NewGuid(); await _repository.SaveAsync(id, cmd.Title, ct); // ← side-effect await _emitter.EmitAsync( new TaskCreatedEvent(id, cmd.Title), EmitMode.Outbox, ct); // ← store, don't dispatch yet await _outboxCommit.CommitAsync(ct); // ← dispatch after save succeeded return Result.Success(id); } } ``` `IOutboxCommit` can equally be injected somewhere else and called outside the handler — for example from a transaction wrapper that flushes once the whole unit of work has committed: ```csharp await outboxCommit.CommitAsync(ct); ``` :::note[Each entry is dispatched in its own scope] `CommitAsync` dispatches every pending entry as a **separate unit of work**: a fresh DI scope, and an `IContext` rebuilt from the flow the entry was stored in — its trace id, its causation id and its baggage. So handlers do not see the committing request's scoped services. A `DbContext` they resolve is a new one, not the one that just saved, and they cannot enlist in a transaction that has already committed — which is the point of storing the event in the first place. It also means an entry stored by one request is never dispatched under another request's identity, which matters because `CommitAsync` drains whatever is pending, not only what the calling scope stored. See [Propagation](./propagation.mdx#outbound-the-outbox). ::: ### Dispatch in the background Instead of calling `CommitAsync` yourself, or as well as it, let a hosted service poll the outbox and dispatch whatever is pending: ```csharp services.AddSynapse(cfg => { cfg.AddOutboxDispatcher(opts => opts.PollingInterval = TimeSpan.FromSeconds(2)); }); ``` The dispatcher polls every `PollingInterval` (default 5 seconds). When a poll dispatches a full `BatchSize` batch successfully, it polls again straight away rather than waiting. Each poll runs in its own DI scope, and a poll that fails (for example because the database is unreachable) is logged and retried at the next interval. Retry, dead-letter and batch settings are the ones from `ConfigureOutbox`, shared with `CommitAsync`. Only generic-host applications start hosted services. #### Running several instances Before dispatching, entries are **claimed** from a storage that implements `IClaimableOutboxStorage`, as the in-memory, [EF Core](./outbox-entityframeworkcore) and [ADO.NET](./outbox-adonet) storages do. A claimed entry is hidden from every other claimer, so several instances of your application, and `CommitAsync` calls running alongside the dispatcher, never hand the same entry out twice. `CommitAsync` claims too. A claim is a lease rather than a held lock. Marking the entry processed or failed releases it. If an instance crashes mid-batch, its entries become claimable again once `ClaimLeaseDuration` (default 5 minutes) has passed. Delivery stays at-least-once, so keep the lease comfortably longer than a batch takes to dispatch: an entry whose lease runs out mid-dispatch can be claimed and dispatched by another instance too. :::caution Set a `BatchSize` when running several instances `BatchSize` is unlimited by default, so a single poll claims the **whole** backlog under one lease. After an outage or a burst, draining that backlog can take longer than `ClaimLeaseDuration`, and another instance can then claim and dispatch the entries still waiting in the same batch. A finite `BatchSize` keeps each claim small enough to finish well within its lease. The dispatcher polls again straight away after a full batch, so throughput doesn't suffer. ::: A custom storage that does not implement `IClaimableOutboxStorage` still works. The dispatcher logs a warning at startup, because without claiming two processors can dispatch the same entry. ### Configure the outbox Set outbox options in `AddSynapse`: ```csharp services.AddSynapse(cfg => { cfg.ConfigureOutbox(opts => { opts.MaxRetryAttempts = 5; opts.InitialRetryDelay = TimeSpan.FromSeconds(1); opts.BackoffFactor = 2.0; // exponential: 1s, 2s, 4s, 8s, 16s opts.BatchSize = 100; // max events per CommitAsync call or dispatcher poll opts.ClaimLeaseDuration = TimeSpan.FromMinutes(5); }); }); ``` | Option | Default | Description | | ------------------- | --------------- | --------------------------------------------------------------- | | `MaxRetryAttempts` | `3` | Number of delivery attempts before marking the event as failed. | | `InitialRetryDelay` | `TimeSpan.Zero` | Delay before the first retry. | | `BackoffFactor` | `2.0` | Multiplier applied to the delay for each subsequent retry. | | `BatchSize` | unlimited | Cap the number of events dispatched per `CommitAsync` call or dispatcher poll. | | `ClaimLeaseDuration` | 5 minutes | How long a claimed entry stays hidden from other processors if it is never marked processed or failed. | ### Configure the default emit mode If most of your events should use the outbox, set it as the default: ```csharp cfg.SetDefaultPublishingMode(EmitMode.Outbox); ``` With this setting, `EmitAsync(event)` and `EmitAsync(event, EmitMode.Default)` store to the outbox automatically. :::warning Production storage The built-in `InMemoryEventOutboxStorage` is **not persistent** — all pending events are lost on restart. Replace it with a database-backed implementation before going to production. It is also **not transactional**: it is not enlisted in your database transaction. If a handler emits an event with `EmitMode.Outbox` and then fails or rolls back, the pending entry stays in memory and is dispatched by the next `IOutboxCommit.CommitAsync` call, typically triggered by an unrelated request. Use a storage that writes the entry in the same transaction as your data. When the in-memory storage is registered in a Production host, Synapse logs a warning at startup. ::: #### Discard the events of a failed request If you stay on the in-memory storage, opt in to `OutboxDiscardOnFailureBehavior`. When a request returns a failed `Result` or throws, it takes back the outbox events that request stored and that were not dispatched yet, so a later `CommitAsync` does not publish something that never happened. It runs outermost, so a failure raised by any other behavior counts too. Enable it per request type: ```csharp cfg.RegisterOutboxDiscardOnFailure(); cfg.RegisterOutboxDiscardOnFailure(); ``` Or for every handler in the assembly: ```csharp [assembly: SynapseGlobalBehavior(typeof(OutboxDiscardOnFailureBehavior<>))] [assembly: SynapseGlobalBehavior(typeof(OutboxDiscardOnFailureBehavior<,>))] ``` Things to know: - It is **opt-in**. Without it, the leak described above still happens. - Discarding is **scope-wide**: it takes back everything the current DI scope stored, not only what the failing request stored. Events stored by other scopes, such as concurrent requests, are never touched. - Events already dispatched by an explicit `CommitAsync` inside the failed handler cannot be taken back. - It only applies to a storage that implements `IDiscardableOutboxStorage`, which the in-memory one does. With a transactional storage the database rollback already removes the entry, so the behavior does nothing. ### Replace the storage for production The built-in `InMemoryEventOutboxStorage` is suitable for testing and simple scenarios. For production, replace it with a persistent implementation. `UnambitiousFx.Synapse.Outbox.EntityFrameworkCore` ships a ready-made, transactional one — see [EF Core outbox storage](./outbox-entityframeworkcore) for installation, the multi-`DbContext` (modular monolith) transaction-sharing pattern, and migrations. Without EF Core, `UnambitiousFx.Synapse.Outbox.AdoNet` does the same over plain ADO.NET for PostgreSQL and SQL Server — see [ADO.NET outbox storage](./outbox-adonet). With EF Core, in short: ```csharp services.AddDbContext(o => o.UseNpgsql(connectionString)); services.AddEfCoreEventOutbox(); services.AddSynapse(cfg => { cfg.SetEventOutboxStorage>(); }); ``` ### Health check Register the outbox health check to surface queue depth and lag in your `/health` endpoint: ```csharp builder.Services.AddHealthChecks() .AddCheck("outbox", tags: ["ready"]); ``` `OutboxHealthCheck` reads its `OutboxHealthCheckOptions` from its constructor (not `IOptions`), so register a configured instance as a singleton; `AddCheck` resolves it through the optional constructor parameter: ```csharp builder.Services.AddSingleton(new OutboxHealthCheckOptions { DegradedPendingThreshold = 50, // Degraded when pending >= 50 CriticalPendingThreshold = 200, // Unhealthy when pending >= 200 CriticalLagThreshold = TimeSpan.FromMinutes(5), }); ``` See [Observability](./observability#outbox-health-check) for the full threshold list (dead-letter and lag thresholds included). ### See also - [Events](./events) — `EmitMode` explained, how to publish events with `IEmitter`. - [Observability](./observability) — outbox metrics (queue depth, lag, failed count). --- ## Propagation A user clicks **Send** in your frontend. The order service accepts the request, stores an event in the outbox, and returns. Minutes later a notification service picks that event up and sends a mail. Propagation is what lets you answer, from the mail, *which click caused it*. ### One identity: W3C trace context .NET already propagates W3C trace context: `DistributedContextPropagator` parses and writes `traceparent` and `tracestate`, and `SocketsHttpHandler` injects them onto every outgoing `HttpClient` request. **Synapse reuses that and adds no identifier scheme of its own.** That means the frontend has one job — send `traceparent` — and everything downstream falls out of it: - `traceparent`'s trace id becomes [`IContext.TraceId`](./context.mdx#flow-identity); - its span id becomes `IContext.CausationId`; - the `baggage` header carries business values only. Two things trace context cannot do on its own, which is why the design is not simply "read `Activity.Current`": | | What Synapse adds | | --- | --- | | **Hosts with no tracing** | With no registered `ActivityListener`, `Activity.Current` is null and there is no id at all. Synapse mints one so correlation still works, and propagates it as `traceparent` so the flow survives the next hop. | | **The outbox** | The producing span ends when the request returns. An event dispatched minutes later — possibly after a restart — has no live span to read, so the trace context is captured with the entry and restored at dispatch. | Note what is *not* in that table: sampling. `traceparent`'s sampled flag governs whether **spans reach your tracing backend** — the trace **id** is in the header either way, so using it as a correlation label in your own logs loses nothing. ```mermaid flowchart LR FE["Frontend\nclick"] API["Order service"] OB[("Outbox")] NS["Notification service"] MAIL["Mail"] FE -->|"traceparent\nbaggage"| API API -->|"store + captured headers"| OB OB -->|"restore headers\nsame trace"| NS NS -->|"Trace-Id header"| MAIL ``` ### The pieces | Type | Role | | --- | --- | | `IPropagationCarrier` | A transport-agnostic view over header-like key/value slots. | | `IContextPropagator` | `Inject` writes state onto an outgoing carrier; `Extract` reads it from an incoming one. | | `PropagatedContext` | What `Extract` returns: the trace context and the baggage. | | `IInboundContextStore` | Where a boundary adapter puts extracted state until the context is built from it. | | `PropagationKeys` | The wire keys — `traceparent`, `tracestate`, `baggage`, `traceresponse`, plus the legacy `Correlation-Context` read as a fallback. All standard names; Synapse defines none of its own. | | `SynapseContext.Current` | The unit of work's context on the current execution branch. For transport hooks the container cannot serve; everything else injects `IContextAccessor` or `IContext`. | The default `IContextPropagator` is registered by `AddSynapse`. One implementation serves every transport; the transport-specific part is only the carrier. #### Carriers | Carrier | For | | --- | --- | | `DictionaryPropagationCarrier` | Any `IDictionary` — message headers, broker application properties, outbox entries. | | `HttpRequestPropagationCarrier` | An incoming ASP.NET Core `HttpRequest`. | | `HttpRequestMessagePropagationCarrier` | An outgoing `HttpRequestMessage`. | A carrier reports a key as **absent** when it holds more than one value. Choosing between conflicting values would be arbitrary, and propagated state is only meaningful when unambiguous. ### Inbound: HTTP `UseSynapsePropagation()` recovers the trace context and baggage from the incoming request: ```csharp var app = builder.Build(); app.UseSynapsePropagation(); // add it early — before any endpoint runs ``` Identity comes from `traceparent` alone. Synapse reads no identity header of its own, so a request without `traceparent` simply starts a new flow with a minted trace id. Two response headers go back, both write-only — nothing reads them in again: - **`traceresponse`** — the W3C Trace Context Level 2 response header, `00---`, so conformant tooling can continue the trace from a response. - **`Trace-Id`** — the bare 32-hex trace id, for the human who needs one string to paste into a tracing backend. No `X-` prefix: RFC 6648 deprecated that in 2012. Inbound baggage is read from `baggage`, falling back to the pre-W3C `Correlation-Context` name so business values from an older ASP.NET Core service survive. Only the W3C name is ever written. #### Browser setup The frontend needs an OpenTelemetry JS propagator (or a hand-written header) plus CORS on both sides: ```csharp policy.WithHeaders("traceparent", "tracestate", "baggage") // Access-Control-Allow-Headers .WithExposedHeaders("traceresponse", "Trace-Id"); // Access-Control-Expose-Headers ``` See [ASP.NET Core Integration](./aspnetcore#propagation-middleware) for the options, including the untrusted-edge mode. ### Inbound: messages and other transports Any transport works the same way — extract into the store before anything resolves `IContext`: ```csharp public async Task OnMessageAsync(BrokerMessage message, CancellationToken ct) { using var scope = _serviceProvider.CreateScope(); var carrier = new DictionaryPropagationCarrier(message.Headers); var propagator = scope.ServiceProvider.GetRequiredService(); var store = scope.ServiceProvider.GetRequiredService(); store.Inbound = propagator.Extract(carrier); // Everything resolved from this scope now shares the sender's flow var invoker = scope.ServiceProvider.GetRequiredService(); await invoker.InvokeAsync(message.ToCommand(), ct); } ``` ### Outbound: HTTP Add `SynapsePropagationHandler` to any client whose calls should stay part of the flow: ```csharp services.AddHttpClient("billing") .AddHttpMessageHandler(); ``` The handler only acts when a context already exists — an outbound call made outside any unit of work does not get a flow invented for it. It writes the **baggage**; `traceparent` and `tracestate` normally come from `SocketsHttpHandler` and the platform propagator, which is why baggage is usually all that is left to do. The exception is a host with **no tracing wired**. There is no `Activity`, so nothing writes `traceparent` and the receiver would start a brand new trace — the flow would break at every hop even though `IContext.TraceId` knew the answer. Synapse fills that gap: with no usable activity it writes `traceparent` itself from the context's trace id, with the sampled flag off and a parent id derived from the trace id (there is no real span to name). A receiver continues the trace and treats the parent span as unavailable, exactly as it would for any non-recording caller. There is a second case where the context, not the activity, decides what goes on the wire: an [untrusted edge](./aspnetcore#trusted-and-untrusted-callers). There `IContext.TraceId` is server-minted while the ambient activity is still parented to the caller, and the header is written from the context — the span id and sampled flag from the activity, since they describe the span actually making the call, and `tracestate` dropped because it is vendor state scoped to the caller's trace. Whenever the two agree, which is every other case, the platform propagator writes the header as usual. It finds the context through the execution context rather than through DI. `IHttpClientFactory` builds message handlers in a scope of its own and caches them across requests, so an injected `IContextAccessor` would never be the one belonging to the unit of work making the call. Two things follow: the handler needs no scope of its own and can be registered anywhere, and the context has to have been read on the same execution flow as the outbound call — which is the case for anything a handler does, but not for work fired off with `Task.Run` before the context was ever touched. ### Outbound: the outbox This is the boundary that matters most, and the one that used to break the chain. `OutboxEntry.Headers` holds the propagation headers **captured when the event was stored**. It has to be captured then rather than read at dispatch time, because by dispatch time the producing request is gone. On dispatch, Synapse: 1. rebuilds the flow state from the stored headers; 2. starts a `synapse.outbox.dispatch` activity **parented on the stored trace context**, so the dispatch joins the originating trace and the whole flow — click to mail — shows up as one trace under one id; 3. dispatches the entry **in a scope of its own**, with the rebuilt state written to that scope's `IInboundContextStore` — so the handlers' `IContext` carries the entry's trace id, its causation id and the baggage it was stored with. The third step is what makes the first two reach your handlers, and it is why dispatching an entry is a **separate unit of work** rather than a continuation of whoever called `CommitAsync`. Two things fall out of that: - **Handlers get their own scope.** Scoped services they resolve — a `DbContext`, a unit-of-work — are *not* the ones the committing request used. An event handler that expects to enlist in the caller's transaction is expecting something the outbox deliberately does not provide: the event was stored precisely so its handling could happen after the transaction committed. - **Entries do not borrow each other's identity.** Pending entries are retrieved across scopes, so a per-request `CommitAsync` dispatches whatever any request stored. Each entry is dispatched under the flow it was stored in, and the caller comes back out on the trace it went in on. Restoring the flow into the scope is also what makes this work on a host with **no tracing wired**: with no registered `ActivityListener`, `StartActivity` returns `null`, so the stored trace context has no span to parent and the scope's context is the only thing carrying it. The parent span has already ended by then. That is expected and legal: an entry dispatched hours later, or retried, simply adds spans to a long-lived trace, which is what makes the whole flow reviewable in one place. Capture works on a host with no tracing too, because it writes `traceparent` from the context when there is no activity to take it from (see [above](#outbound-http)). An entry stored with a context always carries a trace id; only an event stored *outside any unit of work* has none, and its dispatch is genuinely a root. If you implement `IEventOutboxStorage`, keep the headers as given. Synapse tolerates keys that differ only by case on the way back in — a case-sensitive column can produce both `traceparent` and `TraceParent` — and one unreadable entry never aborts the rest of the batch. :::note Batch consumers must use links instead Parenting expresses a *single* cause. A consumer that handles several messages with several different parents in one span has to attach an `ActivityLink` per message — the OpenTelemetry messaging conventions require it there. ::: If you implement `IEventOutboxStorage` yourself, **store `headers` alongside the payload**. Without them a dispatched entry cannot be tied back to the action that produced it. ### What counts as a boundary Propagation only kicks in where a **new context** is built. That distinction decides whether `CausationId` is set: | Hop | New context? | `CausationId` | | --- | --- | --- | | `EmitMode.Now` — event dispatched in the same scope | no | unchanged; it is the same unit of work | | Outbox dispatch | yes | the producing span's id | | Inbound HTTP from another service | yes | the caller's span id | | Broker message consumed in a fresh scope | yes | the sender's span id | `TraceId`, by contrast, is the same across every row — that is the point of having both. Because causation comes from span ids, it is `null` wherever nothing upstream was recording. ### Why extraction happens at creation `IContextFactory.Create` takes the inbound state as a parameter, and a context's identity can never be changed afterwards. That is a deliberate constraint, not an inconvenience. The alternative — create a context, then patch the trace id onto it when the boundary adapter runs — fails in a way that is very hard to notice. `IContext` is a scoped DI registration, so whichever component resolves it *first* pins the instance. Patching afterwards leaves earlier readers holding the old identity while later ones see the new one: handlers log one trace id while the response header reports another. With extraction at creation there is exactly one context object per scope, and the boundary adapter's ordering relative to other components stops mattering — it only has to run before the first component actually *reads* the context. ### Limits and safety - Baggage is capped at **64 entries and 8192 bytes** (`BaggageLimits`), enforced on both `SetBaggage` and extraction. Excess is dropped and logged, never thrown: a peer sending bad baggage must not be able to fail your request. - The 8192 bytes are counted on the **wire form**, percent-encoded. A value made of characters that need escaping — commas, spaces, accents — costs up to three bytes per byte, so it uses up the budget faster than its length suggests. - Keys must be header tokens: no commas, equals signs, whitespace or control characters. **Values are less restricted** — they are percent-encoded, so `Acme, Inc` or `a=b` is fine; only control characters are refused. - Malformed inbound baggage degrades entry by entry — the usable entries survive. - What leaves the process as baggage is exactly `IContext.Baggage`. Entries living only on `Activity.Baggage` are not forwarded, so the platform's own baggage header never rides along and an untrusted boundary's decision to drop inbound baggage actually holds. - Baggage is visible to **every** downstream service, including third parties. Never put confidential values in it. - The trace id is a label for correlating logs and spans. A caller-supplied `traceparent` is exactly as forgeable as any other client input, so it is never an authorization input. ### See also - [Context](./context.mdx) — the three surfaces and what belongs in each. - [ASP.NET Core Integration](./aspnetcore#propagation-middleware) — middleware options and the untrusted edge. - [Outbox](./outbox.mdx) — storage, retries and dead-lettering. - [Observability](./observability.mdx) — activities, metrics and log scopes. --- ## Source Generator `UnambitiousFx.Synapse.Generator` is a Roslyn incremental source generator that reads your handler class attributes and emits a registration class at compile time. This eliminates manual `RegisterRequestHandler` / `RegisterEventHandler` calls and makes the codebase NativeAOT-compatible by capturing dispatch delegates without reflection. ### Install ```bash dotnet add package UnambitiousFx.Synapse.Generator ``` No further setup is needed — the generator runs automatically during the build. ### Mark your handlers Annotate each handler class with the corresponding attribute: #### `[RequestHandler]` — for `IRequest` ```csharp using UnambitiousFx.Synapse.Abstractions; [RequestHandler] public class CreateTaskHandler : IRequestHandler { public async ValueTask> HandleAsync(CreateTaskCommand cmd, CancellationToken ct = default) { // ... return Result.Success(Guid.NewGuid()); } } ``` #### `[RequestHandler]` — for `IRequest` (no response) ```csharp [RequestHandler] public class DeleteTaskHandler : IRequestHandler { public async ValueTask HandleAsync(DeleteTaskCommand cmd, CancellationToken ct = default) { // ... return Result.Success(); } } ``` #### `[EventHandler]` — for `IEventHandler` ```csharp [EventHandler] public class AuditTaskCreated : IEventHandler { public ValueTask HandleAsync(TaskCreatedEvent @event, CancellationToken ct = default) { // ... return ValueTask.FromResult(Result.Success()); } } ``` #### `[StreamRequestHandler]` — for `IStreamRequestHandler` ```csharp [StreamRequestHandler] public class StreamTasksQueryHandler : IStreamRequestHandler { public async IAsyncEnumerable> HandleAsync( StreamTasksQuery query, [EnumeratorCancellation] CancellationToken ct = default) { // ... yield return Result.Success(new TaskDto()); } } ``` ### Generated class By default the generator produces one class named `RegisterGroup` in your assembly's **root namespace** — the MSBuild `RootNamespace` property (which may differ from the assembly name, e.g. assembly `App` with `Company.App`). Prefer a specific namespace, name, or accessibility? See [Customize the generated class](#customize-the-generated-class) below. #### `RegisterGroup` Implements both `IRegisterGroup` and `IEventDispatcherRegistration`. Contains one `RegisterXxxHandler<...>()` call per annotated handler, plus one AOT-safe dispatch delegate per `IEvent` type found in the assembly: ```csharp // Generated — do not edit public sealed class RegisterGroup : IRegisterGroup, IEventDispatcherRegistration { public void Register(IDependencyInjectionBuilder builder) { builder.RegisterRequestHandler(); builder.RegisterRequestHandler(); builder.RegisterEventHandler(); builder.RegisterStreamRequestHandler(); } public void RegisterDispatchers(Action register) { register( typeof(TaskCreatedEvent), static (e, dispatcher, ct) => dispatcher.DispatchAsync((TaskCreatedEvent)e, ct)); } } ``` ### Wire up the generated class Pass the generated class to `AddSynapse`: ```csharp services.AddSynapse(cfg => { cfg.AddRegisterGroup(new RegisterGroup()); }); ``` `AddRegisterGroup` automatically detects that `RegisterGroup` implements `IEventDispatcherRegistration` and registers the AOT-safe dispatch delegates — no second call needed. :::caution NativeAOT requirement `RegisterGroup` implements `IEventDispatcherRegistration`, so the single `AddRegisterGroup` call is sufficient for NativeAOT. Without the source generator, event dispatch falls back to runtime `typeof(T)` resolution which the AOT compiler cannot analyze. ::: ### Customize the generated class To choose the namespace, name, or accessibility of the generated class, declare your own **`partial` class** and mark it with `[RegisterGroup]`. The generator then fills that class in as a partial — implementing `IRegisterGroup` and `IEventDispatcherRegistration` — instead of emitting the default `RegisterGroup` in the root namespace: ```csharp using UnambitiousFx.Synapse.Abstractions; namespace Company.App.Composition; [RegisterGroup] public partial class AppRegistrations; // the generator supplies the implementation ``` Wire it up by its own name: ```csharp services.AddSynapse(cfg => { cfg.AddRegisterGroup(new AppRegistrations()); }); ``` Rules: - The class must be a **top-level, non-generic `partial class`** (diagnostics `MDG016`/`MDG017` otherwise). - Declare **at most one** `[RegisterGroup]` class per assembly (diagnostic `MDG015` otherwise). - The generator emits no accessibility modifier on its partial, so the visibility (`public`, `internal`, …) is whatever you declare on your own partial. When no `[RegisterGroup]` class is present, the default `public sealed class RegisterGroup` in the root namespace is generated as shown above. ### Incremental generation The generator is an `IIncrementalGenerator` — it only re-generates when a handler class is added, removed, or its attributes change. Build times are not affected significantly in large solutions. ### Diagnostics The same `UnambitiousFx.Synapse.Generator` package also ships Roslyn analyzers. They report registration mistakes at compile time and all five default to `Warning`. `SYN103` is live in the editor. `SYN101`, `SYN102`, `SYN104` and `SYN105` are compilation-end analyzers: they need the whole assembly, so they report on a build or a full-solution analysis rather than as you type. | ID | When it fires | Fix | |---|---|---| | `SYN101` | A request type (`IRequest` or `IRequest`) declared in this assembly has no handler in this assembly. Events and stream requests are not checked. A contracts-only assembly (requests without handlers) warns once per request, so set `dotnet_diagnostic.SYN101.severity = none` there. | Add a handler class with the matching `[RequestHandler...]` attribute, or disable the rule if the handler lives in another project. | | `SYN102` | A `[PipelineBehavior]` behavior (or an `[assembly: SynapseGlobalBehavior(...)]` entry) applies to no handler visible from this assembly (for example its generic constraint matches no request), so it never runs. A `[PipelineBehavior]` in a shared library with no handlers of its own is inert in the consuming host too (see [modular monolith](./modular-monolith)), so the warning is real. Global entries whose behavior type comes from a referenced assembly are not reported. | Widen or correct the constraint, or delete the behavior. For a shared library, declare `[assembly: SynapseGlobalBehavior(typeof(...))]` in the composition root instead. | | `SYN103` | A handler attribute is placed on a `record`. The generator ignores records, so the handler is not registered. | Declare the handler as a `class`. | | `SYN104` | A class implements a handler interface but has no handler attribute, in an assembly that uses the attributes elsewhere. | Add the attribute, or register the handler manually and silence the rule for that class with `#pragma warning disable SYN104`. | | `SYN105` | `[assembly: SynapseGlobalBehavior(...)]` is declared and this assembly calls `AddSynapse(...)` (so it is a composition root), but no `AddRegisterGroup(...)` call registers this assembly's own generated group — the attribute is accepted and the behavior never runs, with no error anywhere else. An assembly that declares the attribute with no `AddSynapse(...)` call of its own (a shared library, not a host) is not reported: the attribute is inert there regardless, since assembly attributes are not inherited by referencing assemblies. | Register this assembly's own generated group: `cfg.AddRegisterGroup(new .RegisterGroup())` (or your `[RegisterGroup]` partial's name), alongside the other groups. | Disable a rule per project or per folder with an `.editorconfig` entry: ```ini [*.cs] dotnet_diagnostic.SYN101.severity = none ``` Two limits to keep in mind: - `SYN101` only sees the current assembly. A request whose handler is in another project needs the rule disabled in the project that declares the request. - `SYN104` cannot see manual registration. A handler you register by hand with `RegisterRequestHandler` or `RegisterEventHandler` is still reported when it has no attribute; silence it per class with `#pragma warning disable SYN104`, or disable the rule. The analyzers work at compile time and complement the runtime [`ValidateSynapse()`](./pipelines#validating-the-configuration) check. `SYN105` catches the `[assembly: SynapseGlobalBehavior]` case of this (issue #91): a plain handler group that is never registered at all is a different, louder failure — it fails at the first dispatch attempt instead of staying silent, so no analyzer is needed for it. ### See also - [Commands and Queries](./commands-and-queries) — `IRegisterGroup` and modular registration. - [Events](./events) — `IEventDispatcherRegistration` and polymorphic dispatch. - [ASP.NET Core Integration](./aspnetcore) — NativeAOT setup with `CreateSlimBuilder`. --- ## Streaming Streaming lets a handler yield results one item at a time via `IAsyncEnumerable>`. Use it when the full result set is too large to materialize in memory, or when you want to start sending data to the client before all items are ready. ### Define a streaming request A streaming request implements `IStreamRequest`: ```csharp using UnambitiousFx.Synapse.Abstractions; public record StreamTasksQuery(string? Filter) : IStreamRequest; ``` ### Implement the handler ```csharp using System.Runtime.CompilerServices; using UnambitiousFx.Functional; using UnambitiousFx.Synapse.Abstractions; public class StreamTasksQueryHandler : IStreamRequestHandler { private readonly ITaskRepository _repository; public StreamTasksQueryHandler(ITaskRepository repository) => _repository = repository; public async IAsyncEnumerable> HandleAsync( StreamTasksQuery query, [EnumeratorCancellation] CancellationToken ct = default) { await foreach (var task in _repository.StreamAsync(query.Filter, ct)) { yield return Result.Success(TaskDto.From(task)); } } } ``` Key points: - Return type is `IAsyncEnumerable>` — each item is independently wrapped in a `Result`. - Decorate `CancellationToken` with `[EnumeratorCancellation]` so that cancellation tokens passed to `await foreach` are forwarded correctly. - Yield `Result.Success(item)` for successful items; yield `Result.Failure(...)` to signal a per-item error without aborting the entire stream. ### Register the handler ```csharp services.AddSynapse(cfg => { cfg.RegisterStreamRequestHandler(); }); ``` ### Invoke the stream Use `IInvoker.InvokeStreamAsync`. The item type is inferred from `IStreamRequest`: ```csharp await foreach (var result in invoker.InvokeStreamAsync(new StreamTasksQuery(filter: null), ct)) { if (result.TryGet(out var dto, out var error)) Console.WriteLine(dto.Title); else Console.WriteLine($"Item error: {error}"); } ``` ### Per-item error handling Because each item is a `Result`, a failure in one item does not cancel the rest of the stream. This is useful when some items may be unavailable or fail to deserialize: ```csharp await foreach (var result in invoker.InvokeStreamAsync(new StreamTasksQuery(), ct)) { result.Match( success: dto => ProcessItem(dto), failure: error => LogError(error)); // stream continues after this } ``` ### ASP.NET Core — streaming HTTP responses When using `IHttpInvoker` (from `UnambitiousFx.Synapse.AspNetCore`), call `InvokeStreamAsync` to get back an `IAsyncEnumerable` with successful items already unwrapped and failures silently skipped: ```csharp app.MapGet("/tasks/stream", ([FromServices] IHttpInvoker invoker, CancellationToken ct) => invoker.InvokeStreamAsync(new StreamTasksQuery(), ct)); ``` Returning `IAsyncEnumerable` directly from a Minimal API endpoint causes ASP.NET Core to stream the JSON array to the client rather than buffering the entire response. ### See also - [Commands and Queries](./commands-and-queries) — for non-streaming requests. - [ASP.NET Core Integration](./aspnetcore) — `IHttpInvoker.InvokeStreamAsync` and HTTP streaming. - [Pipeline Behaviors](./pipelines) — `IStreamRequestPipelineBehavior` for stream cross-cutting concerns. --- ## Using these docs with AI These docs are built to be consumed by AI coding assistants, not just humans. There are two ways to plug them in. ### 1. llms.txt (zero setup) Every build publishes machine-readable bundles following the [llmstxt.org](https://llmstxt.org) standard: | File | Contents | |------|----------| | [`/llms.txt`](https://synapse.unambitiousfx.com/llms.txt) | Index of every doc section with links | | [`/llms-full.txt`](https://synapse.unambitiousfx.com/llms-full.txt) | All documentation in one file | | [`/llms-pipelines.txt`](https://synapse.unambitiousfx.com/llms-pipelines.txt) | Pipelines, validation, error handling | | [`/llms-outbox.txt`](https://synapse.unambitiousfx.com/llms-outbox.txt) | Events, outbox, observability | Paste a URL into any assistant, or let tools that understand `llms.txt` fetch them automatically. Pull a focused bundle when you only need one concern. ### 2. Context7 MCP (live, always current) [Context7](https://context7.com) indexes these docs and serves them over MCP, so an assistant can query them on demand without you pasting anything. Library ID: **`/UnambitiousFx/Synapse`** #### Claude Code ```bash claude mcp add --transport http context7 https://mcp.context7.com/mcp ``` Then ask: *"use context7 — how do I register a pipeline behavior in Synapse?"* #### Cursor / VS Code Add Context7 as an MCP server (`https://mcp.context7.com/mcp`) in your editor's MCP settings, then reference the `/UnambitiousFx/Synapse` library in prompts. :::tip Several unrelated projects are named "Synapse" on Context7. Always use the fully-qualified ID `/UnambitiousFx/Synapse` to get these docs. ::: --- ## 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.