Table of Contents

Trellis.EntityFrameworkCore.Outbox

Package: Trellis.EntityFrameworkCore.Outbox
Namespace: Trellis.EntityFrameworkCore
Purpose: Transactional outbox — atomically capture aggregate domain events to an EF Core table in the same transaction as the aggregate change, then durably relay them to Trellis domain-event handlers after the commit.

This package composes Trellis.EntityFrameworkCore (the capture interceptor and table mapping) with Trellis.Mediator (the IDomainEventPublisher dispatch seam the relay reuses). It opts out of AOT/trim, exactly like Trellis.EntityFrameworkCore.

See also: trellis-api-cookbook.md — recipes using this package.

Use this file when

  • You need domain events to survive a crash between the commit and the in-pipeline dispatch (the gap a plain UseDomainEvents() pipeline leaves open).
  • You are wiring AddTrellisOutbox, AddTrellisOutboxInterceptor, or the UseOutbox<TContext>() builder slot.
  • You are reasoning about the outbox's delivery guarantee, retry/parking behavior, or event-serialization constraints.

Patterns Index

Goal Use this See
Capture an aggregate's domain events into the outbox table atomically with the aggregate write optionsBuilder.AddTrellisOutboxInterceptor() (capture) + modelBuilder.AddTrellisOutbox() (table) Wiring: three required calls, OutboxModelBuilderExtensions
Run the background relay that re-dispatches captured events after commit services.AddTrellisOutbox<TContext>(configure?) or trellis.UseOutbox<TContext>(configure?) OutboxServiceCollectionExtensions, UseOutbox builder slot
Tune poll interval, batch size, or max attempts OutboxOptions (via the configure delegate) OutboxOptions
Inspect a captured-but-not-yet-relayed message Query TContext.Set<OutboxMessage>() (read-only; rows are produced by the interceptor) OutboxMessage
Understand what happens when a handler throws The message stays pending and retries only the failed handlers, then parks after MaxAttempts Delivery semantics
Decide how to shape an event so it round-trips Use attribute-driven value objects; Maybe<T> members are supported (present → value, absent → null) Serialization
Publish a stable external contract instead of raw domain events Translate a domain event into an IIntegrationEvent via IIntegrationEventCollector; the relay routes it to IIntegrationEventPublisher Integration events

Common traps

  • The interceptor and the model mapping are not optional extras. AddTrellisOutbox<TContext>() only registers the relay. Without optionsBuilder.AddTrellisOutboxInterceptor() nothing is captured, and without modelBuilder.AddTrellisOutbox() the TrellisOutboxMessages table is unmapped. All three calls are required — see Wiring: three required calls.
  • The relay host must have the producing assemblies loaded. The relay rehydrates each event from its assembly-qualified EventType via Type.GetType. If the worker process does not reference the assembly that declares the event, the message fails deserialization and parks after MaxAttempts.
  • Handlers must be idempotent. Delivery is at-least-once; a crash between dispatch and the relay's bookkeeping SaveChanges re-delivers the message on the next drain. Per-handler progress tracking removes the routine duplicate (a sibling handler failing) but not this crash window.
  • A failing handler now parks the message. Handler exceptions are no longer swallowed on the relay path, so a permanently failing handler exhausts MaxAttempts and dead-letters. Alert on OutboxRelay.MessageParked, and add a migration for the CompletedHandlers column — see Delivery semantics.
  • Caller-registered JsonSerializerOptions converters do not travel with the payload. The outbox serializer round-trips [JsonConverter]-attributed value objects and Maybe<T> members; converters registered only on a caller's own options are not consulted — shape those members with attribute-driven types or nullable transports. See Serialization.
  • OutboxMessage is read-only to application code. Its constructor is private and its mutators are internal; rows are produced exclusively by the capture interceptor and advanced by the relay.
  • Post-commit cancellation does not skip Trellis bookkeeping. AggregateETagInterceptor.SavedChangesAsync synchronizes ETags and OutboxCaptureInterceptor.SavedChangesAsync clears captured events without observing a newly canceled token: the write has already committed. Cancellation before or during saving still propagates normally. A custom interceptor that throws after commit can still interrupt later callbacks; do not introduce such a post-commit cancellation check.
  • Persist-on-failure (FailAfterCommit) events are dispatched by the outbox. The capture interceptor cannot see the command Result, so it captures events from every commit — including the persist-on-failure commits that TransactionalCommandBehavior performs. Without the outbox, DomainEventDispatchBehavior deliberately does not dispatch events for a failed result: the FailAfterCommit contract (documented under persist-on-failure in trellis-api-core.md) treats those events as discarded, not a durable retry buffer. With the outbox enabled, those events are captured and the relay delivers them — so the suppression no longer holds. If you depend on it, do not raise domain events on persist-on-failure paths under the outbox: return a success result for events you want delivered, and model post-failure side effects explicitly (a follow-up command, or a dedicated outbox row).

How the outbox works

The outbox replaces the in-pipeline domain-event dispatch with a durable, two-phase flow:

  1. Capture (inside the transaction). OutboxCaptureInterceptor (a SaveChangesInterceptor) scans the change tracker during SavingChanges for IAggregate entries with uncommitted events. It serializes each event and adds one OutboxMessage row per event to the same SaveChanges — so the rows commit atomically with the aggregate. It does not clear the aggregate's events yet.
  2. Clear (after the commit succeeds). In SavedChanges the interceptor calls each aggregate's AcceptChanges(). Because the events are cleared only after a successful commit, a failed save leaves the in-memory events intact for retry, and the interceptor detaches the rows it staged on SaveChangesFailed and SaveChangesCanceled so a retry on the same context does not double-capture.

Interceptor constraint. No other SaveChangesInterceptor may raise domain events on a tracked aggregate between step 1 and step 2. AcceptChanges() empties an aggregate's whole event list, so an event raised in that window is discarded without ever reaching the outbox. No Trellis interceptor raises domain events, so this applies only to caller-supplied interceptors — raise events from the domain model before SaveChanges is called instead. 3. Single dispatch path. Since the aggregate's events are cleared after the commit (in SavedChanges), a post-commit in-pipeline DomainEventDispatchBehavior observes an empty list and dispatches nothing. The relay becomes the one dispatcher. 4. Relay (after the commit). OutboxRelay<TContext> is a BackgroundService. Each poll it opens a bookkeeping scope, drains a batch of pending rows ordered by Sequence, and routes each by OutboxMessage.Kind. A Domain row is published through IReportingDomainEventPublisher in a dedicated per-message scope: handlers receive their own context, never the bookkeeping context, and their tracked changes do not ride its save. An Integration row is published through IIntegrationEventPublisher. The relay saves processed/failed state, completed-handler progress, and translated rows together on the bookkeeping context.

Integration events

A domain event is internal to the bounded context; an integration event (IIntegrationEvent) is the stable, versioned contract published to other services. The outbox keeps them separate: domain events are captured from aggregates, and integration events are translated from them so external consumers never couple to your internal model. See trellis-api-mediator.md for the IIntegrationEvent* types.

The flow:

  1. A translator — an ordinary IDomainEventHandler<TDomainEvent> — injects IIntegrationEventCollector and Add(...)s integration events while the relay re-dispatches the domain event.
  2. The relay opens the collector's BeginTranslation() lease only around publishing a Domain row and draining the per-message collector. Every drained event is staged as a new OutboxMessageKind.Integration row in the same bookkeeping save as handler progress, even if a sibling handler failed and the source domain row remains pending. Events added before a translator itself throws are also drained; there is no per-handler rollback of collector additions.
  3. A later drain publishes each Integration row through IIntegrationEventPublisher — its sole method PublishAsync(OutboundIntegrationMessage, CancellationToken), carrying that row's own OutboxMessage.Id (default in-process fan-out to IIntegrationEventHandler<T>; replace the registration with a message-broker adapter to deliver to other services).

Register the consumer side with services.AddIntegrationEventDispatch(...) / AddIntegrationEventHandler<TEvent, THandler>(), or the TrellisServiceBuilder.UseIntegrationEvents(...) slot. The collector is optional: outboxes that capture only domain events never register it and are unaffected.

A retry skips translators already recorded in CompletedHandlers; an unrelated sibling failure does not rerun them. A translator that failed after adding an event, or whose successful progress was not saved (crash/bookkeeping failure), can run again and produce a distinct integration row with a new ID. Deduplicate these logical duplicates on business identity, in addition to inbox message-ID deduplication.

Two different duplicates — only one is the message id's job

These are easy to conflate, and they need different defences:

Duplicate Cause OutboxMessage.Id Defence
Redelivery of one Integration row The relay crashed between publishing and its bookkeeping SaveChanges, or the batch outlived its lease Same on every attempt The consumer's inbox, keyed (ConsumerId, MessageId) — provided the transport carried OutboundIntegrationMessage.MessageId verbatim
A re-run translator staging a fresh row The translator itself failed after adding events, or its successful progress was not durably saved; completed translators are skipped on ordinary sibling retries Different each time Business identity — the inbox cannot help, because these genuinely are two messages

The relay hands OutboundIntegrationMessage.MessageId to the publisher precisely so a broker adapter can stamp it on the wire (for example ServiceBusMessage.MessageId) and collapse the first row of the table. An adapter that minted its own id per publish attempt would turn every redelivery into a distinct message and silently defeat the consumer's inbox. It does not collapse the second row, which is why the "dedupe on business identity" guidance above still stands.

Wiring: three required calls

// 1. Map the outbox table.
protected override void OnModelCreating(ModelBuilder modelBuilder) =>
    modelBuilder.AddTrellisOutbox();

// 2. Register the capture interceptor on the context options.
options.UseNpgsql(connectionString)
       .AddTrellisInterceptors()
       .AddTrellisOutboxInterceptor();

// 3. Register the relay + your handlers + IDomainEventPublisher.
services.AddTrellis(trellis => trellis
    .UseDomainEvents(typeof(Program).Assembly)
    .UseEntityFrameworkUnitOfWork<AppDbContext>()
    .UseOutbox<AppDbContext>());

The relay needs IDomainEventPublisher and the IDomainEventHandler<TEvent> registrations that UseDomainEvents() / AddDomainEventDispatch() provide; register them in the same container.

OutboxServiceCollectionExtensions

Static class. Registers the relay for a DbContext.

public static IServiceCollection AddTrellisOutbox<TContext>(
    this IServiceCollection services,
    Action<OutboxOptions>? configure = null)
    where TContext : DbContext;
  • Registers OutboxRelay<TContext> as an IHostedService, the (configured) OutboxOptions as a singleton, and TimeProvider.System if no TimeProvider is already registered.
  • TimeProvider is added with TryAdd. A single shared OutboxOptions instance backs all relays in the container. Configure the outbox for one DbContext per composition (the UseOutbox slot enforces this).
  • Repeated calls accumulate configuration. AddHostedService dedupes by service + implementation type, so calling this twice for the same TContext yields exactly one relay. Each call applies its configure callback on top of the already-registered OutboxOptions instance and re-runs Validate(), so a later callback wins per setting rather than being silently discarded. Configuration is applied to a clone and committed only after Validate() succeeds, so a rejected callback leaves the container's options untouched. Prefer the UseOutbox<TContext>() builder slot, which fails fast on a duplicate.
  • Do not register OutboxOptions yourself via a factory or implementation type. The helper layers onto the last OutboxOptions descriptor, which is the one the container resolves. If that descriptor is a factory or type registration it cannot be cloned, so passing a configure callback throws InvalidOperationException rather than silently configuring an instance the relay would never receive. Calling AddTrellisOutbox<TContext>() with no callback leaves your registration intact.

This is the service-collection half only. Pair with AddTrellisOutbox(ModelBuilder) and AddTrellisOutboxInterceptor(DbContextOptionsBuilder).

OutboxModelBuilderExtensions

Static class. EF Core model + interceptor hooks.

public static ModelBuilder AddTrellisOutbox(this ModelBuilder modelBuilder);

public static DbContextOptionsBuilder AddTrellisOutboxInterceptor(
    this DbContextOptionsBuilder optionsBuilder);

public static DbContextOptionsBuilder<TContext> AddTrellisOutboxInterceptor<TContext>(
    this DbContextOptionsBuilder<TContext> optionsBuilder)
    where TContext : DbContext;
  • AddTrellisOutbox(ModelBuilder) — applies OutboxMessageConfiguration, mapping the TrellisOutboxMessages table. Call from OnModelCreating.
  • AddTrellisOutboxInterceptor(...) — adds the shared, stateless OutboxCaptureInterceptor. The generic overload preserves the DbContextOptionsBuilder<TContext> fluent type so it chains after AddTrellisInterceptors<TContext>(). The interceptor is a singleton; registering it on multiple contexts is safe.

UseOutbox builder slot

TrellisServiceBuilder.UseOutbox<TContext>(Action<OutboxOptions>? configure = null) (in Trellis.ServiceDefaults) is the opinionated entry point. It wraps AddTrellisOutbox<TContext>() and:

  • Fails fast with InvalidOperationException if called more than once — one outbox relay per composition.
  • Is order-independent versus UseEntityFrameworkUnitOfWork<TContext>(): the relay is a hosted service, not a Mediator behavior, so the canonical pipeline order is identical whether UseOutbox is called before or after the unit-of-work slot.
  • Carries [RequiresUnreferencedCode] / [RequiresDynamicCode] because the outbox builds on the non-AOT Trellis.EntityFrameworkCore.

The slot owns only the service registration; the capture interceptor and table mapping are still wired on the DbContext (steps 1–2 above), mirroring how UseEntityFrameworkUnitOfWork pairs with AddTrellisInterceptors.

OutboxMessage

A persisted event awaiting relay — one row per captured domain event or translated integration event. Read-only to application code (private constructor, internal mutators).

Member Type Notes
Sequence long Database-generated, monotonic. Primary key and relay order (ascending).
Id Guid UUIDv7. Stable message identity for consumer-side idempotency / de-duplication.
Kind OutboxMessageKind Domain (captured from an aggregate) or Integration (translated). Routes the relay to the correct publisher. Stored as a string column.
OccurredAt DateTimeOffset Copied from the event's OccurredAt.
EventType string Assembly-qualified name of the concrete event type, used to rehydrate the payload.
Payload string The JSON-serialized event.
ProcessedAt DateTimeOffset? When the message was relayed; null while pending.
Attempts int Relay attempts so far.
LastError string? Most recent relay error, if any.
LockedUntil DateTime? UTC instant until which a relay drain holds an exclusive claim (lease) on the row; null when unclaimed.
LockedBy Guid? Claim token of the relay drain that currently holds the row; null when unclaimed.
CompletedHandlers IReadOnlyList<string> For domain rows, the identity of every handler that has already completed for this event (see DomainEventDispatchReport.HandlerIdentity). A retry skips them, so a successful handler is never re-run because a sibling failed. Deliberately not cleared by ReplayAsync — replaying means "finish the work that did not happen". Empty for integration rows.

The OutboxMessageConfiguration maps the table TrellisOutboxMessages, the Sequence primary key (ValueGeneratedOnAdd), a unique index on Id, the Kind discriminator (string, max length 32), a covering index on { ProcessedAt, LockedUntil, Sequence } for the relay's claimable-rows scan, and an index on LockedBy for loading a drain's just-claimed batch.

Schema migration. Row-claiming added the nullable LockedUntil and LockedBy columns. An existing outbox table needs a migration to add them; both are nullable, so in-flight rows default to unclaimed and are picked up normally.

OutboxMessage is an infrastructure record, not a domain aggregate. The rows are transient and may be pruned once ProcessedAt is set — deleting processed rows loses no source-of-truth state. This is an outbox, not an event store.

OutboxOptions

Tuning for the relay.

Property Type Default Notes
PollInterval TimeSpan 5 seconds How long the relay waits before polling again when the outbox is empty.
BatchSize int 100 Maximum messages drained per poll.
MaxAttempts int 10 After this many failed attempts a message is dead-lettered (parked): left unprocessed and skipped by the scan so it does not block later messages. With the exponential backoff below this is roughly a few hours of retrying at the defaults. Replay dead-lettered messages with IOutboxMaintenance once the cause is fixed. Because the dead-letter set is defined relative to this value, raising it makes previously dead-lettered rows eligible again. Must be greater than zero.
LeaseDuration TimeSpan 5 minutes How long a relay drain holds an exclusive claim (lease) on the rows it drains before another instance may reclaim them. Set comfortably above the time to publish one batch so a slow batch is not reclaimed mid-flight; a crashed instance's rows become reclaimable once the lease expires. Must be greater than zero.
RetryBackoff TimeSpan 30 seconds Base delay before retrying a failed message. The wait after the nth failed attempt is RetryBackoff × 2^(n-1), capped at MaxRetryBackoff, so a transient failure (a brief outage) is retried with growing spacing instead of in a tight claim/fail/reclaim loop that would hammer the database. Must be greater than zero.
MaxRetryBackoff TimeSpan 1 hour Ceiling on the exponential retry backoff, so a persistently failing message keeps retrying at a steady, bounded cadence rather than spacing out into many hours. Must be greater than or equal to RetryBackoff.
RetryBackoffJitter double 0.5 Fraction in [0, 1] of deterministic, per-message jitter that only subtracts from the computed backoff (so the wait stays at or below the cap) to de-correlate messages that failed together — they don't all retry the instant a failed dependency recovers, which would flood it. 0 disables jitter; the jitter is keyed on the message id, so it is reproducible (no run-to-run randomness). Must be between 0 and 1 inclusive.

Delivery semantics

The guarantee is at-least-once delivery, and delivery now means every handler completed.

  • A domain message is marked processed only once every registered IDomainEventHandler<TEvent> has completed. A handler that throws leaves the message pending, and the retry re-invokes only the handlers that failed — OutboxMessage.CompletedHandlers records the ones that already succeeded. An unrelated sibling's failure therefore never re-runs a successful handler's side effect.
  • All drained integration events are staged on a failed attempt, including additions from successful handlers and additions made before a translator itself threw. There is no per-handler rollback of collector additions. Successful handlers are skipped on retry; failed translators can run again and produce logical duplicates under new row IDs.
  • Event-type resolution, deserialization, and publisher failures become per-message failed attempts when their bookkeeping save succeeds. A bookkeeping SaveChanges failure is different: it escapes to the drain loop, which logs DrainFailed and waits PollInterval. That attempt/progress is not durable, the prior lease remains until expiry, and repeated bookkeeping failures need not ever reach MaxAttempts. Monitor drain failures as well as parked messages.
  • Failures retry on later polls with an exponential backoff (RetryBackoff doubling up to MaxRetryBackoff, minus a deterministic per-message jitter), up to MaxAttempts, after which the message is dead-lettered (parked): its Attempts reaches the cap and the scan skips it, so later messages are not blocked. LastError records the most recent failure. The backoff means a brief outage is ridden out by a later retry rather than burning every attempt in a tight loop. With the defaults, a permanently failing handler is retried 10 times over roughly 3 hours before parking.
  • Handlers must still be idempotent. The progress record is only durable once the relay's bookkeeping SaveChanges lands, so a crash between dispatch and that save re-delivers the event to every handler. The per-handler tracking removes the routine duplicate (a failing sibling), not the crash window.
  • Integration messages have a single publish step rather than a fan-out, so CompletedHandlers stays empty for them; a publisher failure retries the whole row.
  • A dead-lettered message stays in the table for inspection and replay — see Dead-letter and replay below. Monitor for rows where ProcessedAt IS NULL AND Attempts >= MaxAttempts.
  • Logs (production support). The relay emits structured events. OutboxRelay.MessageParked (Error, EventId 5) is the alertable signal that a message exhausted MaxAttempts and is dead-lettered — it carries MessageId, EventType, Attempts, and the exception. Transient per-message failures log OutboxRelay.RelayAttemptFailed (Warning, EventId 4, with the attempt number); a drain that outlived its lease and was reclaimed by another instance logs OutboxRelay.LeaseLost (Warning, EventId 6); a whole drain cycle failing logs OutboxRelay.DrainFailed (Error, EventId 3); startup logs OutboxRelay.Started (Information), and OutboxRelay.DrainCompleted (Debug) reports the per-cycle count. Alert on MessageParked.
  • Safe to run N-up (row-claiming + concurrency guard). Each drain atomically claims a batch with a lease — a single UPDATE sets LockedBy (a per-drain token) and LockedUntil (now + LeaseDuration) on eligible rows. Concurrent relays running that UPDATE are serialized by row locks and re-evaluate the lease guard, so every row is claimed by exactly one instance. LockedBy is additionally an optimistic concurrency token: if a slow batch outlives its lease and another instance reclaims a row, the first instance's bookkeeping UPDATE matches no row and it abandons its write for that row (dropping any integration rows it produced and logging OutboxRelay.LeaseLost) rather than clobber the new owner. This makes a horizontally-scaled deployment safe without leader election or an external lock. Delivery is still at-least-once and handlers must stay idempotent: a crash between publish and the relay's SaveChanges, or a batch that outlives its lease, can re-deliver. Set LeaseDuration comfortably above the worst-case batch publish time, and keep node clocks reasonably in sync — the lease is compared against each relay's wall clock.

Migration note. This behavior replaces the previous "handed to the publisher = delivered" semantics, under which a throwing handler silently dropped its work. Two consequences for an existing deployment: messages whose handlers fail now retry and can park (alert on OutboxRelay.MessageParked), and the TrellisOutboxMessages table gains a CompletedHandlers column — add a migration before deploying. The column is non-nullable with an empty default, so existing rows backfill cleanly.

The relay obtains this behavior from IReportingDomainEventPublisher (see trellis-api-mediator.md), the non-swallowing counterpart to IDomainEventPublisher. In-pipeline dispatch still swallows, because it runs post-commit and has no retry mechanism. The relay's StartAsync resolves IReportingDomainEventPublisher in a fresh scope before starting the drain loop and fails host startup if it is missing or cannot be constructed. When an IIntegrationEventCollector or IIntegrationEventPublisher registration enables integration features, startup also resolves the integration publisher; domain-only hosts need no integration publisher. This validates publisher construction, not broker connectivity or every handler dependency. Both response and tracked domain-dispatch registrations supply the reporting capability.

IServiceProviderIsService is optional. When available, the relay probes registrations without constructing the collector. With a provider or scoped-provider wrapper that omits this capability, startup resolves the optional collector to determine whether an integration publisher is required; when there is no collector, it still resolves any optional integration publisher to validate its construction. Optional resolution follows the IServiceProvider contract: an absent service returns null, while construction exceptions propagate. Each publisher is requested directly at most once during validation, and the scope is asynchronously disposed on success or failure. A provider without registration probing therefore need not register a dummy probe or integration publisher for a domain-only outbox.

Dead-letter and replay

A message that exhausts MaxAttempts is dead-lettered (parked): it stays in TrellisOutboxMessages with ProcessedAt == null and Attempts >= MaxAttempts, is skipped by the relay scan so it does not block later messages, and logs OutboxRelay.MessageParked (Error). It is kept in the table for inspection — a soft dead-letter, not a separate queue.

Resolve IOutboxMaintenance (registered by AddTrellisOutbox<TContext>, scoped) to inspect and replay dead-lettered messages once you have fixed the cause (deployed the missing handler assembly, corrected a bad payload):

Member Behavior
GetDeadLetteredAsync(limit = 100, ct) Returns up to limit dead-lettered rows, oldest first, for inspection.
ReplayAsync(id, ct) Replays one message by Id: resets Attempts to 0, clears LastError and the lease so the relay drains it again. Returns the rows affected (0 if it does not exist or is not dead-lettered, 1 otherwise).
ReplayAllAsync(ct) Replays every currently dead-lettered message and returns the count. The set is a snapshot taken when the statement runs — messages that dead-letter afterward are not included.

Replaying a dead-lettered row is race-free against a running relay because the relay never claims a row whose Attempts >= MaxAttempts. Note that the dead-letter set is defined relative to MaxAttempts, so lowering or raising that option also changes which rows are considered dead-lettered. IOutboxMaintenance targets the single outbox context registered in the container (like OutboxOptions); run one outbox per composition.

Serialization

Events are serialized and rehydrated with a Trellis-owned System.Text.Json options instance that adds a Maybe<T> converter.

  • Supported: value objects that carry a [JsonConverter] attribute (the Trellis scalar and composite primitives) round-trip, because the converter travels with the type. Maybe<T> members also round-trip — a present value serializes as the underlying value and an absent one as JSON null.
  • Not supported: converters registered only through a caller-supplied JsonSerializerOptions factory (they are not consulted by the outbox serializer). Shape those members with attribute-driven value objects or a nullable transport (e.g. string?).

A fully configurable serializer is a planned follow-up; until then, keep event payloads to attribute-driven, Maybe<T>, and primitive-or-nullable members.