# Synapse Events, Outbox & Observability > Event publishing, the outbox pattern, and observability. This file contains all documentation content in a single document following the llmstxt.org standard. ## 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. --- ## 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. --- ## 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).