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 commita row written 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, so consumers must be idempotent either way — see Handling duplicates.

In-memory integration bus

Register the in-memory tier with AddInMemoryEventBus() from Elarion.Messaging.InMemory. This wires the domain plane (AddInMemoryDomainEventBus) 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.AddInMemoryEventBus();   // 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.AddInMemoryEventBus(builder.Configuration);

If you only need the integration tier (the domain plane is already wired elsewhere), call AddInMemoryIntegrationEventBus() 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 elarion_outbox_messages + a partial pending index
}

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

Program.cs
builder.Services.AddElarionOutbox<BillingDbContext>(o => {
    o.SerializerOptions     = serializerOptions;        // reuse the host's source-generated options for AOT
    // o.PollingInterval    = TimeSpan.FromSeconds(1);  // idle poll interval
    // o.BatchSize          = 100;                       // messages claimed per poll
    // o.LeaseDuration      = TimeSpan.FromMinutes(2);   // claim lease before another worker may reclaim
    // o.MaxDeliveryAttempts = 10;                       // attempts before a message is left for inspection
    // o.RetentionPeriod    = TimeSpan.FromDays(7);     // null = keep delivered rows forever
});

This registers the durable IIntegrationEventBus (OutboxIntegrationEventBus), the storage (IOutboxStore / EfCoreOutboxStore<TDbContext>), the dispatcher (OutboxEventDispatcher), and the hosted OutboxDeliveryService. 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.AddInMemoryDomainEventBus();          // 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.

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 messages with a provider-neutral conditional ExecuteUpdate lease and a short LeaseDuration, dispatches them to integration consumers on isolated scopes, then finalizes and purges. Running a couple of instances (or overlapping old/new during a deploy) never double-delivers, and a crashed worker's in-flight messages are retried once their lease expires. A message 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.

The UseElarionOutbox pending index is a partial (filtered) index (processed_on_utc IS NULL), so the worker's "oldest undelivered first" scan stays a tiny indexed probe regardless of how many delivered rows await retention purge. Filtered indexes are supported on PostgreSQL, SQL Server, and SQLite; on MySQL, supply an unfiltered index in your own model.

Serializer options. OutboxOptions.SerializerOptions defaults to JsonSerializerDefaults.Web with a reflection-based DefaultJsonTypeInfoResolver. For trimming/AOT, supply the host's source-generated IJsonTypeInfoResolver — the same options used at runtime. See serialization.

On this page