Outbox e Inbox en ASP.NET Core: entrega confiable y efectos idempotentes
Guardamos el cambio de negocio y su evento en la misma transacción; un relay los entrega al menos una vez y cada consumidor usa Inbox para evitar repetir efectos locales.
Guardar un pago y publicar PaymentCreated parecen dos líneas consecutivas. En producción son dos sistemas independientes, dos confirmaciones y una ventana de fallo capaz de dejar los datos y los eventos contando historias distintas.
Un cliente puede repetir un POST después de un timeout aunque el servidor haya
confirmado la transacción. Outbox no deduplica solicitudes HTTP: una clave
Idempotency-Key persistida debe devolver el resultado original y evitar crear
un segundo pago. Esta guía se centra en el salto entre la base de datos y el broker.
Después de guardar el pago quizá necesitemos:
- publicar un evento en RabbitMQ, Kafka, Azure Service Bus o SQS;
- actualizar contabilidad;
- iniciar una factura;
- notificar a otro servicio;
- alimentar analítica o auditoría.
La implementación ingenua hace esto:
await dbContext.SaveChangesAsync(cancellationToken);
await eventPublisher.PublishAsync(paymentCreated, cancellationToken);Hay una caída entre ambas líneas:
- la transacción de PostgreSQL confirma el pago;
- el proceso muere antes de publicar;
- el pago existe, pero ningún consumidor recibe el evento.
Invertir el orden tampoco funciona. Si publicamos primero y la transacción revierte después, los consumidores reaccionan a un pago que nunca existió.
Ese es el problema de dual write: una operación lógica intenta confirmar estado en dos sistemas sin una transacción atómica compartida.
La garantía real que vamos a construir
No vamos a prometer entrega exactly once de extremo a extremo. Una base de datos, un broker y varios consumidores no comparten una única confirmación atómica.
La garantía práctica se construye por capas:
- Transactional Outbox: el cambio de negocio y el mensaje se guardan en la misma transacción local.
- Relay con entrega al menos una vez: un worker publica los mensajes pendientes y puede repetir alguno después de una caída.
- Inbox o consumidor idempotente: cada consumidor registra el identificador estable del mensaje en la misma transacción que sus efectos.
El resultado no elimina los duplicados del transporte. Hace que repetirlos no repita el efecto de negocio.
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
El modelo del ejemplo
Usaremos:
- ASP.NET Core sobre .NET 10;
- Entity Framework Core para la transacción de negocio;
- PostgreSQL 18 para pagos, Outbox e Inbox;
BackgroundServicepara el relay;NpgsqlDataSourcepara reclamar lotes con SQL explícito;- una abstracción
IEventPublisher, para no acoplar el patrón a un broker.
El flujo comienza con POST /payments. La API crea un pago y un evento
PaymentCreated en un solo SaveChangesAsync.
Un worker independiente:
- reclama un lote pequeño de mensajes disponibles;
- confirma rápidamente esa reclamación;
- publica fuera de la transacción de PostgreSQL;
- marca cada mensaje como publicado;
- programa un reintento si el broker falla.
El consumidor de contabilidad guarda (consumer, message_id) antes de crear el
asiento contable. Ambos cambios confirman o revierten juntos.
Diseñar el sobre del evento
El identificador del evento debe generarse una sola vez y sobrevivir a todos los reintentos. No sirve un identificador nuevo por intento de publicación.
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);Hay tres identidades diferentes:
EventId: deduplica el mensaje lógico;AggregateId: identifica el pago;TraceParent: correlaciona telemetría, pero no deduplica.
Una misma traza puede contener varios eventos. Usar el trace ID como clave de Inbox haría que el primer evento suprimiera legítimamente los demás.
SchemaVersion permite evolucionar el contrato sin deducir la versión a partir
del nombre de una clase .NET.
Tablas Outbox e Inbox en PostgreSQL
La Outbox necesita más que un booleano published. En producción también debe
representar reclamaciones abandonadas, reintentos y errores observables.
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)
);La clave compuesta de Inbox importa. Dos consumidores diferentes deben procesar su propia copia del mismo evento:
(accounting, event-123) -> permitido una vez
(notifications, event-123) -> permitido una vezUna restricción UNIQUE o PRIMARY KEY convierte a PostgreSQL en el árbitro de
duplicados concurrentes. Un SELECT seguido de un INSERT sin restricción
mantiene una carrera abierta.
Guardar negocio y evento en la misma transacción
Si pagos y Outbox usan el mismo DbContext, un solo SaveChangesAsync ya
agrupa todos los cambios en una transacción cuando el proveedor relacional lo
soporta.
El handler siguiente muestra solo la primera ejecución. En producción, una
restricción única por cliente y Idempotency-Key debe asociar la clave, el hash
del request, los IDs y la respuesta a la misma transacción que guarda pago y
Outbox. Repetir la misma clave y payload devuelve la respuesta guardada; reusar
la clave con otro payload se rechaza. Los IDs generados aquí no sustituyen ese
control de idempotencia HTTP.
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
});
}Si la inserción del pago falla, no queda evento. Si la inserción de Outbox falla, no queda pago. El broker todavía no participa: esa separación es precisamente lo que elimina la escritura dual.
También puede extraerse la creación de mensajes con un interceptor de
SaveChanges y eventos de dominio. Es útil cuando muchos aggregates publican
eventos, pero añade comportamiento implícito. Para explicar la garantía, la
versión explícita resulta más fácil de auditar.
No mantener una transacción abierta mientras se publica
Este enfoque parece seguro, pero es una trampa:
BEGIN
SELECT ... FOR UPDATE
publicar en el broker
UPDATE outbox
COMMITLa llamada de red puede tardar segundos. Durante ese tiempo mantenemos locks, ocupamos una conexión y ampliamos el impacto de cualquier timeout.
La alternativa es una reclamación con lease:
- una sentencia corta asigna
lock_idylocked_until; - la transacción termina;
- el worker publica sin mantener locks;
- solo el dueño del lease puede marcar el mensaje.
Si el proceso muere, el lease expira y otro worker recupera la fila.
Reclamar un lote con SKIP LOCKED
PostgreSQL permite que varios workers eviten esperar por las mismas filas:
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;La sentencia reclama el lote atómicamente. Aunque candidates se ordena,
PostgreSQL no garantiza que UPDATE ... RETURNING devuelva las filas en ese
orden. Si importa el orden local, ordena de nuevo el resultado por
occurred_at, id en el worker; esto no impone orden entre workers.
attempts cuenta reclamaciones, no solo fallos del broker: una caída o un lease
expirado también consume un intento. Los 30 segundos son ilustrativos, no un
valor universal. Dimensiona el lote para que el último mensaje se procese antes
de vencer o renueva el lease; si vence durante una publicación, otro worker
puede reclamarla y el mismo evento podría entregarse otra vez. Inbox y el ID
estable del mensaje siguen siendo necesarios.
El índice parcial excluye mensajes publicados y enviados a dead letter.
Ejecutar el relay con un scope por iteración
Un BackgroundService se registra como singleton. No debe conservar un
DbContext scoped durante toda la vida del proceso.
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);
}
}
}
}Cada iteración obtiene servicios scoped nuevos y libera conexiones, tracking y transacciones al terminar.
Publicar y marcar sin ocultar la ventana de duplicación
El relay procesa cada mensaje reclamado:
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);
}
}Un fallo al publicar queda programado y no detiene el resto del lote. La
cancelación de apagado se propaga; si falla la persistencia del reintento, ese
error también se propaga en lugar de simular éxito. El repositorio debe sanitizar exception.Message antes de persistirlo; nunca
guardes credenciales ni payloads sensibles.
Todavía existe una ventana:
- el broker acepta el evento;
- el worker muere antes de
MarkPublishedAsync; - el lease expira;
- otro worker vuelve a publicar.
No debemos esconderla con el nombre de una función. Esa ventana es la razón por
la que el evento conserva el mismo message.Id y el consumidor implementa
Inbox.
Backoff, mensajes venenosos y recuperación
Reintentar inmediatamente puede castigar al broker durante una caída. Podemos calcular el siguiente intento con backoff exponencial acotado:
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));
}En producción conviene añadir jitter para evitar que todas las instancias
reintenten al mismo tiempo. El jitter debe calcularse al programar el próximo
intento y persistirse en next_attempt_at.
Si un intento falla cuando attempts llega a 12, ScheduleRetryAsync marca
esta fila con dead_lettered_at, guarda un error sanitizado y libera el lease.
Antes del límite, calcula el backoff con jitter y persiste next_attempt_at,
también liberando el lease. La consulta de reclamación excluye filas con
dead_lettered_at no nulo. Para reencolar, una operación de mantenimiento
limpia esa marca y programa la próxima fecha.
La operación necesita un runbook para:
- inspeccionar el payload sin exponer datos sensibles;
- corregir la causa;
- volver a programar el mensaje;
- conservar el mismo
id.
Deduplicar en el consumidor con Inbox
El consumidor recibe PaymentCreated. Primero intenta insertar su marcador.
Después aplica el efecto de negocio en la misma transacción.
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);
}El ACK al broker ocurre después del commit.
Si el consumidor muere después del commit pero antes del ACK, el broker entrega de nuevo. La segunda ejecución encuentra la clave de Inbox y no crea otro asiento.
Si falla antes del commit, PostgreSQL revierte tanto el marcador como el asiento y la reentrega puede intentarlo de nuevo.
Duplicados concurrentes
Dos instancias pueden recibir el mismo mensaje casi al mismo tiempo. Ambas
podrían ejecutar un SELECT que diga “no existe”.
Por eso la corrección no depende de:
if (!await AlreadyProcessed(id))
{
await Process(id);
}Depende de la restricción única y de una inserción atómica. Solo una transacción
puede reclamar (accounting, eventId).
La aplicación debe reconocer la colisión como duplicado esperado, no reportarla como un error operacional que active reintentos infinitos.
Efectos externos que no caben en Inbox
Una llamada a un proveedor externo no puede confirmar dentro de la transacción de PostgreSQL.
Ejemplos:
- cobrar mediante una pasarela;
- enviar un email;
- crear un envío;
- escribir en otro servicio por HTTP.
Las opciones son:
- usar una clave de idempotencia estable ofrecida por el proveedor;
- crear otra Outbox local para ese efecto;
- modelar estados
processing/completedy reconciliar resultados ambiguos; - usar una saga cuando varias compensaciones forman un proceso largo.
Guardar un marcador Inbox antes de llamar al proveedor no prueba que la llamada terminó. Guardarlo después reabre la ventana de duplicación. La garantía debe continuar en el sistema que ejecuta el efecto.
Orden: lo que ORDER BY no garantiza
Ordenar el lote reclamado por occurred_at, id hace reproducible el orden
local; varios workers con SKIP LOCKED todavía pueden publicar eventos del
mismo aggregate fuera de orden.
Si PaymentAuthorized v2 no puede adelantarse a PaymentCreated v1, necesitamos
una estrategia adicional:
- guardar
aggregate_version; - particionar el broker por
aggregate_id; - permitir solo un mensaje en vuelo por aggregate;
- usar locks por aggregate;
- detectar huecos de versión en el consumidor.
La elección reduce paralelismo. No existe una opción universal que preserve orden global y escale sin coordinación.
Transacciones reintentables y commits ambiguos
EF Core advierte que una estrategia de ejecución con reintentos no puede
reproducir automáticamente una transacción manual arbitraria. Si habilitamos
una estrategia de reintentos y usamos BeginTransactionAsync, toda la unidad
debe ejecutarse dentro del delegate de CreateExecutionStrategy.
También existe un caso más sutil: la conexión puede caer durante el commit y la aplicación no saber si PostgreSQL confirmó.
Los IDs deben generarse una sola vez, antes del delegate de la estrategia de
reintento, y reutilizarse en cada repetición de esa misma transacción. Aun así,
no deduplican por sí solos reintentos HTTP: la clave Idempotency-Key, el hash
del request y la respuesta guardada deben persistirse con el pago y la Outbox.
Si el commit queda ambiguo, la repetición consulta ese registro antes de crear
otra operación.
Propagar trazas sin usar la traza como identidad
El request HTTP y el worker ocurren en momentos y procesos diferentes. Para conservar causalidad podemos guardar el contexto W3C:
string? traceParent = Activity.Current?.Id;El relay lo inyecta como metadata del mensaje y el consumidor lo extrae para crear un nuevo span relacionado.
OpenTelemetry distingue propagación y contexto. El traceparent sirve para
correlacionar telemetría; el EventId sirve para deduplicar.
No conviene guardar secretos o datos personales en baggage. Ese contenido puede propagarse y terminar en logs o proveedores de observabilidad.
Limpieza y retención
Outbox e Inbox crecen continuamente.
Una política razonable:
- conservar Outbox publicada durante el período necesario para auditoría;
- borrar por lotes pequeños para no crear picos de I/O;
- conservar Inbox más tiempo que la ventana máxima de redelivery;
- considerar reenvíos manuales desde dead-letter queues;
- archivar eventos sujetos a requisitos regulatorios;
- minimizar PII dentro de
payload.
Borrar Inbox demasiado pronto vuelve a aceptar un mensaje antiguo como nuevo.
Métricas que detectan el problema antes que los usuarios
El número total de filas pendientes no basta. Conviene medir:
outbox_pending_total;- edad del mensaje pendiente más antiguo;
- publicaciones por segundo;
- intentos por mensaje;
- mensajes en dead letter;
- leases expirados recuperados;
- duplicados ignorados por Inbox;
- latencia desde
occurred_athasta publicación; - latencia desde publicación hasta procesamiento.
La señal más útil suele ser la edad del pendiente más antiguo. Una cola puede crecer temporalmente y seguir saludable; un mensaje inmóvil durante horas indica un bloqueo real.
Pruebas de fallo que debe pasar la implementación
Un test feliz no valida este patrón. Necesitamos apagar procesos en puntos incómodos:
- Rollback del comando: falla la creación del pago y no queda Outbox.
- Caída después del commit: el pago y la Outbox sobreviven al reinicio.
- Dos relays: ningún mensaje se reclama simultáneamente mientras el lease está vigente.
- Caída después de publicar: el evento puede repetirse con el mismo ID.
- Duplicado concurrente: Inbox permite un solo efecto.
- Caída antes del ACK: la reentrega no repite el asiento.
- Broker caído: aumenta
attemptsy respetanext_attempt_at. - Poison message: llega al mecanismo de dead letter.
- Lease vencido: otro worker recupera el mensaje.
- Orden por aggregate: el consumidor detecta una versión inesperada.
La prueba clave no es “se publicó una vez”. Es “aunque el broker entregue el mensaje varias veces, el efecto local protegido por Inbox se confirma una sola vez”.
Cuándo usar otra solución
Outbox no siempre es la respuesta.
- Si todo ocurre en una sola base de datos, una transacción local puede bastar.
- Si el evento solo alimenta analítica, CDC puede evitar un relay escrito a mano.
- Si el broker y el almacén forman parte de una plataforma con garantías transaccionales específicas, evalúa esas garantías sin extenderlas a efectos externos que no controlan.
- Si el proceso cruza varios servicios y requiere compensaciones, probablemente estás entrando en una saga.
- Si perder el evento es aceptable y se puede reconstruir, quizá una reconciliación periódica sea más simple.
El patrón añade tablas, worker, reintentos, limpieza, métricas y disciplina de contratos. Debe pagar ese costo resolviendo un riesgo real.
Resultado
Idempotency-Key protege el borde HTTP. Transactional Outbox protege el salto
desde la transacción local hacia el broker. Inbox protege el efecto del
consumidor ante redeliveries.
Juntos forman una cadena honesta:
reintento HTTP
-> una operación lógica
-> un evento durable
-> publicación al menos una vez
-> un efecto por consumidorNo es magia de “exactly once”. Es una combinación verificable de atomicidad local, identificadores estables, reintentos explícitos y deduplicación.
Fuentes
- 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