Día 24 de 30 Fase 6: Mensajería Asíncrona con Kafka, RabbitMQ, EDA y CQRS

Arquitectura Orientada a Eventos y Patrón Outbox

Eventos de dominio e integración, publicación fiable con Outbox, consumidores idempotentes y consistencia eventual

Visión de conjunto. Sales entendiendo cómo funciona y cuándo usarlo; dominarlo pide más práctica en un proyecto real.

  • 3.5h de estudio
  • 5 conceptos
  • 3 preguntas
  • 2 katas
EDAEventos de DominioOutbox PatternIdempotenciaEventual Consistency
Al terminar este día sabrás 4
  • Modelar eventos de dominio como hechos inmutables y en pasado.
  • Diseñar eventos de integración como contratos estables y versionados.
  • Publicar eventos de forma fiable con el patrón Transactional Outbox.
  • Escribir consumidores idempotentes y diseñar la interfaz para la consistencia eventual.

Arquitectura Orientada a Eventos (EDA): Fundamentos

En EDA, los servicios se comunican exclusivamente publicando eventos (hechos que ya ocurrieron) en lugar de invocar servicios directamente. Un evento es un dato inmutable que registra algo que sucedió en el dominio: PedidoCreado, PagoConfirmado, StockReservado. Los servicios interesados se suscriben a esos eventos y reaccionan de forma autónoma.

En una línea public sealed interface EventoPedido permits PedidoCreado, PedidoEnviado, PedidoCancelado {}

Eventos de dominio dentro de un servicio java
public record TareaCompletada(long tareaId, String responsable, Instant cuando) {}

@Service
class CompletarTareaService {
    private final TareaRepository repo;
    private final ApplicationEventPublisher eventos;
    private final Clock reloj;

    CompletarTareaService(TareaRepository repo, ApplicationEventPublisher eventos, Clock reloj) {
        this.repo = repo;
        this.eventos = eventos;
        this.reloj = reloj;
    }

    @Transactional
    public void completar(long id) {
        var tarea = repo.findById(id).orElseThrow();
        tarea.completar(reloj);
        eventos.publishEvent(new TareaCompletada(id, tarea.getResponsable(), Instant.now(reloj)));
    }
}

@Component
class AlCompletarTarea {
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)  // solo si el commit fue bien
    void notificar(TareaCompletada evento) {
        notificador.avisar(evento.responsable(), "Tarea " + evento.tareaId() + " completada");
    }
}
Cómo funciona por dentro

En una arquitectura orientada a eventos, los servicios se comunican anunciando hechos que han ocurrido (TareaCompletada, PagoRechazado) en lugar de llamarse directamente. El emisor no sabe ni le importa quién escucha. Esto reduce el acoplamiento temporal (el consumidor puede estar caído y procesar más tarde) y permite añadir comportamientos nuevos suscribiéndose a eventos existentes.

Hay dos estilos. En la notificación de eventos, el evento lleva lo mínimo (el id) y quien lo necesite consulta los detalles. En la transferencia de estado, el evento lleva los datos relevantes y los consumidores mantienen su propia copia, sin llamadas de vuelta.

El precio: el flujo de negocio queda repartido entre servicios, es más difícil de seguir y depurar (hace falta trazabilidad distribuida), y los datos son eventualmente consistentes. Dentro de un mismo servicio, Spring ofrece eventos en memoria con ApplicationEventPublisher y @TransactionalEventListener.

Desacopla completamente los servicios: el emisor no conoce ni depende de los consumidores. Permite añadir nuevos servicios sin modificar los existentes.

Se conecta con DesacoplamientoMicroserviciosKafka/RabbitMQ

Cuatro bloques cronometrados. No pases al siguiente sin tener el resultado esperado.

  1. Bloque 1: Fundamentos de EDA y Modelado de Eventos (0 - 25 min)

    Qué estudiar. Diferencia entre Evento y Comando. Diseño de eventos como records inmutables con sealed interfaces.

    Pregunta a responder. ¿Por qué los eventos se nombran en participio pasado?

    Resultado esperado. Jerarquía de eventos: sealed interface EventoPedido permits PedidoCreado, PagoConfirmado, PedidoEnviado.

  2. Bloque 2: Contratos de Eventos e Idempotencia (25 - 50 min)

    Qué estudiar. Eventos de dominio frente a eventos de integración, y consumidores idempotentes.

    Pregunta a responder. ¿Qué pasa en tu consumidor si el mismo evento llega dos veces?

    Resultado esperado. Evento de integración PedidoConfirmadoV1 y un consumidor que ignora los duplicados con una tabla de eventos procesados.

  3. Bloque 3: Outbox Pattern y Consistencia Eventual (50 - 80 min)

    Qué estudiar. Tabla outbox, polling vs CDC con Debezium, idempotencia de consumidores.

    Pregunta a responder. ¿Cómo evitar publicar eventos duplicados al releer la tabla outbox?

    Resultado esperado. Diseño de una tabla outbox_events con columnas: id, aggregate_type, aggregate_id, payload, published_at.

  4. Cierre de Consolidación (80 - 90 min)

    Qué estudiar. Repaso de la rúbrica del tutor.

    Pregunta a responder. Explicar por qué guardar y publicar en el mismo método @Transactional no es atómico.

    Resultado esperado. Checklist de publicación fiable en tus notas: outbox, contratos versionados e idempotencia.

Tres formas de comprobar si lo has entendido, de menos a más exigente: explicarlo con tus palabras, responder preguntas trampa y resolver el reto del tutor.

Explícalo desde cero

Lee cada ficha y explícala en voz alta antes de seguir: si puedes contárselo a alguien que empieza, lo has entendido.

La diferencia entre un Evento y un Comando: hechos vs órdenes

Lo que debes entender

Un Comando es una intención imperativa de que algo ocurra: "Crear Pedido". Puede ser rechazado si las validaciones fallan. Un Evento es un hecho inmutable que YA ocurrió en el pasado: "Pedido Creado". Nunca puede ser rechazado porque ya sucedió; solo se puede reaccionar a él o compensarlo publicando otro evento.

Analogía para contarlo

Un comando es como pedirle a un camarero "ponme una cerveza" (puede decir que no hay). Un evento es como la factura que dice "se ha servido una cerveza a las 15:32"; ya ocurrió.

Lo que suele explicarse mal

Nombrar eventos como si fueran comandos ("CrearUsuarioEvent") en vez de hechos ("UsuarioCreado").

Señal de que aún no lo dominas

Si tus eventos tienen verbos en infinitivo o imperativo en vez de participio pasado.

Preguntas trampa

Responde de memoria, en voz alta o por escrito, y después abre la respuesta razonada: lo importante es el porqué, no la palabra exacta.

Nivel 1 (Básico) ¿Por qué los eventos de dominio deben ser inmutables y en tiempo pasado? Ver respuesta

Respuesta razonada

Porque un evento describe un hecho que ya ocurrió, y el pasado no se puede cambiar. TareaCompletada afirma algo verdadero en el momento en que se emitió; los consumidores pueden reaccionar, pero no rechazarlo ni modificarlo. Si hubo un error, se corrige con otro evento (TareaReabierta), no editando el anterior. Esa inmutabilidad permite repetir, auditar y reprocesar eventos con seguridad.

El tiempo pasado también los distingue de los comandos: CompletarTarea es una petición en imperativo que puede ser rechazada si incumple una regla; TareaCompletada es la consecuencia una vez aceptada. Mezclar ambos conceptos lleva a sistemas en los que no está claro quién decide.

Qué está evaluando la pregunta

Un evento registra algo que ya pasó y no puede deshacerse. Si se mutan o renombran eventos históricos, se pierde la trazabilidad y se corrompe la auditoría. Inmutabilidad + pasado = confiabilidad del log.

Nivel 2 (Intermedio) Un consumidor recibe dos veces el evento PagoConfirmado del mismo pedido. ¿Por qué ocurre y cómo evitas que el pedido se envíe dos veces? Ver respuesta

Respuesta razonada

Porque la entrega es al menos una vez. El consumidor procesó el mensaje, pero antes de confirmar el offset (en Kafka) o el ack (en RabbitMQ) se cayó, hubo un rebalanceo o venció un timeout, y el broker lo entregó de nuevo. También puede venir duplicado del lado del productor, por ejemplo de un relay de outbox que publicó y cayó antes de marcar el evento como enviado.

No se evita en el broker: se neutraliza en el consumidor. Lo habitual es registrar el eventId en una tabla con clave primaria dentro de la misma transacción que el efecto y descartar el mensaje si ya estaba, o escribir el efecto para que repetirlo no cambie nada (update ... where estado = 'CONFIRMADO'). Para efectos externos, como el envío o un cobro, se pasa además una clave de idempotencia al servicio externo.

Qué está evaluando la pregunta

Ocurre porque la entrega es al menos una vez: si el consumidor procesa el mensaje y cae antes de confirmar el offset (o el ack), el broker lo vuelve a entregar. Se evita con un consumidor idempotente: registrar el id del evento en la misma transacción que el efecto, o escribir el efecto para que repetirlo no cambie nada (update ... where estado = 'CONFIRMADO').

Nivel 3 (Entrevista Senior) ¿Cómo garantizas atomicidad entre guardar en tu base de datos local y publicar un evento en Kafka sin usar transacciones distribuidas (2PC)? Ver respuesta

Respuesta razonada

Con el patrón Transactional Outbox. En la misma transacción local en la que guardas los datos de negocio, insertas el evento en una tabla outbox de la misma base de datos. Ambas escrituras son atómicas porque las protege una única transacción ACID. Un proceso separado lee esa tabla y publica en Kafka, marcando cada mensaje como enviado tras recibir la confirmación del broker.

El relay puede ser un @Scheduled con SELECT ... FOR UPDATE SKIP LOCKED o un conector CDC como Debezium, que lee el WAL de PostgreSQL. Como el relay puede fallar entre publicar y marcar, la entrega es «al menos una vez», y los consumidores deben deduplicar usando un id de evento. Una alternativa es el patrón «escúchate a ti mismo»: publicar solo en Kafka y que el propio servicio consuma el evento para actualizar su base de datos.

Qué está evaluando la pregunta

El Transactional Outbox Pattern: se guarda el evento como fila en una tabla outbox_events en la misma transacción local de la BD. Un proceso secundario (polling o CDC con Debezium) lee esa tabla y publica los eventos al broker. Así la consistencia está garantizada por la transacción ACID local.

Reto del tutor

En un servicio, confirmar() hace pedidoRepository.save(pedido) y justo después kafkaTemplate.send("pedidos", evento), todo dentro de un método @Transactional. El equipo dice que así el evento y el cambio van juntos. Evalúa la afirmación.

Lo que una respuesta suele tener bien

Quieren que el evento y el cambio en la base de datos sean coherentes.

Lo que suele quedarse en la superficie

Decir «hay que usar Kafka transaccional» sin explicar qué cubre.

El concepto de fondo que suele faltar

@Transactional solo cubre la base de datos: el envío a Kafka no participa. Si el commit falla después de enviar, el evento anuncia algo que no ocurrió; si Kafka falla o el proceso cae tras el commit, el cambio se queda sin evento.

Cómo lo respondería alguien senior en una entrevista

Patrón Outbox: guardar el evento en la tabla outbox en la misma transacción que el pedido y publicarlo después con un relay (polling o CDC con Debezium), con consumidores idempotentes porque el relay puede publicar dos veces. Las transacciones de Kafka solo dan «exactamente una vez» dentro de Kafka (leer, procesar y escribir en topics), no entre la base de datos y Kafka.

Proyecto «Pedidos» · paso 24 de 30

Eventos fiables con Outbox

Publicar los eventos de pedido sin perder ninguno y sin que un duplicado cause daños.

De qué va el proyecto

El backend de una tienda online: clientes, catálogo de productos y pedidos con sus líneas, pagos y envíos. Empieza como un proyecto Maven con records y termina como un sistema con API REST, seguridad, mensajería, contenedores y observabilidad. Cada paso construye sobre el anterior, así que trabaja siempre en el mismo repositorio.

Qué hacer

  1. Define el evento de integración PedidoConfirmadoV1 como contrato propio, separado del evento interno.
  2. Guarda los eventos en una tabla outbox en la misma transacción que el cambio del pedido, y publícalos con un proceso aparte que los marca como enviados.
  3. Haz idempotente el consumidor de notificaciones con una tabla de eventos procesados.

Está listo cuando

  • Si Kafka no está disponible al confirmar, el evento se publica más tarde y no se pierde.
  • Reenviar el mismo evento dos veces no manda dos notificaciones.

Katas: ejercicios cortos

Reglas de oro

  • Nombra eventos como hechos en participio pasado: PedidoCreado, PagoConfirmado, FacturaEmitida.
  • Nunca publiques un evento a Kafka/RabbitMQ ANTES de hacer commit en tu base de datos; usa el Outbox Pattern.
  • Diseña consumidores de eventos siempre idempotentes: procesar el mismo evento dos veces debe ser seguro.
  • La consistencia eventual requiere que la UI tolere lecturas "ligeramente desactualizadas" tras una escritura.

Java 17 frente a Java 21

CaracterísticaJava 17 LTSJava 21 LTS
Modelado de Eventos sealed interface + record sealed interface + record + switch exhaustivo
Pattern Matching en Eventos instanceof con cast Record Patterns: case PedidoCreado(var id, var items)
Se guarda automáticamente

Fuentes oficiales

Revisado el . Este día en Markdown