# 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 -- "PublishEventAsync / EmitAsync" --> IEmitter IEmitter --> Dispatcher Dispatcher --> Orchestrator Orchestrator --> EventHandlers ``` ## Packages ```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 ``` ```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`, `UseCorrelationId` 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 ```bash dotnet add package UnambitiousFx.Synapse ``` ```xml ``` For web API projects, also install the ASP.NET Core integration layer: ```bash dotnet add package UnambitiousFx.Synapse.AspNetCore ``` ```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. | Concept | Intent | Marker interface | | ----------- | -------------------------------------------------- | ----------------------------------- | | **Command** | Mutates state, may return an ID or acknowledgement | `IRequest` or `IRequest` | | **Query** | Reads state, always returns data | `IRequest` | For strict command/query separation at runtime, see [Pipeline Behaviors — CQRS enforcement](./pipelines#cqrs-boundary-enforcement). ## 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 publish from within a handler via `IContext`: ```csharp public class CreateTaskHandler : IRequestHandler { private readonly IContext _context; public CreateTaskHandler(IContext context) => _context = context; public async ValueTask> HandleAsync(CreateTaskCommand cmd, CancellationToken ct = default) { var id = Guid.NewGuid(); // ... persist ... await _context.PublishEventAsync(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 `CommitEventsAsync` or `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) — publish events from inside a handler via `IContext.PublishEventAsync`. --- ## Pipeline Behaviors Pipeline behaviors let you wrap request, event, or stream handling with cross-cutting logic — logging, tracing, validation, or CQRS enforcement — without touching handler code. They follow the **chain-of-responsibility** (Russian-doll) pattern: each behavior calls `next(...)` to forward to the next behavior or to the handler. All Synapse pipeline behaviors are **typed**: a behavior is bound to a specific request/response (or event, or stream-item) type through the generic interface it implements. The non-generic `HandleAsync(...)` style does not exist — `HandleAsync` is a plain method and `next` is a strongly-typed delegate that takes the request and a `CancellationToken`. ## How the pipeline works ```mermaid flowchart LR Invoker["IInvoker"] B1["Behavior 1\n(outermost)"] B2["Behavior 2"] B3["Behavior N"] Handler["Handler\n(innermost)"] Invoker --> B1 B1 -- next() --> B2 B2 -- next() --> B3 B3 -- next() --> Handler Handler -- Result --> B3 B3 -- Result --> B2 B2 -- Result --> B1 B1 -- Result --> Invoker ``` Each behavior can inspect or modify the request and the result on both sides of `next(...)`. Order is controlled explicitly by [`IOrderedPipelineBehavior`](#ordering) — not by registration order. ## Request pipeline behaviors A request behavior implements one of two typed interfaces: - `IRequestPipelineBehavior` — for requests that return a value (`IRequest`). - `IRequestPipelineBehavior` — for fire-and-forget requests (`IRequest`). ```csharp public sealed class AuditCreateTaskBehavior : IRequestPipelineBehavior { public async ValueTask> HandleAsync( CreateTaskCommand request, RequestHandlerDelegate next, CancellationToken cancellationToken = default) { var result = await next(request, cancellationToken); if (result.TryGetValue(out var id)) Audit.Log($"Task created: {id}"); return result; } } // Register the behavior for its request/response pair cfg.RegisterRequestPipelineBehavior(); ``` For a fire-and-forget command, implement `IRequestPipelineBehavior` and register with the two-type-argument overload: ```csharp public sealed class AuditDeleteTaskBehavior : IRequestPipelineBehavior { public ValueTask HandleAsync( DeleteTaskCommand request, RequestHandlerDelegate next, CancellationToken cancellationToken = default) => next(request, cancellationToken); } cfg.RegisterRequestPipelineBehavior(); ``` ## Applying a behavior to every handler Naming each request/response pair is fine for one-off behaviors, but cross-cutting concerns (logging, tracing, auth) usually apply to *all* handlers. Make the behavior **open-generic** and mark it `[PipelineBehavior]` — the Synapse source generator discovers it, infers the kind/arity from the implemented interface, and emits one closed (Native-AOT-safe) registration per matching handler: ```csharp [PipelineBehavior] public sealed class TimingBehavior : IRequestPipelineBehavior where TRequest : IRequest where TResponse : notnull { private readonly ILogger> _logger; public TimingBehavior(ILogger> logger) => _logger = logger; public async ValueTask> HandleAsync( TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken = default) { var sw = Stopwatch.StartNew(); var result = await next(request, cancellationToken); _logger.LogInformation("{Request} completed in {Ms}ms", typeof(TRequest).Name, sw.ElapsedMilliseconds); return result; } } ``` By default an assembly's `[PipelineBehavior]` classes are applied to handlers in **referenced** assemblies too. Apply `[assembly: DisableSynapseCrossAssemblyBehaviors]` to limit the cross-product to handlers declared in the same assembly. :::note Native AOT Registering an open-generic behavior at runtime (`AddOpenGenericRequestPipelineBehavior(typeof(...))`) is supported but is **not** Native-AOT safe for value-type responses. Prefer `[PipelineBehavior]` so the generator emits closed registrations. ::: ### Registering a behavior you don't own (e.g. from a NuGet package) `[PipelineBehavior]` requires decorating the behavior class, so it only works for behaviors whose source you control. When a behavior ships in a referenced package — for example a company's shared pipeline library — opt into it from the composition root with `[assembly: SynapseGlobalBehavior(typeof(...))]`. The generator treats it exactly like a `[PipelineBehavior]` open generic: it closes the behavior over every matching handler and emits one closed, Native-AOT-safe registration per match. ```csharp [assembly: SynapseGlobalBehavior(typeof(CompanyShared.LoggingBehavior<,>))] ``` Pass the **unbound** generic definition (`typeof(Foo<,>)`) — a non-generic `typeof` is the only way to hand an open generic to an attribute. The behavior type must be `public` and implement one of the pipeline interfaces; its open-generic constraints (named-type and `class`/`struct`/`unmanaged`/`notnull`/`new()`) are honoured, and pipeline position is controlled by the behavior implementing `IOrderedPipelineBehavior`. The same cross-assembly and `[assembly: DisableSynapseCrossAssemblyBehaviors]` rules apply. ## Built-in behaviors ### `SimpleLoggingBehavior` / `SimpleLoggingEventBehavior` Logs the request or event name and elapsed time. Register the closed pair for each handler (`SimpleLoggingBehavior` for void requests): ```csharp cfg.RegisterRequestPipelineBehavior< SimpleLoggingBehavior, CreateTaskCommand, Guid>(); cfg.RegisterEventPipelineBehavior< SimpleLoggingEventBehavior, TaskCreatedEvent>(); ``` ### `LoggingEnrichmentBehavior` Enriches the `ILogger` scope with `CorrelationId` and all context metadata so every log line emitted during the request automatically includes them: ```csharp cfg.RegisterRequestPipelineBehavior< LoggingEnrichmentBehavior, CreateTaskCommand, Guid>(); ``` ### CQRS boundary enforcement `CqrsBoundaryEnforcementBehavior` / `CqrsBoundaryEnforcementBehavior` detect when a command is dispatched from inside a query handler (or vice versa) and throw `CqrsBoundaryViolationException`. The behavior runs outermost (`Order => IOrderedPipelineBehavior.First`). Enable it per request type: ```csharp cfg.RegisterCqrsBoundaryEnforcement(); cfg.RegisterCqrsBoundaryEnforcement(); ``` Or turn it on for the whole assembly by registering the built-in behavior globally — the source generator emits the closed (AOT-safe) registrations for every handler: ```csharp [assembly: SynapseGlobalBehavior(typeof(CqrsBoundaryEnforcementBehavior<>))] [assembly: SynapseGlobalBehavior(typeof(CqrsBoundaryEnforcementBehavior<,>))] ``` :::note `[assembly: EnableSynapseCqrsBoundaryEnforcement]` still works as an alias for the two registrations above, but is now `[Obsolete]` — `SynapseGlobalBehavior` is the general mechanism (see [Registering a behavior you don't own](#registering-a-behavior-you-dont-own-eg-from-a-nuget-package)). ::: :::warning The old runtime switch `cfg.EnableCqrsBoundaryEnforcement()` was removed — it is now `[Obsolete(error: true)]` and throws. Use `RegisterCqrsBoundaryEnforcement<...>()` or the assembly attribute instead. ::: ## Event pipeline behaviors Implement `IEventPipelineBehavior` for a specific event type: ```csharp public sealed class EventLoggingBehavior : IEventPipelineBehavior { private readonly ILogger _logger; public EventLoggingBehavior(ILogger logger) => _logger = logger; public ValueTask HandleAsync( TaskCreatedEvent @event, EventHandlerDelegate next, CancellationToken cancellationToken = default) { _logger.LogInformation("Dispatching event {Event}", typeof(TaskCreatedEvent).Name); return next(@event, cancellationToken); } } cfg.RegisterEventPipelineBehavior(); ``` To cover every event, make the behavior open-generic (`EventLoggingBehavior : IEventPipelineBehavior`) and mark it `[PipelineBehavior]`. ## Stream pipeline behaviors Implement `IStreamRequestPipelineBehavior`. The `next` delegate (`StreamRequestHandlerDelegate`) is parameterless and returns the downstream `IAsyncEnumerable>`: ```csharp public sealed class StreamLoggingBehavior : IStreamRequestPipelineBehavior { public IAsyncEnumerable> HandleAsync( StreamTasksQuery request, StreamRequestHandlerDelegate next, CancellationToken cancellationToken = default) { Console.WriteLine($"Stream started: {typeof(StreamTasksQuery).Name}"); return next(); } } cfg.RegisterStreamRequestPipelineBehavior(); ``` ## Ordering Behaviors run in the order given by `IOrderedPipelineBehavior` — **not** registration order. A behavior opts in by implementing the interface and exposing an `Order` (a `uint`); **lower values run first (outermost)**, higher run last (innermost, closest to the handler). A behavior that does not implement `IOrderedPipelineBehavior` is treated as `Last`. Ties are broken by registration order (stable). ```csharp public sealed class LoggingBehavior : IRequestPipelineBehavior, IOrderedPipelineBehavior where TRequest : IRequest where TResponse : notnull { public uint Order => IOrderedPipelineBehavior.First; // outermost public ValueTask> HandleAsync( TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken = default) => next(request, cancellationToken); } ``` `IOrderedPipelineBehavior` exposes three convenience positions: `First` (`uint.MinValue`), `Middle` (`uint.MaxValue / 2`), and `Last` (`uint.MaxValue`). For example, register logging at `First` so it captures the full round-trip, and validation closer to the handler. ## See also - [Validation](./validation) — `RequestValidationBehavior`, the built-in validation pipeline. - [Observability](./observability) — `SimpleLoggingBehavior` and `LoggingEnrichmentBehavior` in detail. - [Source Generator](./source-generator) — `[PipelineBehavior]` discovery and closed-registration emission. - [Error Handling](./error-handling) — behaviors can short-circuit by returning `Result.Failure(...)` instead of calling `next(...)`. --- ## 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 ```bash dotnet add package UnambitiousFx.Synapse.AspNetCore ``` ```xml ``` ## Setup ```csharp builder.Services.AddSynapse(cfg => { /* ... */ }); builder.Services.AddSynapseAspNetCore(); var app = builder.Build(); app.UseCorrelationId(); // optional — reads/writes X-Correlation-Id header ``` ## 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. ::: ## Correlation ID middleware `UseCorrelationId()` reads the incoming `X-Correlation-Id` header and sets it on `IContext.CorrelationId`. It also writes the correlation ID back to the response header so callers can correlate requests across service boundaries: ```csharp app.UseCorrelationId(); ``` If no `X-Correlation-Id` header is present, the context uses the ID already generated by `IContextFactory`. :::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.CorrelationId` and the `UseCorrelationId` header 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.5.2 [Darwin 25.5.0] Apple M5 Pro, 18 logical / 18 physical cores .NET SDK 10.0.301, .NET 10.0.9, 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 | 17.3 ns | 0.81 | 88 B | | **Synapse** | **21.4 ns** | **1.00** | **72 B** | | MediatR | 27.7 ns | 1.30 | 200 B | | _Synapse (direct handler)_ | _6.8 ns_ | _0.32_ | _72 B_ | ### Send — request without response (void) | Library | Mean | Ratio | Allocated | |---|---:|---:|---:| | **Synapse** | **18.1 ns** | **1.00** | **0 B** | | Mediator | 20.2 ns | 1.12 | 88 B | | MediatR | 30.6 ns | 1.69 | 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 | 79.8 ns | 0.61 | 184 B | | **Synapse** | **130.4 ns** | **1.00** | **520 B** | | MediatR | 358.6 ns | 2.75 | 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.7 ns** | **1.00** | **72 B** | | Mediator | 28.6 ns | 1.26 | 240 B | | MediatR | 57.7 ns | 2.54 | 440 B | ### Pipeline — request through 3 behaviors | Library | Mean | Ratio | Allocated | |---|---:|---:|---:| | **Synapse** | **26.2 ns** | **1.00** | **72 B** | | Mediator | 53.4 ns | 2.04 | 496 B | | MediatR | 89.7 ns | 3.43 | 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 (18.1 vs 20.2 ns) and allocates nothing. - **Publish** — Mediator's generated fan-out wins; Synapse is ~2.75× 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/feature/typed-pipeline-behaviors/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/feature/typed-pipeline-behaviors/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/feature/typed-pipeline-behaviors/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/feature/typed-pipeline-behaviors/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/feature/typed-pipeline-behaviors/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 | ## Source Generator | ID | Summary | Severity | |---|---|---| | [005](https://github.com/UnambitiousFx/Synapse/blob/feature/typed-pipeline-behaviors/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/feature/typed-pipeline-behaviors/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/feature/typed-pipeline-behaviors/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/feature/typed-pipeline-behaviors/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/feature/typed-pipeline-behaviors/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/feature/typed-pipeline-behaviors/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/feature/typed-pipeline-behaviors/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 | ## Pipeline & CQRS | ID | Summary | Severity | |---|---|---| | [008](https://github.com/UnambitiousFx/Synapse/blob/feature/typed-pipeline-behaviors/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/feature/typed-pipeline-behaviors/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/feature/typed-pipeline-behaviors/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/feature/typed-pipeline-behaviors/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/feature/typed-pipeline-behaviors/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/feature/typed-pipeline-behaviors/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/feature/typed-pipeline-behaviors/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/feature/typed-pipeline-behaviors/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/feature/typed-pipeline-behaviors/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/feature/typed-pipeline-behaviors/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 | ## Observability | ID | Summary | Severity | |---|---|---| | [007](https://github.com/UnambitiousFx/Synapse/blob/feature/typed-pipeline-behaviors/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/feature/typed-pipeline-behaviors/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 | ## ASP.NET Core | ID | Summary | Severity | |---|---|---| | [003](https://github.com/UnambitiousFx/Synapse/blob/feature/typed-pipeline-behaviors/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 | --- > **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 provides a correlation ID, a metadata bag, and an extensible feature system. ## 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. ## Correlation ID Every context carries a `CorrelationId` (`Guid`). The default factory generates this with `Guid.CreateVersion7()` (.NET 9+) or `Guid.NewGuid()` on earlier runtimes. ```csharp public class MyHandler : IRequestHandler { private readonly IContext _context; public MyHandler(IContext context) => _context = context; public ValueTask> HandleAsync(MyCommand cmd, CancellationToken ct = default) { var correlationId = _context.CorrelationId; // use correlationId for logging, tracing, audit ... return ValueTask.FromResult(Result.Success(correlationId)); } } ``` With the ASP.NET Core integration, `UseCorrelationId()` reads the `X-Correlation-Id` request header and sets it on the context, then echoes it back in the response. See [ASP.NET Core Integration](./aspnetcore#correlation-id-middleware). ## Metadata The metadata bag is a `string → object` dictionary attached to the context. Use it to propagate ambient values through the request lifecycle without adding them to every method signature. ```csharp // Write context.SetMetadata("TenantId", "acme"); // Read with type safety if (context.TryGetMetadata("TenantId", out var tenantId)) Console.WriteLine(tenantId); // Read directly (returns null if missing) var tenantId = context.GetMetadata("TenantId"); ``` `DefaultContextFactory` automatically stores `OccuredAt` (`DateTimeOffset.UtcNow`) in metadata at context creation time. ## Context factories Two built-in factories are available. Configure them in `AddSynapse`: | Factory | What it creates | | ----------------------- | -------------------------------------------------------------------------------------------------------- | | `DefaultContextFactory` | New Guid v7 correlation ID + `OccuredAt` metadata + captures current `Activity` for distributed tracing. | | `SlimContextFactory` | Bare context — just a new `Guid`. No metadata, no tracing. | ```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 `IContext` exposes `PublishEventAsync` as syntactic sugar over `IEmitter.EmitAsync`. This lets handlers publish events without injecting `IEmitter` separately: ```csharp await context.PublishEventAsync(new TaskCreatedEvent(id, cmd.Title), ct); // With explicit emit mode await context.PublishEventAsync(new TaskCreatedEvent(id, cmd.Title), EmitMode.Outbox, ct); ``` ## Injecting the context `IContext` and `IContextAccessor` are registered in DI. Inject either one depending on whether you need to set or just read the context: ```csharp // Read-only — inject IContext directly public class MyHandler(IContext context) : IRequestHandler { ... } // Write — inject IContextAccessor to also be able to set the context public class MyMiddleware(IContextAccessor contextAccessor) { ... } ``` ## See also - [ASP.NET Core Integration](./aspnetcore) — `UseCorrelationId()` middleware. - [Pipeline Behaviors](./pipelines) — set context features in behaviors before the handler runs. - [Events](./events) — `IContext.PublishEventAsync` 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); } ``` ## 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. ::: --- ## 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 `CorrelationId` and all context metadata 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 { "CorrelationId": "019550a7-0000-7000-0000-000000000000", "OccuredAt": "2026-04-08T10:00:00Z" } ``` ## 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. --- ## 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 : CommitEventsAsync / 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 `context.CommitEventsAsync()` or `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 context.PublishEventAsync(new TaskCreatedEvent(id, title), EmitMode.Outbox, ct); ``` ### Flush after your side-effects commit ```csharp public class CreateTaskHandler : IRequestHandler { private readonly ITaskRepository _repository; private readonly IContext _context; public CreateTaskHandler(ITaskRepository repository, IContext context) { _repository = repository; _context = context; } public async ValueTask> HandleAsync(CreateTaskCommand cmd, CancellationToken ct = default) { var id = Guid.NewGuid(); await _repository.SaveAsync(id, cmd.Title, ct); // ← side-effect await _context.PublishEventAsync( new TaskCreatedEvent(id, cmd.Title), EmitMode.Outbox, ct); // ← store, don't dispatch yet await _context.CommitEventsAsync(ct); // ← dispatch after save succeeded return Result.Success(id); } } ``` You can also inject `IOutboxCommit` directly and call it outside the handler: ```csharp await outboxCommit.CommitAsync(ct); ``` ## 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 }); }); ``` | 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. | ## 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. ::: ## Replace the storage for production The built-in `InMemoryEventOutboxStorage` is suitable for testing and simple scenarios. For production, replace it with a persistent implementation: ```csharp cfg.SetEventOutboxStorage(); ``` Register `EfCoreEventOutboxStorage` in DI with whatever lifetime your database context uses, then implement `IEventOutboxStorage`: ```csharp public class EfCoreEventOutboxStorage : IEventOutboxStorage { private readonly AppDbContext _db; public EfCoreEventOutboxStorage(AppDbContext db) => _db = db; public async ValueTask AddAsync(TEvent @event, CancellationToken ct = default) where TEvent : class, IEvent { _db.OutboxEvents.Add(OutboxEvent.From(@event)); await _db.SaveChangesAsync(ct); return Result.Success(); } // ... implement remaining members } ``` ## 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). --- ## 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. ## 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.