Outbox pattern en microservicios .NET: publicar eventos sin perder consistencia con la base de datos
Un servicio que procesa un pedido normalmente necesita hacer dos cosas: guardar el cambio en su propia base de datos y avisarle al resto del sistema que ese cambio ocurrió, publicando un evento en una cola o en un bus de mensajes. El problema aparece en cuanto se escriben esas dos operaciones como dos pasos separados, porque entre un SaveChangesAsync y una llamada a PublishAsync no existe ninguna transacción que las una. Si el proceso guarda el pedido y después falla, se reinicia o simplemente pierde la conexión con el broker antes de publicar, el pedido queda guardado pero el resto del sistema nunca se entera. Si se publica primero y la escritura en base de datos falla después, el evento sale al mundo describiendo algo que nunca llegó a persistirse. En ambos casos el estado del servicio y el estado que ve el resto del sistema se desincronizan, y no hay reintento del lado del broker que arregle eso, porque desde su perspectiva el mensaje se entregó correctamente.
Por qué no basta con una transacción distribuida
La respuesta obvia —envolver ambas operaciones en una transacción distribuida, con un coordinador de dos fases entre la base de datos y el broker— casi nunca es viable en la práctica. La mayoría de los brokers de mensajería que usamos en proyectos .NET (RabbitMQ, Azure Service Bus en su modo estándar, SQS) no participan en un protocolo de dos fases con SQL Server o PostgreSQL, y aunque participaran, el coste operativo de mantener un coordinador de transacciones distribuidas vivo y confiable supera por mucho el problema que resuelve. El outbox pattern evita ese coordinador por completo, porque convierte la publicación del evento en una escritura más dentro de la misma transacción local que ya se usa para guardar el pedido.
La tabla outbox
La idea central es simple: en vez de publicar el evento directamente al broker, se inserta una fila que describe ese evento en una tabla Outbox de la misma base de datos, dentro de la misma transacción que modifica el agregado de negocio. Si la transacción se confirma, la fila del evento queda confirmada junto con el pedido, en la misma unidad atómica que ya garantiza el motor de base de datos. Si la transacción se revierte, el evento nunca existió, exactamente como el pedido.
public class OutboxMessage
{
public Guid Id { get; set; }
public string Type { get; set; } = default!;
public string Payload { get; set; } = default!;
public DateTime OccurredOnUtc { get; set; }
public DateTime? ProcessedOnUtc { get; set; }
}
public async Task CrearPedidoAsync(Pedido pedido, CancellationToken ct)
{
using var transaction = await _context.Database.BeginTransactionAsync(ct);
_context.Pedidos.Add(pedido);
_context.OutboxMessages.Add(new OutboxMessage
{
Id = Guid.NewGuid(),
Type = nameof(PedidoCreado),
Payload = JsonSerializer.Serialize(new PedidoCreado(pedido.Id, pedido.ClienteId, pedido.Total)),
OccurredOnUtc = DateTime.UtcNow
});
await _context.SaveChangesAsync(ct);
await transaction.CommitAsync(ct);
}
Con EF Core, ni siquiera es necesario abrir la transacción de forma explícita en la mayoría de los casos: un único SaveChangesAsync que incluya tanto el Pedido como el OutboxMessage en el mismo DbContext ya corre dentro de una sola transacción implícita. Lo que hace que esto funcione no es el framework, sino la decisión de tratar "guardar el pedido" y "registrar que hay un evento pendiente" como la misma operación de base de datos, nunca como dos llamadas separadas a dos sistemas distintos.
El publicador en segundo plano
Una vez que el evento vive como una fila en la base de datos, hace falta un proceso separado que la lea y la publique al broker real. La forma más simple de implementar esto en .NET es un BackgroundService que hace polling sobre la tabla a intervalos cortos, publica los mensajes pendientes y los marca como procesados:
public class OutboxPublisher : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly IMessageBus _bus;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
using var scope = _scopeFactory.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var pendientes = await context.OutboxMessages
.Where(m => m.ProcessedOnUtc == null)
.OrderBy(m => m.OccurredOnUtc)
.Take(50)
.ToListAsync(stoppingToken);
foreach (var mensaje in pendientes)
{
await _bus.PublishAsync(mensaje.Type, mensaje.Payload, stoppingToken);
mensaje.ProcessedOnUtc = DateTime.UtcNow;
}
await context.SaveChangesAsync(stoppingToken);
await Task.Delay(TimeSpan.FromSeconds(2), stoppingToken);
}
}
}
Este publicador puede fallar a mitad de un lote —el proceso se reinicia después de publicar diez mensajes de cincuenta, antes de guardar el ProcessedOnUtc— y eso es, por diseño, aceptable: la próxima pasada va a volver a publicar esos diez mensajes. El outbox pattern entrega garantías at-least-once, no exactly-once, y eso significa que cada consumidor del evento tiene que ser idempotente frente a mensajes duplicados, normalmente guardando el identificador del mensaje ya procesado y descartando repeticiones. Quien adopta outbox asumiendo que resuelve duplicados en el otro extremo está resolviendo solo la mitad del problema.
Lo que no funcionó: el publicador que se quedó atrás
En un servicio con un volumen de escrituras notablemente más alto que el resto, el publicador por polling empezó a acumular mensajes sin procesar más rápido de lo que los iba vaciando. El síntoma no fue un error: fue que los eventos seguían llegando a los consumidores, pero con un retraso que crecía de forma sostenida a lo largo del día, porque cada pasada del BackgroundService tardaba más que el intervalo de Task.Delay fijado, y las pasadas empezaban a superponerse en la práctica aunque el código no las ejecutara en paralelo. El Take(50) que parecía una medida de seguridad razonable en desarrollo resultó ser el cuello de botella en producción: con miles de mensajes acumulados, publicar en lotes de cincuenta cada dos segundos nunca iba a alcanzar el ritmo de generación.
La corrección no fue bajar el intervalo de polling, que solo habría aumentado la carga sobre la tabla sin resolver el desbalance de fondo. Fueron dos cambios distintos: subir el tamaño del lote a un número bastante mayor, calculado contra el volumen real de eventos por minuto en ese servicio, y añadir una métrica explícita de retraso —la diferencia entre DateTime.UtcNow y el OccurredOnUtc del mensaje más antiguo sin procesar— expuesta como gauge, para detectar que el publicador se estaba quedando atrás antes de que algún consumidor externo preguntara por qué un evento tardaba tanto en llegar. Sin esa métrica, el retraso solo se notaba cuando alguien fuera del equipo lo reportaba, que es el peor momento para enterarse.
Purgar la tabla, no dejarla crecer indefinidamente
Un error relacionado, menos dramático pero igual de real, es tratar OutboxMessages como si pudiera crecer para siempre. Los mensajes ya procesados no aportan nada después de confirmarse su publicación, salvo como rastro de auditoría durante un período acotado. Mantuvimos un job separado, corriendo con mucho menos frecuencia que el publicador, que borra en lotes los mensajes con ProcessedOnUtc más antiguo que una ventana de retención fija. Sin ese job, la tabla outbox termina siendo la tabla más grande de la base de datos, y cada SELECT del publicador —incluso con el índice correcto sobre ProcessedOnUtc— se vuelve progresivamente más costoso simplemente por el tamaño físico de la tabla.
Qué revisamos antes de dar por buena una implementación de outbox
Antes de considerar lista una implementación de outbox en un servicio .NET, verificamos que la inserción en la tabla outbox ocurra dentro de la misma transacción que la escritura del agregado de negocio, nunca en una llamada posterior; que el publicador marque cada mensaje como procesado solo después de confirmar la publicación, no antes; que cada consumidor del evento sea idempotente frente a reintentos, porque la garantía real es at-least-once; que exista una métrica de retraso sobre el mensaje más antiguo sin procesar, visible antes de que un consumidor externo la note; y que haya un job de purgado independiente del publicador. Cuando esas condiciones se cumplen, una caída del proceso o un reinicio a mitad de un lote deja de ser un evento perdido en algún punto entre dos sistemas, y se convierte en exactamente lo que el patrón promete: un mensaje que se va a publicar, tarde o temprano, porque nunca dejó de estar ahí.