Azure y cloud

Arquitecturas event-driven en .NET: beneficios y costos

Los eventos desacoplan servicios y absorben picos, pero esconden el flujo y complican la consistencia. Eventos en .NET, patrón outbox y cuándo evitarlos.

Por Leyton Manuel Espitia Diaz 5 min de lectura

Fragmento de código C# que guarda un evento en una tabla outbox dentro de la misma transacción

Al principio, el flujo es claro: el endpoint de pedidos guarda el pedido, llama a facturación, llama a inventario y llama a notificaciones. Con el tiempo aparecen más interesados: analítica, el programa de puntos, la integración con la transportadora. El endpoint conoce a todos, y cuando cualquiera de ellos falla, el pedido falla.

Una arquitectura orientada a eventos invierte esa relación. El servicio de pedidos anuncia "se creó un pedido" y no sabe quién escucha. Cada interesado reacciona por su cuenta. Es un cambio poderoso, y también uno que cambia la forma en que se depura, se prueba y se razona sobre el sistema.

Eventos, no comandos disfrazados

Un evento describe algo que ya pasó, en tiempo pasado: OrderPlaced, PaymentApproved, ProductPriceChanged. Quien lo publica no espera nada de nadie.

Un comando pide que algo pase: GenerateInvoice, SendEmail. Tiene un destinatario y una expectativa.

Mezclarlos es un error frecuente: un "evento" llamado SendWelcomeEmail es un comando, y el productor sigue acoplado a lo que espera que ocurra.

Qué debe llevar un evento

public record OrderPlaced(
    Guid EventId,          // para detectar duplicados
    DateTimeOffset OccurredAt,
    int OrderId,
    int CustomerId,
    decimal Total,
    IReadOnlyList<OrderPlacedLine> Lines);

public record OrderPlacedLine(int ProductId, int Quantity, decimal UnitPrice);

Hay dos estilos:

  • Eventos delgados: solo el ID (OrderPlaced(OrderId)). Cada consumidor consulta lo que necesita. Menos acoplamiento al formato, pero cada evento genera consultas de vuelta al productor, y el dato pudo haber cambiado entre el evento y la consulta.
  • Eventos con estado: incluyen los datos relevantes del momento. Los consumidores no dependen de que el productor esté disponible, a cambio de un contrato más grande que hay que versionar.

En la práctica, un punto medio funciona bien: los datos que casi todos los consumidores necesitan, y el ID para lo demás.

El problema central: publicar de forma confiable

await db.SaveChangesAsync(ct);
await bus.PublishAsync(new OrderPlaced(...), ct); // ❌ si esto falla, el evento se pierde

Guardar en la base de datos y publicar en el bus son dos operaciones distintas. Si la segunda falla, el pedido existe y nadie se enteró. Si se invierte el orden, se publica un evento de un pedido que quizás no se guardó.

El patrón outbox

El evento se guarda en la misma transacción que el cambio, en una tabla de la base de datos:

db.Orders.Add(order);
db.OutboxMessages.Add(new OutboxMessage
{
    Id = Guid.NewGuid(),
    Type = nameof(OrderPlaced),
    Payload = JsonSerializer.Serialize(new OrderPlaced(...)),
    OccurredAt = clock.GetUtcNow()
});

await db.SaveChangesAsync(ct); // pedido y evento: todo o nada

Un proceso aparte lee los mensajes pendientes, los publica en el bus y los marca como enviados. Si el bus no está disponible, los mensajes esperan en la tabla. Bibliotecas como MassTransit, NServiceBus o Wolverine implementan este patrón; también se puede escribir en pocas decenas de líneas con un Background Service.

El outbox garantiza que el evento se publique al menos una vez. Puede publicarse dos veces si el proceso cae después de publicar y antes de marcarlo. Por eso, del otro lado, los consumidores tienen que ser idempotentes.

Consumidores idempotentes

public async Task HandleAsync(OrderPlaced evt, CancellationToken ct)
{
    // Si ya procesamos este evento, no hacemos nada
    if (await db.ProcessedEvents.AnyAsync(e => e.EventId == evt.EventId, ct))
        return;

    db.Invoices.Add(Invoice.For(evt));
    db.ProcessedEvents.Add(new ProcessedEvent(evt.EventId));
    await db.SaveChangesAsync(ct); // factura y registro del evento juntos
}

Una restricción única sobre EventId en la tabla protege incluso contra dos consumidores procesando el mismo evento al mismo tiempo.

Lo que se vuelve más difícil

  • Seguir el flujo. Ya no hay una pila de llamadas que lea "qué pasa cuando se crea un pedido". Hay que buscar quién está suscrito a OrderPlaced. El tracing distribuido (OpenTelemetry) con el contexto propagado en los mensajes es casi obligatorio.
  • Consistencia eventual. La factura aparece segundos después del pedido. Las pantallas y los procesos tienen que contemplarlo.
  • Errores que no llegan al usuario. Si facturación falla, el pedido ya fue confirmado. Hace falta monitorear las colas de mensajes muertos y tener una forma de reprocesar.
  • Versionar los eventos. Un evento publicado es un contrato con consumidores que quizás no conoces. Agregar campos es seguro; quitar o renombrar, no.
  • Probar. Las pruebas de extremo a extremo tienen que esperar a que los eventos se procesen.

El trade-off

La arquitectura orientada a eventos cambia acoplamiento temporal y de conocimiento (el productor ya no necesita que los consumidores estén disponibles ni saber que existen) por visibilidad y consistencia inmediata. Es un gran intercambio cuando hay muchos interesados en lo que pasa, y uno malo cuando el flujo es lineal y todos los pasos deben ocurrir juntos.

Cuándo usarla

  • Varios sistemas o módulos reaccionan al mismo hecho de negocio.
  • Los consumidores pueden procesar con retraso sin afectar al usuario.
  • Hay picos de carga que conviene absorber en una cola.
  • Se integran sistemas de equipos distintos que se despliegan por separado.

Cuándo no

  • Flujos que deben ser consistentes de inmediato: el usuario necesita el resultado para continuar.
  • Sistemas pequeños con un solo equipo, donde una llamada directa es más simple de entender y depurar.
  • Cuando no hay capacidad para operar lo que trae: monitoreo de colas, reprocesamiento y trazas distribuidas.

Conclusión

Los eventos son una buena herramienta para que un sistema crezca sin que cada parte conozca a todas las demás. Pero cada evento es un contrato, cada consumidor debe soportar duplicados y cada publicación necesita una garantía como el outbox. Con esas tres piezas, la arquitectura cumple lo que promete. Sin ellas, se pierden eventos sin que nadie lo note.

Leyton Manuel Espitia Diaz

Escrito por

Leyton Manuel Espitia Diaz

CEO y fundador de Nakanaba

Desarrollador de software y fundador de Nakanaba. Lidera la arquitectura de Vitrin: backend en .NET, aplicaciones en Angular y .NET MAUI, SQL Server y servicios en Cloudflare.

Lleva tu aplicación a la nube con criterio

Diseñamos arquitecturas en Azure que crecen con tu negocio sin disparar los costos de infraestructura.

Ver soluciones escalables