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) | |
|---|---|---|
| Durability | best-effort; flushed-but-undelivered events are lost on crash | at-least-once; survives restarts |
| How it captures | a per-scope buffer flushed after commit | one immutable envelope per distinct target role in the same transaction as your data |
| Delivery | a hosted channel pump (EventDispatchPump) | a hosted worker that polls, leases, dispatches, retries (OutboxDeliveryService) |
| EF Core | needs interceptors on your DbContext (registered for you) | needs the outbox table on your DbContext |
| Use when | events are advisory and loss is acceptable | events 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.
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:
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):
[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:
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:
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 atomicallyIf 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:
builder.Services.AddElarionOutbox<BillingDbContext>(o =>
o.RunDeliveryWorker = builder.Configuration.GetValue("Outbox:RunDeliveryWorker", true));{ "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.
Consuming events
Declare event consumers as handlers or service methods, for both inline domain events and after-commit integration events, and keep them idempotent.
Client events (near-realtime)
Push after-commit facts to connected browsers as invalidation hints — typed topics, fail-closed subscriptions, and a generated TypeScript client over Server-Sent Events.