Elarion

Event backends

Choose between the best-effort in-memory integration bus and the durable EF Core transactional outbox, and wire the one you pick.

The domain plane (IDomainEventBus) is always in-memory and inline — there is no backend choice to make there. The integration plane (IIntegrationEventBus) is the only broker-portable plane, and it is where you pick a backend. Two ship: a best-effort in-memory tier and the durable EF Core transactional outbox. (For the plane model itself, see the events overview.)

Choosing a backend

Recommended: the EF Core outbox for anything that must not be lost. The in-memory tier is a best-effort convenience for events you can afford to drop on a crash.

In-memory (Elarion.Messaging.InMemory)EF Core outbox (Elarion.Messaging.Outbox)
Durabilitybest-effort; flushed-but-undelivered events are lost on crashat-least-once; survives restarts
How it capturesa per-scope buffer flushed after commitone immutable envelope per distinct target role in the same transaction as your data
Deliverya hosted channel pump (EventDispatchPump)a hosted worker that polls, leases, dispatches, retries (OutboxDeliveryService)
EF Coreneeds interceptors on your DbContext (registered for you)needs the outbox table on your DbContext
Use whenevents are advisory and loss is acceptableevents drive real side effects that must happen

Both deliver at-least-once and unordered to consumers. Handler-form consumers are deduped by the built-in inbox by default (a per-consumer claim on the message id, committed with the consumer's own writes); method-form and opted-out consumers must absorb duplicates themselves — see Handling duplicates.

In-memory integration bus

Register the in-memory tier with AddElarionInMemoryEventBus() from Elarion.Messaging.InMemory. This wires the domain plane (AddElarionDomainEventBus) plus the in-memory integration plane, whose after-commit delivery is commit-gated by the database transaction without a hand-written decorator: the package's EF Core interceptors are registered automatically and flush buffered events after the DbContext commits and discard them on rollback.

Program.cs
builder.Services.AddElarionInMemoryEventBus();   // domain plane + in-memory integration plane (interceptors included)

InMemoryIntegrationEventBus.PublishAsync buffers each event into a per-scope EventDispatchScope; the EventDispatchSaveChangesInterceptor/EventDispatchTransactionInterceptor flush that buffer to the hosted EventDispatchPump after commit (and discard it on rollback). The pump drains a bounded channel and dispatches each event on an isolated scope.

A configuration overload reads the EventBus section — EventBus:Enabled (default true) and EventBus:DeliveryChannelCapacity (default 1024, the back-pressure bound on flushed-but-undelivered events) — and throws on a non-boolean/non-integer value:

builder.Services.AddElarionInMemoryEventBus(builder.Configuration);

If you only need the integration tier (the domain plane is already wired elsewhere), call AddElarionInMemoryIntegrationEventBus() directly; it registers IIntegrationEventBus, the scope buffer, the pump, and the two interceptors, sharing the same EventSubscriptionRegistry.

The interceptors are resolved per scope by AddDbContext, so the in-memory integration tier needs an EF Core context registered the normal way. Events flushed but not yet delivered when the process exits are lost — reach for the outbox when that is unacceptable.

EF Core transactional outbox

The outbox makes integration delivery durable by writing each event as a row in your DbContext, committed atomically with the business data. A background worker delivers it after commit. It replaces the in-memory integration tier; the domain plane and the generated consumer descriptors are registered separately.

Add the table to the context that owns your business entities, in OnModelCreating:

BillingDbContext.cs
protected override void OnModelCreating(ModelBuilder modelBuilder) {
    base.OnModelCreating(modelBuilder);
    modelBuilder.UseElarionOutbox();   // adds the target-group envelope table
}

UseElarionOutbox takes optional tableName and schema parameters plus a snakeCase toggle (default true; false switches the table/column/index names to PascalCase — the default becomes ElarionOutboxMessages), for example modelBuilder.UseElarionOutbox("app_outbox", "app").

On a [GenerateDbSets] context you can skip the hand-written call entirely: annotate the context with [GenerateElarionOutbox] (optionally with SnakeCase/TableName/Schema) and the bundled generator emits the DbSet and applies the same model configuration through the EF generator's model-config seam (ELOBX001 if [GenerateDbSets] is missing):

BillingDbContext.cs
[GenerateDbSets]
[GenerateElarionOutbox]
public sealed partial class BillingDbContext(DbContextOptions<BillingDbContext> options) : DbContext(options) {
    protected override void OnModelCreating(ModelBuilder modelBuilder) => ConfigureEntities(modelBuilder);
}

Register the tier in the host, generic over that context:

Program.cs
builder.Services.AddElarionOutbox<BillingDbContext>(o => {
    // Event payloads use the canonical IElarionJsonSerialization by default (contributed by AddElarion) —
    // register your event DTOs in a module JSON context. Set o.SerializerOptions only to override.
    // o.PollingInterval    = TimeSpan.FromSeconds(1);  // idle poll interval
    // o.BatchSize          = 100;                       // target groups claimed per poll
    // o.LeaseDuration      = TimeSpan.FromMinutes(2);   // group lease before another worker may reclaim
    // o.MaxDeliveryAttempts = 10;                       // attempts before one group is left for inspection
    // o.BaseRetryDelay     = TimeSpan.FromSeconds(5);   // backoff base: a failed message is invisible for BaseRetryDelay × 2^(attempts-1)
    // o.MaxRetryDelay      = TimeSpan.FromHours(1);     // ceiling on the exponential retry backoff
    // o.RetentionPeriod    = TimeSpan.FromDays(7);     // null = keep delivered rows forever
    // o.RunDeliveryWorker  = true;                      // false = publish-only node; another instance runs delivery
});

This registers the durable IIntegrationEventBus (OutboxIntegrationEventBus), the storage (IOutboxStore / EfCoreOutboxStore<TDbContext>), the dispatcher (OutboxEventDispatcher), and the hosted OutboxDeliveryService (skipped when RunDeliveryWorker is false — see Where the worker runs). The bus is registered last-wins, so the outbox is authoritative even if an in-memory integration bus was registered for the domain plane's sake.

Register the domain plane and the consumers. The domain plane is always in-memory and inline — wire it on its own so only the outbox delivers Plane B:

Program.cs
builder.Services.AddElarionDomainEventBus();          // domain plane + registry
builder.Services.AddElarionOutbox<BillingDbContext>(); // integration plane (durable)

Consumer descriptors are registered separately — automatically and feature-gated when you use [GenerateModuleBootstrapper] (see Module gating), or explicitly via the generated Add{Assembly}EventConsumers(). If you only publish integration events you don't need the in-memory tier at all — the outbox and the consumer descriptors are enough. Publishing requires the descriptors because it resolves consumers into target groups before persistence.

Publish before you save. PublishAsync only tracks the outbox row — it does not call SaveChanges. Your unit of work must persist it within the same transaction as the business data, so publish before the SaveChanges that commits the command:

db.Invoices.Add(invoice);
await integrationEvents.PublishAsync(new InvoiceCreated(invoice.Id, client.Email), ct);
await db.SaveChangesAsync(ct);   // persists the invoice and the outbox row atomically

If the transaction rolls back, both the invoice and the event vanish together — no half-published notifications. The database transaction provides the commit-gating directly, so the outbox needs no per-scope flush/discard buffer.

How delivery stays safe

The OutboxDeliveryService claims pending target-group envelopes with a provider-neutral conditional ExecuteUpdate lease and a short LeaseDuration, dispatches each on an isolated scope, then finalizes and purges. A crashed worker's in-flight group is retried once its lease expires. A group that fails past MaxDeliveryAttempts is left for inspection and no longer retried. Defaults are tuned for low/single-instance deployments — in multi-instance deployments keep LeaseDuration comfortably above the time to deliver a full BatchSize batch.

Delivery is not atomic with your consumers. Each handler-form consumer commits its own transaction; the worker then finalizes the target group — a separate lease-guarded write on the worker's connection. A crash between a consumer's commit and group finalize can redeliver consumers in that group. The built-in inbox absorbs the remaining at-least-once window for handler-form consumers by default — each consumer claims the message id inside its own transaction, so a redelivery is acknowledged instead of re-run (pair with AddElarionIdempotencyEntityFrameworkCore so claims survive restarts) — see Handling duplicates.

The common path stays one row. When an event has no role-routed consumer, publish does not enumerate handlers or persist consumer ids: an immutable startup catalog answers that case in O(1). Delivery uses one lease, one payload deserialization, one scope, ordered sequential invocation, and one finalize regardless of consumer count. Multiple rows and duplicated payloads appear only when one publish genuinely resolves to multiple execution targets.

Finalizing is lease-guarded. Every finalize — mark delivered, mark failed, or park — is conditioned on the finalizing worker still owning the lease it claimed under. If a worker stalls past its lease and another worker legitimately reclaims and delivers the message, the stalled worker's late finalize matches zero rows, is skipped, and is logged at Warning — it can never wipe the new owner's active lease and trigger overlapping redelivery.

Failures back off. A failed delivery is made invisible for BaseRetryDelay × 2^(attempts-1) (capped at MaxRetryDelay) via a visibility timeout, so a poison consumer no longer re-enters the front of every claim batch at full poll frequency — head-of-line blocking is avoided and retries back off. Set BaseRetryDelay to TimeSpan.Zero to retry on the next poll instead.

Unresolvable groups are parked, not dropped. A missing ConsumerId, incompatible event-type rename, or null payload parks only that target group. New publishes fail immediately when no consumer is registered, so malformed fan-out is caught before SaveChanges.

Where the worker runs

Every publishing instance must register the same generated consumer catalog because publish-time grouping stores target roles and, for role-routed events, stable consumer ids. RunDeliveryWorker = false is still available for a fixed publish-only process, but it disables claiming only; it does not remove the catalog requirement:

Program.cs (web node)
builder.Services.AddElarionOutbox<BillingDbContext>(o =>
    o.RunDeliveryWorker = builder.Configuration.GetValue("Outbox:RunDeliveryWorker", true));
appsettings.json (web node)
{ "Outbox": { "RunDeliveryWorker": false } }   // the worker-role instance leaves the default (true)

Role-bound groups are different: every worker may run, but it claims a group only when its TargetRole is null or appears in the local IRoleLeaseRegistry as held. Generated SingleHome and VirtualShards actor consumers populate that role automatically; failover transfers eligibility without HTTP forwarding or a global delivery gate. The worker rechecks the live lease immediately before each dispatch; if ownership changed after the batch claim, it releases that group without incrementing its attempt count or applying backoff so the new holder can claim it immediately.

The envelope table has a partial (filtered) claim index over (target_role, occurred_on_utc, id) where processed_on_utc IS NULL (PascalCase equivalents with snakeCase: false). The worker's "oldest eligible delivery first" scan therefore stays on one table; completed rows awaiting retention purge are absent from the index. Filtered indexes are supported on PostgreSQL, SQL Server, and SQLite; on MySQL, supply an unfiltered index in your own model.

Retention purge starts from the processed-envelope index and deletes eligible groups in bounded batches.

Tracing across the commit boundary

Each outbox row persists the publisher's W3C traceparent (the trace_parent column), and delivery parents its consume {event} span on it — so in your tracing backend the after-commit consumers appear inside the trace of the command that published the event, even when delivery happens on another worker instance or after a restart. Delivery outcomes are also counted and timed, and failure logs carry the message's correlation id. Register EventTelemetry to collect the signals — see Telemetry.

Serializer options. OutboxOptions.SerializerOptions is null by default, so event payloads use the canonical IElarionJsonSerialization — register each event DTO in a module JSON context and it is trim/AOT-safe. Set SerializerOptions only to serialize the outbox differently from the rest of the app.

On this page