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:
- the PostgreSQL transaction commits the payment;
- the process dies before publishing;
- 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:
- Transactional Outbox: the business change and the outgoing message are stored in the same local transaction.
- At-least-once relay: a worker publishes pending messages and may repeat one after a failure.
- 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;
BackgroundServicefor the relay;NpgsqlDataSourceto claim batches with explicit SQL;- an
IEventPublisherabstraction 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:
- claims a small batch of available messages;
- commits that claim quickly;
- publishes outside the PostgreSQL transaction;
- marks each message as published;
- 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:
EventIddeduplicates the logical message;AggregateIdidentifies the payment;TraceParentcorrelates 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 onceA 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
COMMITThe 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:
- one short statement assigns
lock_idandlocked_until; - the transaction ends;
- the worker publishes without database locks;
- 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:
- the broker accepts the event;
- the worker dies before
MarkPublishedAsync; - the lease expires;
- 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:
- use a stable idempotency key supported by the provider;
- create another local Outbox for that effect;
- model
processing/completedstates and reconcile ambiguous outcomes; - 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_atto 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:
- Command rollback: payment creation fails and no Outbox row remains.
- Crash after commit: payment and Outbox survive restart.
- Two relays: no message is claimed simultaneously while its lease is active.
- Crash after publish: the event can repeat with the same ID.
- Concurrent duplicate: Inbox permits only one effect.
- Crash before ACK: redelivery does not repeat the ledger entry.
- Broker outage:
attemptsgrows andnext_attempt_atis respected. - Poison message: it reaches the dead-letter mechanism.
- Expired lease: another worker recovers the message.
- 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 consumerThis is not “exactly once” magic. It is a verifiable combination of local atomicity, stable identifiers, explicit retries, and deduplication.
Sources
- Transactional Outbox — Microservices.io
- Transactional Outbox pattern — AWS Prescriptive Guidance
- Idempotent Consumer Pattern — Microsoft Azure Architecture Center
- Transactions — Entity Framework Core
- Connection resiliency and transactions — Entity Framework Core
- Use scoped services within a BackgroundService — .NET
SELECTlocking clauses — PostgreSQL 18- Constraints — PostgreSQL 18
- Context propagation — OpenTelemetry
- Trace Context — W3C Recommendation