← Volver al blogOutbox e Inbox en ASP.NET Core: entrega confiable y efectos idempotentes
dotnetaspnet-corepostgresqldistributed-systemsoutboxinboxmessagingarchitecture

Outbox e Inbox en ASP.NET Core: entrega confiable y efectos idempotentes

Implementa Transactional Outbox e Inbox con .NET 10 y PostgreSQL 18: guardado atómico, entrega al menos una vez, reintentos y efectos locales deduplicados en la transacción del consumidor.

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:

  1. la transacción de PostgreSQL confirma el pago;
  2. el proceso muere antes de publicar;
  3. 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:

  1. Transactional Outbox: el cambio de negocio y el mensaje se guardan en la misma transacción local.
  2. Relay con entrega al menos una vez: un worker publica los mensajes pendientes y puede repetir alguno después de una caída.
  3. 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;
  • BackgroundService para el relay;
  • NpgsqlDataSource para 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:

  1. reclama un lote pequeño de mensajes disponibles;
  2. confirma rápidamente esa reclamación;
  3. publica fuera de la transacción de PostgreSQL;
  4. marca cada mensaje como publicado;
  5. 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 vez

Una 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
COMMIT

La 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:

  1. una sentencia corta asigna lock_id y locked_until;
  2. la transacción termina;
  3. el worker publica sin mantener locks;
  4. 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:

  1. el broker acepta el evento;
  2. el worker muere antes de MarkPublishedAsync;
  3. el lease expira;
  4. 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:

  1. usar una clave de idempotencia estable ofrecida por el proveedor;
  2. crear otra Outbox local para ese efecto;
  3. modelar estados processing/completed y reconciliar resultados ambiguos;
  4. 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_at hasta 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:

  1. Rollback del comando: falla la creación del pago y no queda Outbox.
  2. Caída después del commit: el pago y la Outbox sobreviven al reinicio.
  3. Dos relays: ningún mensaje se reclama simultáneamente mientras el lease está vigente.
  4. Caída después de publicar: el evento puede repetirse con el mismo ID.
  5. Duplicado concurrente: Inbox permite un solo efecto.
  6. Caída antes del ACK: la reentrega no repite el asiento.
  7. Broker caído: aumenta attempts y respeta next_attempt_at.
  8. Poison message: llega al mecanismo de dead letter.
  9. Lease vencido: otro worker recupera el mensaje.
  10. 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 consumidor

No es magia de “exactly once”. Es una combinación verificable de atomicidad local, identificadores estables, reintentos explícitos y deduplicación.

Fuentes

Comentarios

Cargando comentarios…