Skip to main content

Oubox Pattern

1. What this document is about

This document describes the Outbox Pattern as a reliability mechanism for publishing integration events in systems that use a relational database as the system of record. It addresses the lack of atomicity between state mutation and event publication in distributed systems.

This pattern applies to:

  • Event-driven architectures
  • Systems using relational databases (SQL Server, PostgreSQL, MySQL)
  • Domains where emitted events have business meaning

It does not apply to:

  • Systems using a log-first architecture (e.g. Kafka as the source of truth)
  • Purely synchronous request/response systems
  • Best-effort or non-critical integrations

2. Why this matters in real systems

The core issue is not messaging - it is partial failure.

In real systems:

  • Databaes fail independently from brokers
  • Networks partition
  • Processes crash between instructions
  • Retries amplify side effects

Common failure sequences:

  1. Transaction commits, broker publish fails → lost event
  2. Broker publish succeeds, transaction rolls back → ghost event
  3. Retry publishes the same event multiple times → duplicate side effects
  4. Service crashes after commit but before publish → invisible inconsistency

These failures:

  • Are hard to reproduce
  • Surface weeks later
  • Corrupt downstream projections
  • Break reporting, billing, and compliance flows

As systems evolve monoliths to distributed architectures, these inconsistencies become systemic, not accidental.


3. Core concept (mental model)

The Outbox Pattern enforces a simple rule:

If a fact is not committed to the database, it must not be published.

Mental model:

  • The database is the source of truth
  • Events are facts derived from committed state
  • Message brokers are distribution mechanisms, not transactional authorities

Think of the outbox as:

  • A write-ahead log for integration events
  • A durable contract between domain logic and infrastructure
  • A buffer that absorbs partial failures

This decouples:

  • Business correctness from delivery timing
  • Domain logic from messaging infrastructure

4. How it works (step-by-step)

  1. Start a database transaction Defines the consistency boundary.

  2. Apply domain state changes Aggregate invariants are enforced normally.

  3. Create an outbox entry

    • Id (guid)
    • AggregateId (guid)
    • Message Type (string)
    • Payload (string)
    • Assembly Name (string)
    • Assembly (string)
    • Sent (bool)
    • Occured At (timestamp)
  4. Commit the transaction At this point:

    • Business state exists
    • Event intent exists
    • System in internally consistent
  5. Outbox processor queries pending messages

    • Typically ordered by occurrence times
    • Bounded by batch size
  6. Publish events to the broker

    • Retryable
    • Idempotent at the consumer side
  7. Mark messages as sent

    • Timestamp
    • Optional delivery metadata

Key invariants

  • The database transaction is the only atomic boundary
  • Publishing is asyncronous and retryable
  • Consumers must tolerate duplicates
  • The outbox is append-only from the domain perspective

5. Minimal but realistic example

This example focuses on durable persistence, safe dispatch, type fidelity, and operational clarity.

5.1 Table design (SQL Server)

Design goals:

  • Query efficiently for pending messages
  • Store enough metadata to support evolution and debugging
  • Keep dispatch idempotent and observable

CREATE TABLE dbo.OutboxMessage (
Id UNIQUEIDENTIFIER NOT NULL CONSTRAINT PK_OutboxMessage PRIMARY KEY,
OccuredAtUtc DATETIME2(3) NOT NULL,
AggregateId UNIQUEIDENTIFIER NULL,
MessageType NVARCHAR(256) NOT NULL, -- e.g. "OrderPaidIntegrationEvent"
AssemblyName NVARCHAR(512) NOT NULL, -- e.g. "Company.Sales.Contracts, Company.Sales.Contracts"
Payload NVARCHAR(MAX) NOT NULL, -- JSON
CorrelationId UNIQUEIDENTIFIER NULL,
TenantId NVARCHAR(64) NULL,
Sent BIT NOT NULL CONSTRAINT DF_Outbox_Sent DEFAULT(0),
SentAtUtc DATETIME2(3) NULL,
AttemptCount INT NOT NULL CONSTRAINT DF_Outbox_AttemptCount DEFAULT(0),
LastAttemptAtUtc DATETIME2(3) NULL,
LastError NVARCHAR(2048) NULL,
LockId UNIQUEIDENTIFIER NULL,
LockedAtUtc DATETIME2(3) NULL,
RowVersion ROWVERSION NOT NULL
);


CREATE INDEX IX_Outbox_Pending
ON dbo.OutboxMessage (Sent, OccurredAtUtc)
INCLUDE (MessageType, AssemblyName, CorrelationId, TenantId)

CREATE INDEX IX_Outbox_Locks
ON dbo.OutboxMessage (LockedAtUtc)
INCLUDE (LockId, Sent);


5.2 Outbox model (.NET)

OutboxMessage.cs

public sealed class OutboxMessage
{
public Guid Id { get; private set; }
public DateTime OccurredAtUtc { get; private set; }
public Guid? AggregateId { get; private set; }
public string MessageType { get; private set; } = default!;
public string AssemblyName { get; private set; } = default!;
public string Payload { get; private set; } = default!;
public Guid? CorrelationId { get; private set; }
public string? TenantId { get; private set; }
public bool Sent { get; private set; }
public DateTime? SentAtUtc { get; private set; }
public int AttemptCount { get; private set; }
public DateTime? LastAttemptAtUtc { get; private set; }
public string? LastError { get; private set; }
public Guid? LockId { get; private set; }
public DateTime? LockedAtUtc { get; private set; }

private OutboxMessage() { }

public static OutboxMessage Create<T>(
T @event,
Guid? aggregateId,
Guid? correlationId,
string? tenantId,
DateTime utcNow) where T : class
{
var type = @event.GetType();

return new OutboxMessage
{
Id = Guid.NewGuid(),
OccurredAtUtc = utcNow,
AggregateId = aggregateId,
MessageType = type.FullName ?? type.Name,
AssemblyName = type.AssemblyQualifiedName ?? $"{type.FullName}, {type.Assembly.GetName().Name}",
Payload = JsonSerializer.Serialize(@event, type, JsonDefaults.Options),
CorrelationId = correlationId,
TenantId = tenantId,
Sent = false
};
}

public void MarkAttempt(DateTime utcNow, string? error)
{
AttemptCount++;
LastAttemptAtUtc = utcNow;
LastError = error;
}

public void MarkSent(DateTime utcNow)
{
Sent = true;
SentAtUtc = utcNow;
LockId = null;
LockedAtUtc = null;
LastError = null
}

public bool TryLock(Guid lockId, DateTime utcNow, TimeSpan lockTtl)
{
if(LockedAtUtc is not null && LockedAtUtc.Value > utcNow.Subtract(lockTtl))
return false;

LockId = lockId;
LockedAtUtc = utcNow;

return true;
}
}

public static class JsonDefaults
{
public static readonly JsonSerializerOptions Options = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false
};
}


5.3 Writing to Outbox inside the domain transaction

Key point: state change and outbox insert share the same commit boundary.


using var transaction = await db.Database.BeginTransactionAsync(cancellationToken);

var utcNow = _clock.UtcNow;

// 1) Apply domain changes
order.MarkAsPaid(utcNow);
db.Orders.Update(order);

// 2) Capture integration event
var @event = new OrderPaidIntegrationEvent(
orderId: order.Id,
amount: order.TotalAmount,
occurredAtUtc: utcNow,
version: 1
);

// 3) Persist outbox message inside the same transaction
var outboxMessage = OutboxMessage.Create(
@event,
aggregatedId: order.Id,
correlationId: _correlation.CorrelationId,
tenantId: _tenant.Id,
utcNow: utcNow
);


db.OutboxMessages.Add(outboxMessage);

await db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);

Why this is correct

  • The outbox record is a durable fact that "an event must be emitted".
  • Messaging infrastructure is not part of the consistency boundary.

5.4 Dispatching with lock + idempotent Update

Dispatch requirements:

  • Multiple dispatcher instances must not double-send the same RowVersion
  • Failures must be retryable
  • "Sent" update must be safe and observable

Dispatcher query strategy

  • Pull pending messages
  • Lock them optimistically (row update)
  • Publish
  • Mark as sent
OutboxDispatcher.cs

public sealed class OutboxDispatcher
{
private readonly AppDbContext _db;
private readonly IServiceBusPublisher _bus;
private readonly IClock _clock;

private static readonly TimeSpan LockTtl = TimeSpan.FromMinutes(2);

public OutboxDispatcher(AppDbContext db, IServiceBusPublisher bus, IClock clock)
{
_db = db;
_bus = bus;
_clock = clock;
}

public async Task DispatchOnceAsync(int batchSize, CancellationToken cancellationToken)
{
var utcNow = _clock.UtcNow;
var lockId = Guid.NewGuid();

// 1) Load candidates (cheap scan)
var candidates = await _db.OutboxMessages
.Where(x => !x.Sent)
.OrderBy(x => x.OccurredAtUtc)
.Take(batchSize)
.ToListAsync(cancellationToken);

foreach (var message in candidates)
{
// 2) Acquire lock (best-effort) and persist lock
if(!message.TryLock(lockId, utcNow, LockTtl))
continue;

message.MarkAttempt(utcNow, error: null);
}

await _db.SaveChangesAsync(cancellationToken);

// 3) Dispatch locked rows for this run
var locked = candidates.Where(x => x.LockId == lockId).ToList();

foreach(var message in locked)
{
try
{
var eventType = Type.GetType(message.AssemblyName, throwOnError: false);

await _bus.PublishAsync(
messageType: message.MessageType,
payloadJson: message.Payload,
correlationId: message.CorrelationId,
tenantId: message.TenantId,
occurredAtUtc: message.OccurredAtUtc,
cancellationToken: cancellationToken
);

message.MarkSent(_clock.UtcNow);
}
catch (Exception ex)
{
message.MarkAttempt(_clock.UtcNow, ex.Message);
}
}

await _db.SaveChangesAsync(cancellationToken);
}
}


5.5 Service Bus Publisher

This publisher ensures:

  • message id can be used for idempotency downstream
  • correlation flows across services
  • consistent metadata for observability and routing
IServiceBusPublisher.cs

public interface IServiceBusPublisher
{
Task PublishAsync(
string messageType,
string payloadJson,
Guid? correlationId,
string? tenantId,
DateTime occurredAtUtc,
CancellationToken cancellationToken);
}

AzureServiceBusPublisher.cs

public sealed class AzureServiceBusPublisher : IServiceBusPublisher
{
private readonly ServiceBusSender _sender;

public AzureServiceBusPublisher(ServiceBusSender sender)
{
_sender = sender;
}

public async Task PublishAsync(
string messageType,
string payloadJson,
Guid? correlationId,
string? tenantId,
DateTime occurredAtUtc,
CancellationToken cancellationToken)
{
var message = new ServiceBusMessage(payloadJson)
{
ContentType = "application/json",
Subject = messageType,
CorrelationId = correlationId?.ToString(),
TimeToLive = TimeSpan.FromDays(7)
};

if (!string.IsNullOrWhiteSpace(tenantId))
message.ApplicationProperties["tenantId"] = tenantId;

message.ApplicationProperties["occurredAtUtc"] = occurredAtUtc.ToString("O");
message.ApplicationProperties["messageType"] = messageType;

await _send.SendMessageAsync(message, cancellationToken);
}
}


6. High-Level Overview

Visual representation of the end-to-end flow, highlighting the transactional boundary, outbox persistence, asynchronous dispatch, and downstream consumption.

Scroll to zoom • Drag to pan
Outbox Pattern — Reliable Event Publication (DB as Source of Truth)Outbox Pattern — Reliable Event Publication (DB as Source of Truth)API . Application ServiceSQL ServerSQL ServerSQL ServerAzure Service BusConsumer ServiceConsumer DB . ProjectionConsumer DB . ProjectionClientAPI . Application ServiceSQL ServerOutbox DispatcherAzure Service BusConsumer ServiceConsumer DB . ProjectionClientClientAPI / Application Service(.NET)API / Application Service(.NET)SQL Server(Orders + OutboxMessages)SQL Server(Orders + OutboxMessages)Outbox Dispatcher(Background Worker)Outbox Dispatcher(Background Worker)Azure Service Bus(Topic/Queue)Azure Service Bus(Topic/Queue)Consumer Service(Downstream)Consumer Service(Downstream)Consumer DB / ProjectionConsumer DB / ProjectionAPI . Application ServiceSQL ServerSQL ServerSQL ServerAzure Service BusConsumer ServiceConsumer DB . ProjectionConsumer DB . ProjectionWrite path: atomic state + outboxRequest: PayOrder(orderId)BEGIN TRANSACTIONUPDATE Orders SET Status='Paid' ...INSERT OutboxMessages(Id, OccurredAtUtc, MessageType,AssemblyName, Payload, Sent=0, ...)COMMIT200 OK (business commit succeeded)Invariant:\nState change and Outbox record\nshare the same DB commit boundary.Dispatch path: publish after commitSELECT pending OutboxMessagesWHERE Sent=0 ORDER BY OccurredAtUtcLIMIT batchTryLock each rowSET LockId, LockedAtUtc(+ AttemptCount++)Lock TTL avoids stuck rows.\nMultiple dispatchers can run safely.Publish message(Subject=MessageType,CorrelationId, tenantId,payload JSON)Ack (sent to broker)MarkSentSET Sent=1, SentAtUtc=now,LockId=NULL, LockedAtUtc=NULLOKConsumer side: must be idempotentDeliver message (at-least-once)Idempotency check(e.g., Inbox / Dedup table)If already processed => skipNotProcessed / AlreadyProcessedalt[Not processed]Apply side effects(update projection / trigger workflow)+ record MessageId processedOK[Already processed]Ack without side effectsAckFailure behavior (key idea)If publish fails:\n- Outbox row remains Sent=0\n- Dispatcher retries later\n- No lost event (durable intent)Because delivery is at-least-once:\nConsumers must be idempotent\n(or use an Inbox pattern).plantuml-src RLRBak8s5DtxAsxDPg2QfhkdAXkgw2hpIE8C3PwsfwPInKRO0XHioP5a6BBAH-GByoKviWtOP3RT8Ec-pZdtNgom6QTLQJVgEuNCMgub_VltF_JECy4sk9iVkRGKbfjC9CmA9Qatcn0p5AbI9vpKbc9TsdtVyyn1o89fbjE69OUTLgLCfofJcgncqk0Ahbglp9wbwYJaZhOiCxnrKvacIakkwULJsuJ_MNAPy3Tk3DjnFnCxIGdSSjrw5J9jHI8A9ct8qjH5-FHJwtwYlb_EEzvzhTKftela8E6VNZoF9HRfJpFXG43HKw-u-QOFVXZG4_b5SGKdulee4hwMlU5o7lS_E2C_3BsKMRPXXjE7wCkYUiKr7grqohMXXuQ2fYnJsyqctMWDJJC1A6sohnnChaZJDwKFN3UcGHHwWAf4GF-lKlDBOZGf3SnYLOZawOjxqEGOtGMRAcdA7FZVAfgfapHMSvRNDjFLCceLThM0FfuerEeFdZXCwhSenXkFwPiMbXFotu-8MPMBX8nbE7eWLPSt7djJUdnrgD48EaGLnex0wRa2gwVSto3jEsx4qPawV3tt75Q8FQB9_9TWIV6xlupyQHoibhUtBetsqw_Xp8_drF0GpMEAa4vfnXz39jAFD1mEE--3PJH_Z-_e0ZP1Eg1LafHQyzItNsqoeEOsFXTyi9Ra6yFpJNPUifmFN3cPOZ2AeApnyy05wdTJcwxUteBOI_d_ayUJALvyUdwcrMVgRKeZAiKcAi-59LCc2USf1qWjwmecgV197IkMl40UcHQWUxIMKSL2icTonub1JetQD4SVfck9PeMAx9wJGG4kmoROnecFwVFGux1o_YkYBofjk2xS035xObkqwiKKL3kXNYkEvelvDAO2ZbnlteFyxTVv-xm1Z5Rli_axJNxlehwMYm2WOG0XT9VrJgHOdnSgEH3GsMFQd50_g7T7ZaZt_-gmzq0-vc9Us2agjGyF_NjAMfYwjgiSn_62s569r43V9GuGn0pNygtCh2WmLzDh5uCs9acN4iXkUTO1yf8qsXDPXms8UGq9SelAZUkoyPtMfWg8PDL4SlLOBa4oFk6gg8L7lqMhPRk7qFykY9DM5NH4FhBk6JVuhAADLcwuTFGKtQFwnlJ1qLEZMH7rKYkymd8irGafr2YFbryNYmxKrKc7iraddzNdU-0Xi-lGCIBb8yfB0qLomfUy08Ao4bbLs6irh-1dXjNco6fGf1wpZnbdnZugR9OM9jLyyoehPrtt_yLnSaQpyEG04lXmDnnG85sxFC5zMXPatVBihsMm9PPXPAPdAhHA49AdD7ubB9sY5GdLJ9jgcooNoePN0-oOsidrg0r5DLjPPftDBOf7r4tThQbpXHFnxHQgSNEhB5BdehXEQyIoMkns0EY4XRBDr0bbF3Hpu3BKWhGJg9CAqLqf88xeBb-EXNyfwWSvNoNlH7WITe_bq4dTzSY5c_RZ3YuLVn39pqnaRWTk-9uT1HPwxy3FJYEix-JHxT-1qpLekqoiBOmD1kJZTIAg4z38cP2c6KRkyhQLSGa4kI4qeDlHZ-25CWLXykgtL2yjTVNJIaWdq7whtM-PLBbFUC9Au9JMYaNAXZfYHLgN1Z0_q3xuHR7E0mPChS-Y_etNlmsP_m00?>Outbox Pattern — Reliable Event Publication (DB as Source of Truth)Outbox Pattern — Reliable Event Publication (DB as Source of Truth)API . Application ServiceSQL ServerSQL ServerSQL ServerAzure Service BusConsumer ServiceConsumer DB . ProjectionConsumer DB . ProjectionClientAPI . Application ServiceSQL ServerOutbox DispatcherAzure Service BusConsumer ServiceConsumer DB . ProjectionClientClientAPI / Application Service(.NET)API / Application Service(.NET)SQL Server(Orders + OutboxMessages)SQL Server(Orders + OutboxMessages)Outbox Dispatcher(Background Worker)Outbox Dispatcher(Background Worker)Azure Service Bus(Topic/Queue)Azure Service Bus(Topic/Queue)Consumer Service(Downstream)Consumer Service(Downstream)Consumer DB / ProjectionConsumer DB / ProjectionAPI . Application ServiceSQL ServerSQL ServerSQL ServerAzure Service BusConsumer ServiceConsumer DB . ProjectionConsumer DB . ProjectionWrite path: atomic state + outboxRequest: PayOrder(orderId)BEGIN TRANSACTIONUPDATE Orders SET Status='Paid' ...INSERT OutboxMessages(Id, OccurredAtUtc, MessageType,AssemblyName, Payload, Sent=0, ...)COMMIT200 OK (business commit succeeded)Invariant:\nState change and Outbox record\nshare the same DB commit boundary.Dispatch path: publish after commitSELECT pending OutboxMessagesWHERE Sent=0 ORDER BY OccurredAtUtcLIMIT batchTryLock each rowSET LockId, LockedAtUtc(+ AttemptCount++)Lock TTL avoids stuck rows.\nMultiple dispatchers can run safely.Publish message(Subject=MessageType,CorrelationId, tenantId,payload JSON)Ack (sent to broker)MarkSentSET Sent=1, SentAtUtc=now,LockId=NULL, LockedAtUtc=NULLOKConsumer side: must be idempotentDeliver message (at-least-once)Idempotency check(e.g., Inbox / Dedup table)If already processed => skipNotProcessed / AlreadyProcessedalt[Not processed]Apply side effects(update projection / trigger workflow)+ record MessageId processedOK[Already processed]Ack without side effectsAckFailure behavior (key idea)If publish fails:\n- Outbox row remains Sent=0\n- Dispatcher retries later\n- No lost event (durable intent)Because delivery is at-least-once:\nConsumers must be idempotent\n(or use an Inbox pattern).plantuml-src RLRBak8s5DtxAsxDPg2QfhkdAXkgw2hpIE8C3PwsfwPInKRO0XHioP5a6BBAH-GByoKviWtOP3RT8Ec-pZdtNgom6QTLQJVgEuNCMgub_VltF_JECy4sk9iVkRGKbfjC9CmA9Qatcn0p5AbI9vpKbc9TsdtVyyn1o89fbjE69OUTLgLCfofJcgncqk0Ahbglp9wbwYJaZhOiCxnrKvacIakkwULJsuJ_MNAPy3Tk3DjnFnCxIGdSSjrw5J9jHI8A9ct8qjH5-FHJwtwYlb_EEzvzhTKftela8E6VNZoF9HRfJpFXG43HKw-u-QOFVXZG4_b5SGKdulee4hwMlU5o7lS_E2C_3BsKMRPXXjE7wCkYUiKr7grqohMXXuQ2fYnJsyqctMWDJJC1A6sohnnChaZJDwKFN3UcGHHwWAf4GF-lKlDBOZGf3SnYLOZawOjxqEGOtGMRAcdA7FZVAfgfapHMSvRNDjFLCceLThM0FfuerEeFdZXCwhSenXkFwPiMbXFotu-8MPMBX8nbE7eWLPSt7djJUdnrgD48EaGLnex0wRa2gwVSto3jEsx4qPawV3tt75Q8FQB9_9TWIV6xlupyQHoibhUtBetsqw_Xp8_drF0GpMEAa4vfnXz39jAFD1mEE--3PJH_Z-_e0ZP1Eg1LafHQyzItNsqoeEOsFXTyi9Ra6yFpJNPUifmFN3cPOZ2AeApnyy05wdTJcwxUteBOI_d_ayUJALvyUdwcrMVgRKeZAiKcAi-59LCc2USf1qWjwmecgV197IkMl40UcHQWUxIMKSL2icTonub1JetQD4SVfck9PeMAx9wJGG4kmoROnecFwVFGux1o_YkYBofjk2xS035xObkqwiKKL3kXNYkEvelvDAO2ZbnlteFyxTVv-xm1Z5Rli_axJNxlehwMYm2WOG0XT9VrJgHOdnSgEH3GsMFQd50_g7T7ZaZt_-gmzq0-vc9Us2agjGyF_NjAMfYwjgiSn_62s569r43V9GuGn0pNygtCh2WmLzDh5uCs9acN4iXkUTO1yf8qsXDPXms8UGq9SelAZUkoyPtMfWg8PDL4SlLOBa4oFk6gg8L7lqMhPRk7qFykY9DM5NH4FhBk6JVuhAADLcwuTFGKtQFwnlJ1qLEZMH7rKYkymd8irGafr2YFbryNYmxKrKc7iraddzNdU-0Xi-lGCIBb8yfB0qLomfUy08Ao4bbLs6irh-1dXjNco6fGf1wpZnbdnZugR9OM9jLyyoehPrtt_yLnSaQpyEG04lXmDnnG85sxFC5zMXPatVBihsMm9PPXPAPdAhHA49AdD7ubB9sY5GdLJ9jgcooNoePN0-oOsidrg0r5DLjPPftDBOf7r4tThQbpXHFnxHQgSNEhB5BdehXEQyIoMkns0EY4XRBDr0bbF3Hpu3BKWhGJg9CAqLqf88xeBb-EXNyfwWSvNoNlH7WITe_bq4dTzSY5c_RZ3YuLVn39pqnaRWTk-9uT1HPwxy3FJYEix-JHxT-1qpLekqoiBOmD1kJZTIAg4z38cP2c6KRkyhQLSGa4kI4qeDlHZ-25CWLXykgtL2yjTVNJIaWdq7whtM-PLBbFUC9Au9JMYaNAXZfYHLgN1Z0_q3xuHR7E0mPChS-Y_etNlmsP_m00?>