Patrones de consistencia de datos y sagas en microservicios con Spring Boot

Capítulo 11

Tiempo estimado de lectura: 9 minutos

+ Ejercicio

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).

Continúa en nuestra aplicación.
  • Escuche el audio con la pantalla apagada.
  • Obtenga un certificado al finalizar.
  • ¡Más de 5000 cursos para que explores!
O continúa leyendo más abajo...
Download App

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

CampoPropósito
idIdentificador del mensaje (UUID)
aggregate_type / aggregate_idReferencia al agregado (por ejemplo, Order/123)
event_typeTipo lógico (OrderCreated, StockReserved, etc.)
payloadJSON del evento
occurred_atMomento de creación
statusPENDING/SENT/FAILED
attemptsReintentos

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 eventVersion y mantén deserialización tolerante.
  • Evita cambios semánticos silenciosos: si cambia el significado, crea un nuevo eventType (por ejemplo, order.created.v2 o order.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_messages con eventId. 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

CampoPropósito
event_idUUID del evento procesado
processed_atTimestamp
consumerNombre 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ónTipoNombrePropósito
Order → InventoryComandoReserveStockSolicitar reserva
Inventory → OrderEventoStockReservedConfirmar reserva
Inventory → OrderEventoStockReservationRejectedRechazar reserva
Order → InventoryComandoReleaseStockCompensació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 orderId y 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 eventId o por reservationId (recomendado: usar orderId como clave de reserva si el negocio lo permite).
  • Intentar reservar stock (decremento o creación de “hold”).
  • Emitir evento StockReserved o StockReservationRejected mediante 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 CONFIRMED en transacción local.
  • (Opcional) Publicar order.confirmed para 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_STOCKCANCELLED.
  • 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 orderId y/o reservationId.
  • 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 actualEventoNuevo estadoAcción
PENDING_STOCKStockReservedCONFIRMEDPublicar order.confirmed
PENDING_STOCKStockRejectedCANCELLEDPublicar order.cancelled
CONFIRMEDStockReserved (duplicado)CONFIRMEDNo-op (idempotente)
CANCELLEDCualquierCANCELLEDNo-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.

Ahora responde el ejercicio sobre el contenido:

¿Cuál es la principal ventaja del patrón Outbox al publicar mensajes en una arquitectura de microservicios?

¡Tienes razón! Felicitaciones, ahora pasa a la página siguiente.

¡Tú error! Inténtalo de nuevo.

Outbox registra el mensaje en una tabla dentro de la misma transacción local que el cambio de dominio. Luego un publicador asíncrono lo envía al broker, evitando inconsistencias del tipo “se guardó el cambio pero no se publicó el evento”.

Siguiente capítulo

Proyecto integrador de microservicios con Spring Boot orientado a buenas prácticas

Arrow Right Icon
Portada de libro electrónico gratuitaMicroservicios con Spring Boot desde Cero
92%

Microservicios con Spring Boot desde Cero

Nuevo curso

12 páginas

Descarga la aplicación para obtener una certificación gratuita y escuchar cursos en segundo plano, incluso con la pantalla apagada.