← Back to the blogTransactional Outbox and Inbox in ASP.NET Core: Reliable Delivery, Idempotent Effects
dotnetaspnet-corepostgresqldistributed-systemsoutboxinboxmessagingarchitecture

Transactional Outbox and Inbox in ASP.NET Core: Reliable Delivery, Idempotent Effects

Implement Transactional Outbox and Inbox with .NET 10 and PostgreSQL 18: atomic writes, at-least-once delivery, retries, and consumer effects deduplicated in the same transaction.

Transactional Outbox and Inbox in ASP.NET Core: Reliable Delivery, Idempotent Effects

Store each business change and its event in one transaction; a relay delivers them at least once, and each consumer uses Inbox to prevent repeating local effects.

Saving a payment and publishing PaymentCreated look like two consecutive lines. In production they are two independent systems, two acknowledgements, and one failure window that can leave data and events telling different stories.

A client can retry a POST after a timeout even when the server committed the transaction. Outbox does not deduplicate HTTP requests: a persisted Idempotency-Key must return the original result and prevent a second payment. This guide focuses on the hop between the database and the broker.

After storing the payment, we might need to:

  • publish an event to RabbitMQ, Kafka, Azure Service Bus, or SQS;
  • update accounting;
  • start an invoice;
  • notify another service;
  • feed analytics or auditing.

The naive implementation does this:

await dbContext.SaveChangesAsync(cancellationToken);
await eventPublisher.PublishAsync(paymentCreated, cancellationToken);

There is a failure gap between the two lines:

  1. the PostgreSQL transaction commits the payment;
  2. the process dies before publishing;
  3. the payment exists, but no consumer receives the event.

Reversing the order does not work either. If we publish first and the database transaction later rolls back, consumers react to a payment that never existed.

This is the dual write problem: one logical operation tries to commit state to two systems without one shared atomic transaction.

The guarantee we are actually building

We will not promise end-to-end exactly-once delivery. A database, a broker, and several consumers do not share one atomic acknowledgement.

The practical guarantee is built in layers:

  1. Transactional Outbox: the business change and the outgoing message are stored in the same local transaction.
  2. At-least-once relay: a worker publishes pending messages and may repeat one after a failure.
  3. Inbox or idempotent consumer: each consumer records the stable message ID in the same transaction as its business effects.

The result does not remove transport duplicates. It makes repeating them avoid repeating the business effect.

sequenceDiagram
    participant API as Payments API
    participant DB as PostgreSQL
    participant Relay as Outbox worker
    participant Broker as Broker
    participant Consumer as Accounting

    API->>DB: BEGIN
    API->>DB: INSERT payment
    API->>DB: INSERT outbox message
    API->>DB: COMMIT
    Relay->>DB: Claim pending message
    Relay->>Broker: Publish PaymentCreated
    Broker-->>Consumer: Deliver event
    Consumer->>DB: BEGIN
    Consumer->>DB: INSERT inbox marker
    Consumer->>DB: Apply accounting effect
    Consumer->>DB: COMMIT
    Consumer-->>Broker: ACK

The example model

We will use:

  • ASP.NET Core on .NET 10;
  • Entity Framework Core for the business transaction;
  • PostgreSQL 18 for payments, Outbox, and Inbox;
  • BackgroundService for the relay;
  • NpgsqlDataSource to claim batches with explicit SQL;
  • an IEventPublisher abstraction so the pattern is not tied to one broker.

The flow starts with POST /payments. The API creates a payment and a PaymentCreated event in one SaveChangesAsync.

An independent worker:

  1. claims a small batch of available messages;
  2. commits that claim quickly;
  3. publishes outside the PostgreSQL transaction;
  4. marks each message as published;
  5. schedules a retry if the broker fails.

The accounting consumer stores (consumer, message_id) before creating the ledger entry. Both changes commit or roll back together.

Design the event envelope

The event identifier must be generated once and survive every publication retry. A new identifier per attempt defeats deduplication.

public sealed record PaymentCreated(
    Guid EventId,
    Guid PaymentId,
    decimal Amount,
    string Currency,
    long AggregateVersion,
    DateTimeOffset OccurredAtUtc);

public sealed record IntegrationEventEnvelope(
    Guid Id,
    string Type,
    int SchemaVersion,
    Guid AggregateId,
    long AggregateVersion,
    DateTimeOffset OccurredAtUtc,
    string? TraceParent,
    string Payload);

There are three different identities:

  • EventId deduplicates the logical message;
  • AggregateId identifies the payment;
  • TraceParent correlates telemetry but does not deduplicate.

One trace can contain several events. Using the trace ID as an Inbox key would let the first event incorrectly suppress the rest.

SchemaVersion lets the contract evolve without inferring its version from a .NET class name.

PostgreSQL Outbox and Inbox tables

An Outbox needs more than a published boolean. Production also needs to represent abandoned claims, retries, and observable errors.

CREATE TABLE outbox_messages (
    id uuid PRIMARY KEY,
    aggregate_type text NOT NULL,
    aggregate_id uuid NOT NULL,
    aggregate_version bigint NOT NULL,
    event_type text NOT NULL,
    schema_version integer NOT NULL,
    payload jsonb NOT NULL,
    trace_parent text,
    occurred_at timestamptz NOT NULL,
    published_at timestamptz,
    attempts integer NOT NULL DEFAULT 0,
    next_attempt_at timestamptz NOT NULL DEFAULT now(),
    lock_id uuid,
    locked_until timestamptz,
    last_error text,
    dead_lettered_at timestamptz
);

CREATE INDEX ix_outbox_ready
    ON outbox_messages (next_attempt_at, occurred_at, id)
    WHERE published_at IS NULL
      AND dead_lettered_at IS NULL;

CREATE TABLE inbox_messages (
    consumer_name text NOT NULL,
    message_id uuid NOT NULL,
    processed_at timestamptz NOT NULL,
    PRIMARY KEY (consumer_name, message_id)
);

The composite Inbox key matters. Two different consumers must each process their own copy of the same event:

(accounting, event-123)     -> allowed once
(notifications, event-123)  -> allowed once

A UNIQUE constraint or primary key makes PostgreSQL the arbiter of concurrent duplicates. A SELECT followed by an unconstrained INSERT still leaves a race.

Store business data and the event in one transaction

If payments and the Outbox use the same DbContext, one SaveChangesAsync already groups all changes in a transaction when the relational provider supports transactions.

The handler below shows only the first execution. In production, a unique constraint scoped to the client and Idempotency-Key must bind the key, request hash, IDs, and response to the same transaction that stores the payment and Outbox row. Repeating the same key and payload returns the stored response; reusing the key with a different payload is rejected. The generated IDs do not replace HTTP request idempotency.

public static async Task<IResult> CreatePayment(
    CreatePaymentRequest request,
    PaymentsDbContext dbContext,
    TimeProvider timeProvider,
    CancellationToken cancellationToken)
{
    Guid paymentId = Guid.NewGuid();
    Guid eventId = Guid.NewGuid();
    DateTimeOffset occurredAtUtc = timeProvider.GetUtcNow();

    Payment payment = Payment.Create(
        paymentId,
        request.Amount,
        request.Currency,
        occurredAtUtc);

    PaymentCreated paymentCreated = new(
        eventId,
        payment.Id,
        payment.Amount,
        payment.Currency,
        payment.Version,
        occurredAtUtc);

    string payload = JsonSerializer.Serialize(paymentCreated);
    string? traceParent = Activity.Current?.Id;

    OutboxMessage outboxMessage = OutboxMessage.Create(
        paymentCreated.EventId,
        "payment",
        paymentCreated.PaymentId,
        paymentCreated.AggregateVersion,
        nameof(PaymentCreated),
        1,
        payload,
        traceParent,
        occurredAtUtc);

    dbContext.Payments.Add(payment);
    dbContext.OutboxMessages.Add(outboxMessage);

    await dbContext.SaveChangesAsync(cancellationToken);

    return Results.Created($"/payments/{payment.Id}", new
    {
        payment.Id,
        payment.Status
    });
}

If the payment insert fails, no event remains. If the Outbox insert fails, no payment remains. The broker does not participate yet, which is exactly how the dual write is removed.

A SaveChanges interceptor can extract domain events and create Outbox rows. That reduces repetition when many aggregates emit events, but it also adds implicit behavior. The explicit version is easier to audit while explaining the guarantee.

Do not hold a database transaction open while publishing

This approach looks safe, but it is a trap:

BEGIN
SELECT ... FOR UPDATE
publish to the broker
UPDATE outbox
COMMIT

The network call can take seconds. During that time, the process holds locks, occupies a connection, and expands the impact of every timeout.

Use a lease-based claim instead:

  1. one short statement assigns lock_id and locked_until;
  2. the transaction ends;
  3. the worker publishes without database locks;
  4. only the lease owner can mark the message.

If the process dies, the lease expires and another worker recovers the row.

Claim a batch with SKIP LOCKED

PostgreSQL lets several workers avoid waiting for the same rows:

WITH candidates AS (
    SELECT id
    FROM outbox_messages
    WHERE published_at IS NULL
      AND dead_lettered_at IS NULL
      AND next_attempt_at <= now()
      AND (locked_until IS NULL OR locked_until < now())
    ORDER BY occurred_at, id
    FOR UPDATE SKIP LOCKED
    LIMIT @batch_size
)
UPDATE outbox_messages AS message
SET lock_id = @worker_id,
    locked_until = now() + interval '30 seconds',
    attempts = attempts + 1
FROM candidates
WHERE message.id = candidates.id
RETURNING
    message.id,
    message.aggregate_id,
    message.aggregate_version,
    message.event_type,
    message.schema_version,
    message.payload,
    message.trace_parent,
    message.occurred_at,
    message.attempts;

The statement atomically claims the batch. Although candidates are ordered, PostgreSQL does not guarantee that UPDATE ... RETURNING returns rows in that order. If local order matters, sort the result again by occurred_at, id in the worker; this does not impose order across workers.

attempts counts claims, not only broker failures: a crash or expired lease also consumes an attempt. The 30-second lease is illustrative, not universal. Size the batch so the last message is processed before expiry or renew leases; if a lease expires during publication, another worker can reclaim the row and deliver the same event again. Inbox and the stable message ID are still needed.

The partial index excludes published and dead-lettered messages.

Run the relay with one scope per iteration

A BackgroundService is registered as a singleton. It should not keep one scoped DbContext for the lifetime of the process.

public sealed class OutboxWorker(
    IServiceScopeFactory scopeFactory) : BackgroundService
{
    protected override async Task ExecuteAsync(
        CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            await using AsyncServiceScope scope =
                scopeFactory.CreateAsyncScope();

            OutboxRelay relay =
                scope.ServiceProvider.GetRequiredService<OutboxRelay>();

            int processed = await relay.RunBatchAsync(stoppingToken);

            if (processed == 0)
            {
                await Task.Delay(
                    TimeSpan.FromMilliseconds(500),
                    stoppingToken);
            }
        }
    }
}

Each iteration gets new scoped services and releases connections, tracking, and transactions when it ends.

Publish and mark without hiding the duplicate window

The relay processes every claimed message:

public async Task PublishAsync(
    ClaimedOutboxMessage message,
    Guid workerId,
    CancellationToken cancellationToken)
{
    try
    {
        await eventPublisher.PublishAsync(
            message.Id,
            message.EventType,
            message.Payload,
            message.TraceParent,
            cancellationToken);

        await repository.MarkPublishedAsync(
            message.Id,
            workerId,
            cancellationToken);
    }
    catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
    {
        throw;
    }
    catch (Exception exception)
    {
        await repository.ScheduleRetryAsync(
            message.Id,
            workerId,
            exception.Message,
            cancellationToken);
    }
}

A publish failure is scheduled and does not stop the rest of the batch. Shutdown cancellation propagates; if retry persistence fails, that error also propagates rather than looking like success. The repository must sanitize exception.Message before persisting it; never store credentials or sensitive payloads.

A failure window still exists:

  1. the broker accepts the event;
  2. the worker dies before MarkPublishedAsync;
  3. the lease expires;
  4. another worker publishes again.

We should not hide that window behind a function name. It is why the event keeps the same message.Id and why the consumer implements Inbox.

Backoff, poison messages, and recovery

Immediate retries can hammer the broker during an outage. We can calculate a capped exponential delay:

public static TimeSpan RetryDelay(int attempts)
{
    int exponent = Math.Min(attempts, 9);
    double seconds = Math.Pow(2, exponent);
    return TimeSpan.FromSeconds(Math.Min(seconds, 300));
}

Production systems should add jitter so all instances do not retry at the same time. Calculate it when scheduling the next attempt and persist the result in next_attempt_at.

If a publish attempt fails when attempts reaches 12, ScheduleRetryAsync marks this row with dead_lettered_at, stores a sanitized error, and releases the lease. Before the limit, it calculates backoff with jitter and persists next_attempt_at, also releasing the lease. The claim query excludes rows with non-null dead_lettered_at. To requeue a message, a maintenance operation clears that marker and schedules its next attempt.

Operations need a runbook to:

  • inspect the payload without exposing sensitive data;
  • fix the cause;
  • reschedule the message;
  • preserve the same id.

Deduplicate in the consumer with Inbox

The consumer receives PaymentCreated. It first attempts to insert its marker, then applies the business effect in the same transaction.

public async Task HandleAsync(
    PaymentCreated message,
    CancellationToken cancellationToken)
{
    string consumerName = "accounting";
    DateTimeOffset processedAtUtc = timeProvider.GetUtcNow();

    await using IDbContextTransaction transaction =
        await dbContext.Database.BeginTransactionAsync(cancellationToken);

    int claimed = await dbContext.Database.ExecuteSqlInterpolatedAsync(
        $"""
        INSERT INTO inbox_messages (
            consumer_name,
            message_id,
            processed_at
        )
        VALUES (
            {consumerName},
            {message.EventId},
            {processedAtUtc}
        )
        ON CONFLICT DO NOTHING
        """,
        cancellationToken);

    if (claimed == 0)
    {
        await transaction.CommitAsync(cancellationToken);
        return;
    }

    LedgerEntry entry = LedgerEntry.FromPayment(
        message.PaymentId,
        message.Amount,
        message.Currency);

    dbContext.LedgerEntries.Add(entry);
    await dbContext.SaveChangesAsync(cancellationToken);
    await transaction.CommitAsync(cancellationToken);
}

The broker ACK happens after the commit.

If the consumer dies after the commit but before the ACK, the broker delivers again. The second execution finds the Inbox key and does not create another ledger entry.

If it fails before the commit, PostgreSQL rolls back both the marker and the ledger entry, and redelivery can safely try again.

Concurrent duplicates

Two instances can receive the same message almost simultaneously. Both could run a SELECT that says “not found.”

Correctness therefore does not depend on:

if (!await AlreadyProcessed(id))
{
    await Process(id);
}

It depends on the unique constraint and one atomic insert. Only one transaction can claim (accounting, eventId).

The application should recognize the collision as an expected duplicate, not report it as an operational error that triggers infinite retries.

External side effects that do not fit inside Inbox

A third-party call cannot commit inside the PostgreSQL transaction.

Examples include:

  • charging through a payment provider;
  • sending an email;
  • creating a shipment;
  • writing to another service over HTTP.

The options are:

  1. use a stable idempotency key supported by the provider;
  2. create another local Outbox for that effect;
  3. model processing/completed states and reconcile ambiguous outcomes;
  4. use a saga when several compensations form one long-running process.

Writing an Inbox marker before the provider call does not prove the call finished. Writing it afterwards reopens the duplicate window. The guarantee must continue into the system that performs the effect.

Ordering: what ORDER BY does not guarantee

Sorting the claimed batch by occurred_at, id makes local processing order reproducible; several workers using SKIP LOCKED can still publish events for the same aggregate out of order.

If PaymentAuthorized v2 must not overtake PaymentCreated v1, an additional strategy is required:

  • persist aggregate_version;
  • partition the broker by aggregate_id;
  • allow only one in-flight event per aggregate;
  • use per-aggregate locks;
  • detect version gaps in the consumer.

Each option reduces parallelism. There is no universal choice that preserves global order and scales without coordination.

Retriable transactions and ambiguous commits

EF Core warns that a retrying execution strategy cannot automatically replay an arbitrary manually controlled transaction. If retries are enabled and BeginTransactionAsync is used, the whole unit must run inside the delegate from CreateExecutionStrategy.

There is another subtle case: the connection can drop while committing, leaving the application unable to know whether PostgreSQL committed.

Generate the IDs once, before the execution-strategy delegate, and reuse them for every replay of that same transaction. They do not deduplicate HTTP retries on their own: persist the Idempotency-Key, request hash, and stored response with the payment and Outbox row. If the commit outcome is ambiguous, a replay checks that record before creating another operation.

Propagate traces without using the trace as identity

The HTTP request and the worker happen at different times and possibly in different processes. To preserve causality, we can store W3C context:

string? traceParent = Activity.Current?.Id;

The relay injects it as message metadata and the consumer extracts it to create a related span.

OpenTelemetry separates context from propagation. traceparent correlates telemetry; EventId deduplicates.

Do not put secrets or personal data in baggage. It can propagate and end up in logs or external observability systems.

Cleanup and retention

Outbox and Inbox grow continuously.

A reasonable policy:

  • retain published Outbox rows for the required audit period;
  • delete in small batches to avoid I/O spikes;
  • retain Inbox longer than the broker's maximum redelivery window;
  • account for manual replay from dead-letter queues;
  • archive events subject to regulatory requirements;
  • minimize PII inside payload.

Deleting Inbox too early makes an old message look new again.

Metrics that expose trouble before users do

The total number of pending rows is not enough. Useful metrics include:

  • outbox_pending_total;
  • age of the oldest pending message;
  • publications per second;
  • attempts per message;
  • dead-letter messages;
  • recovered expired leases;
  • duplicates ignored by Inbox;
  • latency from occurred_at to publication;
  • latency from publication to processing.

The age of the oldest pending message is often the strongest signal. A queue can temporarily grow and remain healthy; one message stuck for hours indicates a real blockage.

Failure tests the implementation must pass

A happy-path test does not validate this pattern. We need to kill processes at uncomfortable points:

  1. Command rollback: payment creation fails and no Outbox row remains.
  2. Crash after commit: payment and Outbox survive restart.
  3. Two relays: no message is claimed simultaneously while its lease is active.
  4. Crash after publish: the event can repeat with the same ID.
  5. Concurrent duplicate: Inbox permits only one effect.
  6. Crash before ACK: redelivery does not repeat the ledger entry.
  7. Broker outage: attempts grows and next_attempt_at is respected.
  8. Poison message: it reaches the dead-letter mechanism.
  9. Expired lease: another worker recovers the message.
  10. Per-aggregate order: the consumer detects an unexpected version.

The key test is not “it was published once.” It is “even if the broker delivers the message more than once, the local effect protected by Inbox is committed only once.”

When to choose another solution

Outbox is not always the answer.

  • If everything happens in one database, a local transaction may be enough.
  • If the event only feeds analytics, CDC can avoid a hand-written relay.
  • If the broker and store are part of a platform with specific transactional guarantees, evaluate those guarantees without extending them to external side effects they do not control.
  • If the process spans several services and requires compensations, you are probably entering saga territory.
  • If losing the event is acceptable and state can be rebuilt, periodic reconciliation might be simpler.

The pattern adds tables, a worker, retries, cleanup, metrics, and contract discipline. It should pay that cost by addressing a real risk.

Result

Idempotency-Key protects the HTTP edge. Transactional Outbox protects the hop from the local transaction to the broker. Inbox protects the consumer effect from redelivery.

Together they form an honest chain:

HTTP retry
    -> one logical operation
    -> one durable event
    -> at-least-once publication
    -> one effect per consumer

This is not “exactly once” magic. It is a verifiable combination of local atomicity, stable identifiers, explicit retries, and deduplication.

Sources

Comments

Loading comments…