Skip to content
A. Shafie edited this page May 28, 2026 · 5 revisions

Outbox

The outbox stores events for publication after a state change commits. IEventPublisher.PublishAsync remains an in-process notification API. Outbox publication is explicit through IOutboxWriter or IIntegrationOutbox.

Contract

Use IIntegrationOutbox for events that cross a bounded-context or process boundary.

public sealed record OrderSubmittedIntegrationEvent : IIntegrationEvent
{
    public required Guid OrderId { get; init; }
}
var receipt = await integrationOutbox.AddAsync(
    new OrderSubmittedIntegrationEvent
    {
        OrderId = orderId
    },
    new OutboxOptions
    {
        MessageId = eventId,
        Topic = "orders",
        CorrelationId = correlationId
    },
    cancellationToken);

IOutboxWriter accepts any non-null event type. IIntegrationOutbox narrows the API to IIntegrationEvent so external publication is opt-in at the type level.

Use OutboxOptions.MessageId when the application already has a stable event id. v5 does not put that id on a marker interface; the event contract stays focused on domain data while the outbox envelope owns storage identity.

Registration

Register contracts and an outbox store.

builder.Services.AddLiteBus(liteBus =>
{
    liteBus.AddOutboxModule(outbox =>
    {
        outbox.Contracts.Register<OrderSubmittedIntegrationEvent>(
            name: "orders.events.order-submitted",
            version: 1);
    });
});

The processor dispatches through IOutboxDispatcher. To dispatch through LiteBus event handlers in the current process, opt in to the LiteBus event dispatcher and register the event module.

builder.Services.AddLiteBus(liteBus =>
{
    liteBus.AddEventModule(events =>
    {
        events.Register<OrderSubmittedEventHandler>();
    });

    liteBus.AddOutboxModule(outbox =>
    {
        outbox.Contracts.Register<OrderSubmittedIntegrationEvent>(
            "orders.events.order-submitted",
            version: 1);

        outbox.UseLiteBusEventDispatcher();
    });
});

External broker dispatchers can implement IOutboxDispatcher without changing the writer or processor.

PostgreSQL Store

The PostgreSQL package provides a raw Npgsql store.

var dataSource = NpgsqlDataSource.Create(connectionString);

builder.Services.AddLiteBus(liteBus =>
{
    liteBus.AddOutboxModule(outbox =>
    {
        outbox.Contracts.Register<OrderSubmittedIntegrationEvent>(
            "orders.events.order-submitted",
            version: 1);
    });

    liteBus.AddPostgreSqlOutboxStore(postgres =>
    {
        postgres.UseDataSource(dataSource);
        postgres.UseOptions(new PostgreSqlOutboxStoreOptions
        {
            SchemaName = "app",
            TableName = "litebus_outbox_messages"
        });
    });
});

await PostgreSqlOutboxSchema.CreateIfNotExistsAsync(
    dataSource,
    new PostgreSqlOutboxStoreOptions
    {
        SchemaName = "app",
        TableName = "litebus_outbox_messages"
    },
    cancellationToken);

The store uses jsonb payloads and FOR UPDATE SKIP LOCKED leases. It does not depend on EF Core.

Processing Flow

  1. Application code stores an event through IOutboxWriter or IIntegrationOutbox in the same transaction as the state change.
  2. The writer resolves a stable contract name and version.
  3. The writer serializes the event and stores an OutboxMessageEnvelope.
  4. IOutboxProcessor.ProcessPendingAsync leases due messages.
  5. The processor calls IOutboxDispatcher.DispatchAsync for each message.
  6. The processor marks the message published, failed for retry, or dead-lettered.

Store Roles

Interface Used by Responsibility
IOutboxMessageWriter IOutboxWriter Append a pending outbox envelope and return the stored row
IOutboxMessageLeaseStore IOutboxProcessor Atomically claim due messages for one publisher
IOutboxMessageStateStore IOutboxProcessor Record published, failed, or dead-lettered publication results

The role split keeps event writers from depending on publisher-only operations. A single database implementation can still implement all roles when one table owns the transaction boundary.

Generic Events

Closed generic events are supported when each closed event type is registered with its own stable contract.

public sealed record ExportCompletedEvent<TPayload> : IIntegrationEvent
{
    public required TPayload Payload { get; init; }
}

outbox.Contracts.Register<ExportCompletedEvent<CustomerExport>>(
    "exports.events.customer-completed",
    version: 1);

Open generic outbox contracts are not supported. Do not register ExportCompletedEvent<> as a persisted contract because the processor or dispatcher must deserialize each row into one concrete CLR type.

Storage Record

Field Purpose
message id Unique outbox message identity
contract name Stable persisted event name
contract version Payload version used by dispatchers
serialized payload Event data
topic Optional publication target
created timestamp Storage time
visible-after timestamp Retry visibility
status pending, publishing, published, failed, dead-lettered
attempt count Retry policy input
lease owner Publisher identity
lease expiration Crash recovery
last error Failure diagnostics
correlation id Trace grouping
causation id Parent operation tracking
tenant id Tenant isolation when needed

Retry and Dead Letter

OutboxProcessorOptions uses the same RetryOptions type as the inbox. Failed messages are retried after a delay, then moved to DeadLettered when attempts are exhausted.

outbox.UseProcessorOptions(new OutboxProcessorOptions
{
    BatchSize = 100,
    LeaseDuration = TimeSpan.FromMinutes(2),
    Retry = new RetryOptions
    {
        MaxAttempts = 12,
        InitialDelay = TimeSpan.FromSeconds(10),
        MaxDelay = TimeSpan.FromMinutes(10),
        Backoff = RetryBackoff.Exponential,
        UseJitter = true
    }
});

Boundary Rule

The outbox is not an event mediator replacement. Use PublishAsync when the current process should notify handlers now. Use IIntegrationOutbox.AddAsync when a fact must survive process failure and be published after commit.

Clone this wiki locally