# Día 24: Arquitectura Orientada a Eventos y Patrón Outbox

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

- Plan: [JavaMastery](https://aprende-java.webs-economicas.es/), día 24 de 30
- Fase 6: Mensajería Asíncrona con Kafka, RabbitMQ, EDA y CQRS
- Dedicación: 3.5h
- Profundidad: visión de conjunto
- Revisado: 2026-10-29
- Versión interactiva: https://aprende-java.webs-economicas.es/dias/arquitectura-eventos-outbox/

## Al terminar este día sabrás

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


## Conceptos

### 1. 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.

**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`.

*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");
    }
}
```

- **En una línea:** `public sealed interface EventoPedido permits PedidoCreado, PedidoEnviado, PedidoCancelado {}`
- **Por qué importa:** Desacopla completamente los servicios: el emisor no conoce ni depende de los consumidores. Permite añadir nuevos servicios sin modificar los existentes.
- **Error típico:** Modelar eventos como comandos imperativos ("CrearFactura") en lugar de hechos pasados ("PedidoCreado"). Un evento describe lo que YA ocurrió, no lo que debe ocurrir.
- **Java 17 frente a 21:** Patrón arquitectónico independiente de versión.
- **Se conecta con:** Desacoplamiento, Microservicios, Kafka/RabbitMQ

### 2. Eventos de Dominio frente a Eventos de Integración

Un evento de dominio expresa un hecho dentro de un servicio y puede llevar detalles internos; un evento de integración es el contrato público que consumen otros servicios. El de integración se diseña como una API: estable, versionado y con solo los datos que necesitan los demás.

**Cómo funciona por dentro.**

Dentro de un servicio, un evento de dominio (`PedidoConfirmado`) es un objeto que escuchan otras partes del mismo código, por ejemplo con `ApplicationEventPublisher` de Spring. Puede llevar objetos del dominio, porque vive y muere dentro del mismo despliegue y cambia junto con el código que lo usa.

Cuando el hecho tiene que salir del servicio, se convierte en un evento de integración: un mensaje en Kafka o RabbitMQ que leerán otros equipos, con otro ritmo de despliegue y quizá en otro lenguaje. Desde ese momento es un contrato público, igual que una API REST: debe ser estable, estar documentado y llevar versión (`pedidos.confirmados.v1`, o un campo `version` con un registro de esquemas como Avro o JSON Schema). Lleva solo lo que los consumidores necesitan y con tipos neutros: ids como texto, importes como decimal con su moneda, fechas en ISO-8601 y UTC.

La traducción de un tipo a otro ocurre en un único sitio, normalmente el que escribe en la outbox. Así puedes refactorizar el dominio sin romper a nadie, y cuando el contrato tiene que cambiar de forma incompatible publicas una `v2` en paralelo hasta que todos los consumidores migren.

*Evento interno y contrato público*

```java
// Evento de dominio: interno, puede usar tipos del dominio
public record PedidoConfirmado(Pedido pedido, Instant cuando) {}

// Evento de integración: contrato público, plano y versionado
public record PedidoConfirmadoV1(
        String eventId,
        String pedidoId,
        String clienteId,
        String total,          // "149.90"
        String moneda,         // "EUR"
        String confirmadoEn    // ISO-8601 en UTC
) {}

@Component
class TraductorEventosPedido {
    private final Outbox outbox;

    TraductorEventosPedido(Outbox outbox) { this.outbox = outbox; }

    @EventListener   // síncrono: se ejecuta dentro de la transacción de confirmar()
    void alConfirmar(PedidoConfirmado e) {
        var p = e.pedido();
        outbox.guardar("pedidos.confirmados.v1", p.id().toString(), new PedidoConfirmadoV1(
            UUID.randomUUID().toString(),
            p.id().toString(),
            p.cliente().id().toString(),
            p.total().toPlainString(),
            "EUR",
            e.cuando().toString()));
    }
}
```

- **En una línea:** `record PedidoConfirmadoV1(String eventId, String pedidoId, String total, String moneda)` publicado en `pedidos.confirmados.v1`.
- **Por qué importa:** Publicar los eventos internos tal cual acopla a todos los consumidores a tu modelo: cualquier refactor rompe servicios ajenos.
- **Error típico:** Serializar la entidad JPA completa como evento: arrastra relaciones y campos internos, y cambia con cada migración.
- **Java 17 frente a 21:** Igual en Java 17 y 21; los records son el formato natural para los dos tipos de evento.
- **Se conecta con:** Contratos de eventos, Versionado de esquemas, Outbox Pattern

### 3. El Transactional Outbox Pattern: Consistencia entre BD y Mensajería

Cuando un servicio debe guardar datos en su base de datos Y publicar un evento en Kafka/RabbitMQ, no puede hacerlo en una transacción distribuida atómica. El Outbox Pattern resuelve esto guardando el evento como una fila en una tabla `outbox_events` dentro de la MISMA transacción de base de datos. Un proceso secundario (polling o CDC con Debezium) lee la tabla outbox y publica los eventos al broker.

**Cómo funciona por dentro.**

El problema de la doble escritura: guardas una tarea en PostgreSQL y publicas un evento en Kafka. Si haces commit y la publicación falla, la base de datos dice que pasó algo que nadie más sabrá nunca. Si publicas primero y el commit falla, anuncias algo que no ocurrió. No hay transacción que abarque ambos sistemas sin recurrir a 2PC, que Kafka no soporta y que no escala.

El patrón Outbox lo resuelve con la base de datos local: en la misma transacción que modifica los datos de negocio, insertas el evento en una tabla `outbox`. O se guardan ambos o ninguno. Después, un proceso independiente (relay) lee la tabla y publica en Kafka, marcando o borrando lo publicado.

El relay puede consultar la tabla periódicamente (polling, simple) o leer el log de transacciones de la base de datos con CDC (Debezium), que tiene menos latencia y carga. En ambos casos la entrega es al menos una vez, así que los consumidores deben ser idempotentes.

*Outbox con relay por polling*

```java
@Service
class CompletarTareaService {
    @Transactional                                          // UNA transacción local
    public void completar(long id) {
        var tarea = repo.findById(id).orElseThrow();
        tarea.completar(reloj);
        outbox.save(new OutboxMensaje(                      // misma BD, mismo commit
            UUID.randomUUID(), "tareas.eventos", String.valueOf(id),
            json.escribir(new TareaCompletada(id, tarea.getResponsable(), Instant.now(reloj)))));
    }
}

@Component
class OutboxRelay {
    @Scheduled(fixedDelay = 500)
    @Transactional
    public void publicarPendientes() {
        // FOR UPDATE SKIP LOCKED: varias instancias pueden ejecutar el relay sin pisarse
        List<OutboxMensaje> lote = outbox.bloquearPendientes(100);
        for (var m : lote) {
            kafka.send(m.topic(), m.clave(), m.payload()).join();   // espera la confirmación
            m.marcarPublicado();
        }
    }
}

// CREATE TABLE outbox (
//   id UUID PRIMARY KEY, topic TEXT NOT NULL, clave TEXT, payload JSONB NOT NULL,
//   creado_en TIMESTAMPTZ NOT NULL DEFAULT now(), publicado_en TIMESTAMPTZ
// );
```

- **En una línea:** `@Transactional void crearPedido(Pedido p) { pedidoRepo.save(p); outboxRepo.save(new OutboxEvent("PedidoCreado", toJson(p))); }`
- **Por qué importa:** Garantiza que nunca se pierde un evento ni se publica un evento sin que la operación de base de datos se haya confirmado.
- **Error típico:** Publicar el evento en Kafka ANTES de hacer commit en la base de datos; si el commit falla, el evento ya fue publicado y el sistema queda inconsistente.
- **Java 17 frente a 21:** Independiente de versión.
- **Se conecta con:** Consistencia Eventual, Debezium CDC, Transacciones Locales

### 4. Consumidores Idempotentes y Entrega «Al Menos Una Vez»

Kafka y RabbitMQ entregan normalmente al menos una vez: tras un reintento, un rebalanceo o una caída antes de confirmar el offset o el ack, el mismo mensaje puede llegar dos veces. Un consumidor idempotente produce el mismo resultado lo procese una vez o varias.

**Cómo funciona por dentro.**

Los brokers garantizan, por defecto, entrega al menos una vez. En Kafka, el consumidor confirma su posición (offset) después de procesar; si se cae entre procesar y confirmar, o si un rebalanceo reasigna la partición, el siguiente consumidor relee desde el último offset confirmado y procesa otra vez algunos mensajes. En RabbitMQ pasa lo mismo con un mensaje procesado cuyo ack no llegó. Y también hay duplicados del productor: un relay de outbox que publica y cae antes de marcar el evento como enviado lo publicará de nuevo.

Como los duplicados son inevitables, el consumidor tiene que ser idempotente. Hay dos estrategias. La primera es registrar cada `eventId` procesado en una tabla con clave primaria, en la misma transacción que el efecto: si el id ya estaba, el evento se descarta. La segunda es escribir el efecto para que repetirlo no cambie nada: `update pedidos set estado = 'PAGADO' where id = ? and estado = 'CONFIRMADO'` no hace nada la segunda vez.

Kafka ofrece productores idempotentes y transacciones que dan «exactamente una vez» dentro de Kafka (leer de un topic, procesar y escribir en otro). Pero en cuanto el efecto sale de Kafka (una base de datos, un email, una llamada HTTP), la idempotencia vuelve a ser responsabilidad tuya.

*Consumidor que ignora los duplicados*

```java
@Component
class PagosListener {
    private final JdbcTemplate jdbc;
    private final PedidoService pedidos;

    PagosListener(JdbcTemplate jdbc, PedidoService pedidos) {
        this.jdbc = jdbc;
        this.pedidos = pedidos;
    }

    @KafkaListener(topics = "pagos.confirmados.v1", groupId = "pedidos")
    @Transactional
    public void alConfirmarPago(PagoConfirmadoV1 evento) {
        // PostgreSQL: si el event_id ya estaba, no inserta nada y devuelve 0
        int nuevos = jdbc.update("""
            insert into eventos_procesados (event_id, procesado_en)
            values (?, now())
            on conflict (event_id) do nothing""", evento.eventId());
        if (nuevos == 0) {
            return;                                    // duplicado: el efecto ya se aplicó
        }
        pedidos.marcarPagado(evento.pedidoId());       // misma transacción que el registro
    }
}
```

- **En una línea:** Guardar el `eventId` en una tabla `eventos_procesados` con clave primaria y descartar el mensaje si ya estaba.
- **Por qué importa:** Sin idempotencia, un mensaje repetido cobra dos veces, envía dos emails o descuenta stock de más.
- **Error típico:** Confiar en el «exactamente una vez» de extremo a extremo: las transacciones de Kafka no cubren efectos externos como una base de datos o un email.
- **Java 17 frente a 21:** Igual en Java 17 y 21.
- **Se conecta con:** Outbox Pattern, Tabla de eventos procesados, Claves de idempotencia

### 5. Consistencia Eventual vs Consistencia Fuerte en Arquitecturas Distribuidas

En un sistema distribuido con EDA, los servicios NO están sincronizados en tiempo real. Cuando el servicio de pedidos publica un evento, el servicio de inventario puede tardar milisegundos o segundos en procesarlo. Durante ese intervalo, las dos bases de datos tienen datos temporalmente inconsistentes (consistencia eventual). El sistema converge a un estado consistente en un período breve.

**Cómo funciona por dentro.**

Consistencia fuerte significa que, tras confirmar una escritura, cualquier lectura posterior la ve. Es lo que da una base de datos relacional dentro de una transacción. Consistencia eventual significa que, si no hay nuevas escrituras, todas las copias acabarán convergiendo, pero durante un tiempo una lectura puede devolver datos antiguos.

En sistemas distribuidos, el teorema CAP (y su extensión PACELC) recuerda que ante particiones de red hay que elegir entre disponibilidad y consistencia, y que incluso sin fallos hay un compromiso entre latencia y consistencia. Las arquitecturas basadas en eventos eligen casi siempre consistencia eventual entre servicios a cambio de disponibilidad y desacoplamiento.

La clave es decidir dónde se necesita cada una: las invariantes críticas (no gastar más saldo del que hay) deben protegerse con consistencia fuerte dentro de un único agregado y una única transacción. Entre agregados o servicios se acepta la eventual, y la interfaz se diseña para ello: confirmar «recibido», mostrar estados intermedios o leer tus propias escrituras desde el modelo de escritura.

*Diseñar la API para la consistencia eventual*

```java
// El comando se acepta, pero el resultado se procesa de forma asíncrona
@PostMapping("/api/pedidos")
ResponseEntity<PedidoAceptado> crear(@RequestBody @Valid CrearPedido req) {
    var id = pedidos.aceptar(req);                         // guarda en estado PENDIENTE + outbox
    return ResponseEntity
        .accepted()                                        // 202: aceptado, aún no terminado
        .location(URI.create("/api/pedidos/" + id))
        .body(new PedidoAceptado(id, "PENDIENTE"));
}

// El cliente consulta el estado (o recibe una notificación por SSE/WebSocket)
@GetMapping("/api/pedidos/{id}")
PedidoResponse estado(@PathVariable UUID id) {
    // Leer del modelo de escritura garantiza ver tus propias escrituras,
    // aunque la proyección de lectura aún no se haya actualizado.
    return PedidoResponse.desde(pedidos.obtener(id));
}
```

- **En una línea:** `Tras crear el pedido, redirige a una página de confirmación con los datos del comando recién ejecutado, no del lado de consulta.`
- **Por qué importa:** Aceptar la consistencia eventual permite construir sistemas altamente escalables y resilientes, pero requiere diseñar las interfaces de usuario para tolerar lecturas "ligeramente desactualizadas".
- **Error típico:** Diseñar una interfaz de usuario que inmediatamente después de crear un pedido muestre una lista actualizada consultando el lado de lectura que aún no ha procesado el evento (mostrando datos obsoletos).
- **Java 17 frente a 21:** Concepto arquitectónico universal.
- **Se conecta con:** Teorema CAP, Idempotencia, Compensación


## Sesión de 90 minutos

### 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?
- **Entregable:** Jerarquía de eventos: sealed interface EventoPedido permits PedidoCreado, PagoConfirmado, PedidoEnviado.

### 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?
- **Entregable:** Evento de integración `PedidoConfirmadoV1` y un consumidor que ignora los duplicados con una tabla de eventos procesados.

### 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?
- **Entregable:** Diseño de una tabla outbox_events con columnas: id, aggregate_type, aggregate_id, payload, published_at.

### 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.
- **Entregable:** Checklist de publicación fiable en tus notas: outbox, contratos versionados e idempotencia.


## Explícalo desde cero

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

- **Qué 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.
- **Qué suele explicarse mal:** Nombrar eventos como si fueran comandos ("CrearUsuarioEvent") en vez de hechos ("UsuarioCreado").
- **Ejemplo sencillo:** 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ó.
- **Señal de alerta:** Si tus eventos tienen verbos en infinitivo o imperativo en vez de participio pasado.


## Preguntas

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

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.

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

### 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?

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

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

### 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)?

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.

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


## Reto del tutor

**Reto:** 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 está bien:** Quieren que el evento y el cambio en la base de datos sean coherentes.
- **Respuesta superficial:** Decir «hay que usar Kafka transaccional» sin explicar qué cubre.
- **Lo que falta de fondo:** `@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.
- **Respuesta senior:** 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.


## Katas

### Kata 24.1: Modelado de Eventos de Dominio con sealed interface y records

Crea una sealed interface `EventoPedido` con 3 records que la implementen: `PedidoCreado(UUID id, List<Item> items, Instant timestamp)`, `PagoConfirmado(UUID pedidoId, BigDecimal monto)` y `PedidoCancelado(UUID pedidoId, String motivo)`. Escribe un método que haga switch exhaustivo sobre los eventos.

### Kata 24.2: Consumidor idempotente

Escribe un consumidor de `PagoConfirmado` que guarde el `eventId` en una tabla con clave primaria dentro de la misma transacción que el cambio de estado. Demuestra con un test que procesar el mismo evento dos veces deja el pedido igual y no repite el envío.


## 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ística | Java 17 | Java 21 |
| --- | --- | --- |
| 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) |


## Proyecto guiado «Pedidos»: Eventos fiables con Outbox

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

**Tareas:**

- Define el evento de integración `PedidoConfirmadoV1` como contrato propio, separado del evento interno.
- 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.
- 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.


## Fuentes oficiales

- [Martin Fowler: What do you mean by "Event-Driven"?](https://martinfowler.com/articles/201701-event-driven.html)
- [Patrón Transactional Outbox](https://microservices.io/patterns/data/transactional-outbox.html)
- [Debezium: Outbox Event Router](https://debezium.io/documentation/reference/stable/transformations/outbox-event-router.html)

