Elarion

Events & messaging

An in-process eventing subsystem split by its relationship to the database transaction — inline domain events and after-commit integration events.

Elarion's event bus is organized around one question: when does the event run relative to the database transaction? That split — not a verb like publish or notify — is the whole API. There are two planes, each its own interface in Elarion.Abstractions.Messaging, and an event binds to exactly one of them through a marker interface.

PlaneInterfaceMarkerRuns
DomainIDomainEventBusIDomainEventinline, in the caller's scope and transaction
IntegrationIIntegrationEventBusIIntegrationEventafter the transaction commits, on a separate scope

Consumers are reflection-free: the EventConsumerRegistrationGenerator discovers them, validates the signatures, and emits the registration and invocation delegates. There is no runtime assembly scanning or reflective method dispatch.

Binding an event to a plane

An event is a plain type carrying exactly one marker interface. The marker decides which plane delivers it; the generator rejects a type that carries both.

using Elarion.Abstractions.Messaging;

// Recorded in the unit of work, delivered after commit:
public sealed record InvoiceCreated(Guid InvoiceId, string ClientEmail) : IIntegrationEvent;

// Dispatched inline, inside the publisher's transaction:
public sealed record InvoiceLineAdded(Guid InvoiceId, decimal Amount) : IDomainEvent;

[ConsumeEvent] is the single, unified way to subscribe — you never pick a bus or a plane on the consumer. The event type's marker decides the plane, and every consumer is a fan-out subscriber, so a consumer reads identically whether it runs inline or after commit. The event bus is pub/sub-only: it broadcasts notifications and never returns a value to the publisher. See Consuming events for the consumer model. For request/reply (one typed response), call the target handler directly instead — see calling a handler from other code.

Plane A — domain events (inline)

IDomainEventBus dispatches within the caller's DI scope, and therefore within the caller's transaction. Consumers share the scoped DbContext, their writes commit atomically with the command, and a consumer failure fails the command. This is the right plane for in-aggregate invariants and synchronous side effects that must be part of the same unit of work.

// Inside a command handler, within the unit of work — RecalculateTotals runs now,
// same scope, same transaction, and commits together with the line:
await domainEvents.PublishAsync(new InvoiceLineAdded(c.InvoiceId, c.Amount), ct);

PublishAsync<TEvent> where TEvent : IDomainEvent fans out to every consumer in ascending [ConsumeEvent(Order = …)], awaiting each in turn; if one or more consumers throw, the rest still run and the exceptions are aggregated and rethrown. The bus is pub/sub-onlyPublishAsync returns no value, so when you need a single typed response from another handler, call that handler directly rather than going through the bus (see calling a handler from other code). Domain events are never broker-portable — they are an in-process, in-transaction mechanism by definition.

A domain-event handler runs nested in the command's pipeline. Because the domain plane dispatches inline in the publisher's scope, a handler-form consumer's decorator pipeline runs inside the command that published the event — same scope, same DbContext, same transaction. So a domain-event handler must not open its own transaction or resilience scope; a nested transaction, or retrying writes against the already-dirty DbContext, would corrupt the command's unit of work. The clean way is a transaction decorator with an AppliesTo predicate that matches commands and integration events but not domain events — see decorator pipelines.

Plane B — integration events (after commit)

IIntegrationEventBus records the event in the caller's unit of work and delivers it after the transaction commits, on a fresh scope, retried independently. A consumer failure never fails the command, and a rollback discards the event. This is the plane for independent, after-the-fact reactions — notifications, read-model projections, outbound webhooks, cross-module fan-out.

await integrationEvents.PublishAsync(new InvoiceCreated(invoice.Id, client.Email), ct);

PublishAsync<TEvent> where TEvent : IIntegrationEvent only records the event; the configured backend delivers it after commit. Because delivery happens later on its own scope, integration consumers run at-least-once and without an ordering guarantee. Handler-form consumers are deduped by the built-in inbox by default; write them order-independent, and see Consuming events for what the inbox covers and when to opt out. Unlike a domain-event handler, an integration-event handler runs on a fresh post-commit scope with its own DbContext and transaction, so the full pipeline is exactly right (the inbox decorator owns that transaction, claiming the message id atomically with the consumer's writes).

This is the only broker-portable plane: an alternative backend implements only IIntegrationEventBus. Two backends ship — a best-effort in-memory tier and the durable EF Core outbox. See Event backends for the choice and the wiring.

Choosing a plane

Use the domain plane when…Use the integration plane when…
the reaction must be atomic with the commandthe reaction should happen after the command commits
a failure should fail the commanda failure must not fail the command
consumers must share the command's DbContext/transactionconsumers run on their own scope and unit of work
in-aggregate invariants, synchronous side effectsnotifications, projections, webhooks, cross-module fan-out

When in doubt: if the side effect should be undone when the command rolls back, it is a domain event; if it should be retried until it succeeds after the command is durably committed, it is an integration event.

A third audience sits past both planes: the browser. When a committed fact should nudge connected UIs to refresh, project the integration event onto a client event — an at-most-once hint carrying ids/refs, pushed over Server-Sent Events, with the client converging by re-query. It is deliberately not a plane: no delivery guarantee crosses the trust boundary to a browser.

Module gating

When the host opts into [GenerateModuleBootstrapper], event consumers are associated with their owning module and registered through that module's generated ConfigureDefaultServices, gated by Modules:{Name}:Enabled. So disabling a module also stops its events from being consumed — no separate wiring, consistent with how handlers, services, and scheduled jobs gate. A consumer whose namespace falls under no module is reported (ELEVT003, warning) so it is never silently dropped.

Where to next

On this page