El problema: transacciones distribuidas y el coste del acoplamiento fuerte
En un monolito, una transacción ACID puede abarcar varias tablas y garantizar atomicidad. En microservicios, cada servicio posee su propia base de datos; intentar una transacción única que abarque múltiples servicios implica coordinación distribuida (por ejemplo, 2PC/XA) y suele introducir: bloqueo prolongado de recursos, dependencia fuerte entre servicios, degradación ante fallos parciales, y complejidad operativa. Por eso, en microservicios se prefiere consistencia eventual con patrones que mantienen autonomía: cada servicio confirma su cambio local y comunica el resultado mediante eventos o comandos, permitiendo compensaciones si algo falla.
Objetivo práctico
Diseñar flujos de negocio que: (1) confirmen cambios localmente, (2) propaguen intención/resultado con mensajería, (3) manejen duplicados y reintentos, (4) soporten compensación, y (5) mantengan estados claros y auditables.
Patrón Saga: orquestada vs coreografiada
Saga orquestada
Un orquestador central (un microservicio o componente) dirige la saga enviando comandos y esperando respuestas/eventos. Ventajas: flujo explícito, más fácil de entender y depurar, estados centralizados. Desventajas: riesgo de “cerebro central” si se diseña mal, y más lógica en el orquestador.
- Cuándo usarla: flujos largos con múltiples pasos, necesidad de visibilidad del estado, reglas de negocio complejas, o cuando quieres minimizar acoplamiento entre participantes (cada participante solo conoce comandos/eventos del orquestador).
Saga coreografiada
No hay un coordinador central. Cada servicio reacciona a eventos y emite nuevos eventos. Ventajas: menos componente central, alta autonomía. Desventajas: el flujo queda “disperso”, puede ser difícil de seguir, y aumenta el riesgo de acoplamiento por conocimiento implícito de la secuencia.
- Cuándo usarla: flujos simples, pocos participantes, y cuando el equipo domina bien el modelado por eventos y la observabilidad del flujo.
Regla de diseño
Independientemente del estilo, una saga se apoya en: transacciones locales + mensajería confiable + compensaciones (cuando no es posible “deshacer” de forma ACID).
- Escuche el audio con la pantalla apagada.
- Obtenga un certificado al finalizar.
- ¡Más de 5000 cursos para que explores!
Descargar la aplicación
Outbox pattern: publicar eventos sin perderlos
Un problema típico: guardas en la BD y luego publicas un evento. Si la app cae entre ambas operaciones, puedes confirmar el cambio pero no publicar el evento (o publicarlo sin confirmar el cambio). El patrón Outbox evita esto escribiendo el evento en una tabla outbox dentro de la misma transacción local que el cambio de dominio. Luego, un publicador asíncrono lee la outbox y envía al broker, marcando como enviado.
Estructura mínima de outbox
| Campo | Propósito |
|---|---|
| id | Identificador del mensaje (UUID) |
| aggregate_type / aggregate_id | Referencia al agregado (por ejemplo, Order/123) |
| event_type | Tipo lógico (OrderCreated, StockReserved, etc.) |
| payload | JSON del evento |
| occurred_at | Momento de creación |
| status | PENDING/SENT/FAILED |
| attempts | Reintentos |
Pseudoflujo
- Transacción local: actualizar dominio + insertar fila en outbox (status=PENDING).
- Proceso publicador: seleccionar PENDING, publicar al broker, marcar SENT.
- Si falla: reintentar con backoff; si excede umbral, marcar FAILED y alertar.
Compensaciones: “deshacer” en sistemas distribuidos
Una compensación es una acción semántica que revierte el efecto de un paso previo. No siempre es un rollback exacto; por ejemplo, “liberar stock” compensa “reservar stock”. Diseña compensaciones como operaciones idempotentes y seguras ante reintentos.
Buenas prácticas
- Idempotencia: repetir la compensación no debe causar daño (por ejemplo, liberar una reserva ya liberada debe ser no-op).
- Estados explícitos: el agregado debe reflejar si está en progreso, confirmado, cancelado, etc.
- Timeouts: si un paso no responde, decide si reintentas, cancelas, o escalas a intervención manual.
Diseño de eventos de dominio: forma, semántica y contratos
Evento de dominio vs evento de integración
En la práctica, lo que publicas al broker suele ser un evento de integración: un mensaje estable para otros servicios. Puede derivarse del evento de dominio interno, pero conviene que sea un contrato explícito, versionado y orientado a consumidores.
Campos recomendados en el sobre (envelope)
eventId: UUID para trazabilidad y deduplicación.eventType: nombre estable (por ejemplo,order.created).eventVersion: versión del esquema.occurredAt: timestamp.producer: servicio emisor (por ejemplo,order-service).correlationId: para correlacionar toda la saga.causationId: id del evento/comando que causó este evento.payload: datos del evento.
Ejemplo de evento (JSON)
{ "eventId": "7b6b3f2a-9d7c-4b0b-9c0a-1b0a9d8f2c11", "eventType": "order.created", "eventVersion": 1, "occurredAt": "2026-02-03T10:15:30Z", "producer": "order-service", "correlationId": "c0a8012e-4f3a-4d9f-9b0d-3a2a1c9d2f10", "causationId": "http-request-9f1c", "payload": { "orderId": "ORD-10001", "customerId": "C-77", "items": [ { "sku": "SKU-1", "qty": 2 }, { "sku": "SKU-9", "qty": 1 } ], "total": 125.50 } }Versionado de eventos: cómo evolucionar sin romper consumidores
Los eventos son contratos. Cambiarlos sin estrategia rompe consumidores. Reglas prácticas:
- Compatibilidad hacia atrás: agrega campos opcionales en lugar de renombrar/eliminar.
- Versiona el esquema: usa
eventVersiony mantén deserialización tolerante. - Evita cambios semánticos silenciosos: si cambia el significado, crea un nuevo
eventType(por ejemplo,order.created.v2oorder.opened). - Upcasters: en el consumidor, transforma eventos antiguos a la forma nueva internamente.
Manejo de duplicados: entrega al menos una vez y consumo idempotente
En mensajería es común la entrega at-least-once: un evento puede llegar duplicado. Por tanto, el consumidor debe ser idempotente.
Estrategias comunes
- Inbox/Dedup table: tabla
processed_messagesconeventId. Si ya existe, ignorar. - Idempotency key por operación: por ejemplo,
reservationIdúnico para reservar stock. - Upsert: operaciones que no fallan si el registro ya existe.
Ejemplo de tabla de deduplicación
| Campo | Propósito |
|---|---|
| event_id | UUID del evento procesado |
| processed_at | Timestamp |
| consumer | Nombre del consumidor |
Ejemplo guiado: creación de pedido con reserva de stock y compensación
Escenario: al crear un pedido, se debe reservar stock. Si la reserva falla, el pedido debe cancelarse. Usaremos una saga orquestada para que el flujo y los estados queden claros.
Servicios involucrados
- order-service: gestiona pedidos y actúa como orquestador de la saga.
- inventory-service: gestiona stock y reservas.
Modelo de estados del pedido
Define estados explícitos para evitar ambigüedad:
PENDING_STOCK: pedido creado, esperando reserva.CONFIRMED: stock reservado con éxito.CANCELLED: pedido cancelado (por fallo o timeout).
Estados de reserva de inventario
RESERVED: reserva activa.REJECTED: no se pudo reservar.RELEASED: reserva liberada (compensación).
Comandos y eventos del flujo
| Dirección | Tipo | Nombre | Propósito |
|---|---|---|---|
| Order → Inventory | Comando | ReserveStock | Solicitar reserva |
| Inventory → Order | Evento | StockReserved | Confirmar reserva |
| Inventory → Order | Evento | StockReservationRejected | Rechazar reserva |
| Order → Inventory | Comando | ReleaseStock | Compensación (si aplica) |
Paso a paso (orquestación)
1) Crear pedido en order-service (transacción local + outbox)
Al recibir la solicitud de creación:
- Persistir el pedido con estado
PENDING_STOCK. - Insertar en outbox el comando/evento de intención para reservar stock (por ejemplo, un mensaje
ReserveStock). - Responder al cliente con el
orderIdy estado inicial.
// Order aggregate (simplificado) public class Order { private String id; private OrderStatus status; private List<OrderItem> items; // ... } public enum OrderStatus { PENDING_STOCK, CONFIRMED, CANCELLED }// En el caso de uso createOrder() (pseudocódigo) @Transactional public OrderCreatedResult createOrder(CreateOrderCommand cmd) { Order order = new Order(cmd.orderId(), PENDING_STOCK, cmd.items()); orderRepository.save(order); OutboxMessage msg = OutboxMessage.command( "inventory.reserve-stock", new ReserveStockPayload(order.getId(), cmd.items()), cmd.correlationId() ); outboxRepository.save(msg); return new OrderCreatedResult(order.getId(), order.getStatus()); }2) Publicación asíncrona del outbox
Un publicador (scheduler o worker) envía inventory.reserve-stock al broker. Este componente debe ser tolerante a fallos y reintentos.
// Pseudocódigo public void publishPendingOutbox() { List<OutboxMessage> pending = outboxRepository.findPendingBatch(100); for (OutboxMessage m : pending) { try { broker.publish(m.getTopic(), m.getPayload(), m.getHeaders()); outboxRepository.markSent(m.getId()); } catch (Exception ex) { outboxRepository.incrementAttempts(m.getId()); } } }3) Consumir ReserveStock en inventory-service (idempotencia + transacción local)
El inventario recibe el comando. Debe:
- Verificar deduplicación por
eventIdo porreservationId(recomendado: usarorderIdcomo clave de reserva si el negocio lo permite). - Intentar reservar stock (decremento o creación de “hold”).
- Emitir evento
StockReservedoStockReservationRejectedmediante outbox.
public enum ReservationStatus { RESERVED, REJECTED, RELEASED } @Transactional public void handleReserveStock(ReserveStockCommand cmd) { if (inboxRepository.alreadyProcessed(cmd.eventId())) return; inboxRepository.save(cmd.eventId()); boolean ok = inventoryDomain.tryReserve(cmd.orderId(), cmd.items()); if (ok) { outboxRepository.save(OutboxMessage.event("stock.reserved", new StockReservedPayload(cmd.orderId()), cmd.correlationId())); } else { outboxRepository.save(OutboxMessage.event("stock.rejected", new StockRejectedPayload(cmd.orderId(), "INSUFFICIENT_STOCK"), cmd.correlationId())); } }4) order-service reacciona a stock.reserved y confirma
Cuando llega StockReserved:
- Deduplicar por
eventId. - Validar transición: solo si el pedido está en
PENDING_STOCK. - Actualizar estado a
CONFIRMEDen transacción local. - (Opcional) Publicar
order.confirmedpara otros servicios.
@Transactional public void onStockReserved(StockReservedEvent evt) { if (inboxRepository.alreadyProcessed(evt.eventId())) return; inboxRepository.save(evt.eventId()); Order order = orderRepository.findById(evt.orderId()); if (order.getStatus() == PENDING_STOCK) { order.setStatus(CONFIRMED); orderRepository.save(order); outboxRepository.save(OutboxMessage.event("order.confirmed", new OrderConfirmedPayload(order.getId()), evt.correlationId())); } }5) order-service reacciona a stock.rejected y cancela (compensación)
Cuando llega StockReservationRejected:
- Deduplicar.
- Validar transición:
PENDING_STOCK→CANCELLED. - Persistir cancelación.
- Decidir si enviar compensación
ReleaseStock. En este caso, si el inventario rechazó, no hay reserva que liberar. Pero en otros fallos (por ejemplo, confirmación parcial) sí aplicaría.
@Transactional public void onStockRejected(StockRejectedEvent evt) { if (inboxRepository.alreadyProcessed(evt.eventId())) return; inboxRepository.save(evt.eventId()); Order order = orderRepository.findById(evt.orderId()); if (order.getStatus() == PENDING_STOCK) { order.setStatus(CANCELLED); orderRepository.save(order); outboxRepository.save(OutboxMessage.event("order.cancelled", new OrderCancelledPayload(order.getId(), evt.reason()), evt.correlationId())); } }6) Compensación ante fallo posterior: liberar stock
Considera un caso distinto: el inventario reservó stock, pero un paso posterior (por ejemplo, pago) falla. Entonces el orquestador debe emitir ReleaseStock para compensar. Aunque este capítulo se centra en pedido+stock, el patrón es el mismo: cada paso exitoso debe tener una compensación definida.
Diseño recomendado del comando de compensación:
- Incluir
orderIdy/oreservationId. - Ser idempotente: si la reserva ya está
RELEASED, no hacer nada.
@Transactional public void handleReleaseStock(ReleaseStockCommand cmd) { if (inboxRepository.alreadyProcessed(cmd.eventId())) return; inboxRepository.save(cmd.eventId()); inventoryDomain.releaseIfExists(cmd.orderId()); outboxRepository.save(OutboxMessage.event("stock.released", new StockReleasedPayload(cmd.orderId()), cmd.correlationId())); }Diseño de transiciones claras (máquina de estados)
Para evitar estados imposibles, modela transiciones permitidas. Esto reduce errores cuando llegan eventos fuera de orden o duplicados.
Transiciones del pedido
| Estado actual | Evento | Nuevo estado | Acción |
|---|---|---|---|
| PENDING_STOCK | StockReserved | CONFIRMED | Publicar order.confirmed |
| PENDING_STOCK | StockRejected | CANCELLED | Publicar order.cancelled |
| CONFIRMED | StockReserved (duplicado) | CONFIRMED | No-op (idempotente) |
| CANCELLED | Cualquier | CANCELLED | No-op o registrar |
Qué hacer con eventos fuera de orden
- Ignorar con seguridad: si el estado actual no permite la transición, no cambies nada.
- Registrar y monitorear: guarda el evento y genera métrica/alerta si ocurre con frecuencia.
- Reconciliación: en casos críticos, un proceso batch puede comparar estados (pedido vs reserva) y corregir inconsistencias mediante comandos compensatorios.
Checklist de implementación en Spring Boot (sin atarte a un broker específico)
- Outbox en cada servicio que publique mensajes: tabla + publicador.
- Inbox/dedup en cada consumidor: tabla de mensajes procesados.
- CorrelationId propagado en headers/payload para seguir la saga.
- Idempotencia en comandos y compensaciones.
- Estados explícitos y transiciones validadas.
- Versionado en el envelope del evento y deserialización tolerante.