# Event-Driven — Implementaciones Completas de Referencia

Ejemplos completos de código Python para arquitectura event-driven.

---

## RabbitMQ — Pub/Sub con Python (aio_pika)

```python
# pip install aio-pika
import aio_pika
import asyncio
import json
from dataclasses import dataclass, asdict
from datetime import datetime, timezone
from typing import Any, Callable, Awaitable
import uuid

@dataclass
class Evento:
    tipo: str
    payload: dict
    id: str = ""
    timestamp: str = ""
    correlation_id: str = ""
    version: str = "1.0"

    def __post_init__(self):
        if not self.id:
            self.id = str(uuid.uuid4())
        if not self.timestamp:
            self.timestamp = datetime.now(timezone.utc).isoformat()

class BrokerEventos:
    """Wrapper sobre aio_pika para publicar y consumir eventos."""

    def __init__(self, url: str):
        self._url = url
        self._conexion: aio_pika.RobustConnection | None = None
        self._canal: aio_pika.abc.AbstractChannel | None = None

    async def conectar(self) -> None:
        self._conexion = await aio_pika.connect_robust(
            self._url,
            heartbeat=60,
            reconnect_interval=5.0,
        )
        self._canal = await self._conexion.channel()
        await self._canal.set_qos(prefetch_count=10)

    async def publicar(
        self,
        evento: Evento,
        exchange: str = "eventos.rrhh",
        routing_key: str | None = None,
    ) -> None:
        assert self._canal, "Llamar connect() primero"
        exchange_obj = await self._canal.declare_exchange(
            exchange, aio_pika.ExchangeType.TOPIC, durable=True,
        )
        await exchange_obj.publish(
            aio_pika.Message(
                body=json.dumps(asdict(evento), ensure_ascii=False).encode(),
                content_type="application/json",
                delivery_mode=aio_pika.DeliveryMode.PERSISTENT,
                message_id=evento.id,
                timestamp=datetime.now(timezone.utc),
                headers={
                    "x-correlation-id": evento.correlation_id,
                    "x-event-version": evento.version,
                },
            ),
            routing_key=routing_key or evento.tipo,
        )

    async def suscribir(
        self,
        cola: str,
        exchange: str,
        routing_keys: list[str],
        handler: Callable[[Evento], Awaitable[None]],
    ) -> None:
        assert self._canal
        dlq_nombre = f"{cola}.dlq"
        await self._canal.declare_queue(dlq_nombre, durable=True)
        queue = await self._canal.declare_queue(
            cola, durable=True,
            arguments={
                "x-dead-letter-exchange": "",
                "x-dead-letter-routing-key": dlq_nombre,
                "x-message-ttl": 86400000,
            },
        )
        exchange_obj = await self._canal.declare_exchange(
            exchange, aio_pika.ExchangeType.TOPIC, durable=True
        )
        for routing_key in routing_keys:
            await queue.bind(exchange_obj, routing_key=routing_key)

        async def procesar_mensaje(mensaje: aio_pika.IncomingMessage) -> None:
            async with mensaje.process(requeue=False):
                try:
                    datos = json.loads(mensaje.body)
                    evento = Evento(**datos)
                    await handler(evento)
                except Exception as e:
                    logger.error("Error procesando evento %s: %s", mensaje.message_id, e)
                    raise

        await queue.consume(procesar_mensaje)
```

---

## Kafka — Streaming de Alta Escala

```python
# pip install aiokafka
from aiokafka import AIOKafkaProducer, AIOKafkaConsumer
import asyncio
import json

class ProductorKafka:
    def __init__(self, bootstrap_servers: str):
        self._producer = AIOKafkaProducer(
            bootstrap_servers=bootstrap_servers,
            enable_idempotence=True,
            max_batch_size=65536,
            compression_type="gzip",
            value_serializer=lambda v: json.dumps(v, ensure_ascii=False).encode(),
            key_serializer=lambda k: k.encode() if k else None,
        )

    async def __aenter__(self) -> "ProductorKafka":
        await self._producer.start()
        return self

    async def __aexit__(self, *args) -> None:
        await self._producer.stop()

    async def enviar(self, topico: str, evento: dict, clave: str | None = None) -> None:
        await self._producer.send_and_wait(
            topico, value=evento, key=clave,
            headers=[
                ("content-type", b"application/json"),
                ("event-id", evento.get("id", "").encode()),
            ],
        )

class ConsumidorKafka:
    def __init__(self, bootstrap_servers: str, group_id: str, topicos: list[str]):
        self._consumer = AIOKafkaConsumer(
            *topicos,
            bootstrap_servers=bootstrap_servers,
            group_id=group_id,
            auto_offset_reset="earliest",
            enable_auto_commit=False,
            max_poll_records=100,
            session_timeout_ms=30000,
        )

    async def consumir(self, handler) -> None:
        await self._consumer.start()
        try:
            async for mensaje in self._consumer:
                try:
                    datos = json.loads(mensaje.value)
                    await handler(datos)
                    await self._consumer.commit()
                except Exception as e:
                    logger.error(
                        "Error en topico=%s partition=%s offset=%s: %s",
                        mensaje.topic, mensaje.partition, mensaje.offset, e,
                    )
        finally:
            await self._consumer.stop()
```

---

## Schemas de Eventos con Pydantic

```python
from pydantic import BaseModel
from datetime import datetime
from typing import Literal
import uuid

class EventoBase(BaseModel):
    id: str = ""
    timestamp: datetime = None
    version: str = "1.0"
    correlation_id: str = ""
    origen: str

    def model_post_init(self, *args):
        if not self.id:
            self.id = str(uuid.uuid4())
        if not self.timestamp:
            self.timestamp = datetime.now(timezone.utc)

class EmpleadoCreadoPayload(BaseModel):
    empleado_id: str
    numero_empleado: str
    nombre_completo: str
    email_corporativo: str
    departamento_id: str
    puesto: str
    fecha_ingreso: str

class EventoEmpleadoCreado(EventoBase):
    tipo: Literal["empleado.creado"] = "empleado.creado"
    payload: EmpleadoCreadoPayload

# Versioning — NUNCA romper compatibilidad hacia atrás
class EmpleadoCreadoPayloadV2(EmpleadoCreadoPayload):
    nss: str | None = None
    curp: str | None = None
```

---

## Idempotency — Registro con Redis

```python
class RegistroIdempotencia:
    def __init__(self, redis_client):
        self._redis = redis_client
        self._ttl = 86400

    async def ya_procesado(self, evento_id: str, consumer_id: str) -> bool:
        clave = f"idempotencia:{consumer_id}:{evento_id}"
        return bool(await self._redis.exists(clave))

    async def marcar_procesado(self, evento_id: str, consumer_id: str) -> None:
        clave = f"idempotencia:{consumer_id}:{evento_id}"
        await self._redis.setex(clave, self._ttl, "1")

class HandlerNuevaAlta:
    def __init__(self, repo, idempotencia: RegistroIdempotencia):
        self._repo = repo
        self._idempotencia = idempotencia

    async def manejar(self, evento: EventoEmpleadoCreado) -> None:
        consumer_id = "handler.nueva_alta.configurar_nomina"
        if await self._idempotencia.ya_procesado(evento.id, consumer_id):
            logger.info("Evento %s ya procesado, ignorando", evento.id)
            return
        await self._repo.configurar_nomina(evento.payload)
        await self._idempotencia.marcar_procesado(evento.id, consumer_id)
```

---

## Retry con Backoff Exponencial

```python
class PoliticaReintento:
    def __init__(self, max_intentos=5, delay_inicial=1.0, delay_maximo=60.0, multiplicador=2.0):
        self.max_intentos = max_intentos
        self.delay_inicial = delay_inicial
        self.delay_maximo = delay_maximo
        self.multiplicador = multiplicador

    def delay_para_intento(self, intento: int) -> float:
        import random
        delay = min(
            self.delay_inicial * (self.multiplicador ** (intento - 1)),
            self.delay_maximo,
        )
        jitter = delay * 0.25 * (2 * random.random() - 1)
        return delay + jitter

class ProcesadorDLQ:
    async def analizar_dlq(self, cola_dlq: str) -> list[dict]:
        mensajes = []
        async for mensaje in self._consumer.consumir_dlq(cola_dlq):
            mensajes.append({
                "id": mensaje.message_id,
                "timestamp_original": mensaje.headers.get("x-first-death-time"),
                "motivo_fallo": mensaje.headers.get("x-death")[0]["reason"],
                "intentos": mensaje.headers.get("x-death")[0]["count"],
                "cuerpo": json.loads(mensaje.body),
            })
        return mensajes
```

---

## CQRS — Command Query Responsibility Segregation

```python
# COMMAND SIDE
class ComandoCrearEmpleado(BaseModel):
    nombre: str
    apellido_paterno: str
    apellido_materno: str
    email: str
    departamento_id: str

class HandlerComandoCrearEmpleado:
    async def manejar(self, comando, db, broker) -> str:
        empleado = Empleado(**comando.model_dump())
        db.add(empleado)
        await db.flush()
        evento = EventoEmpleadoCreado(
            origen="servicio-empleados",
            payload=EmpleadoCreadoPayload(
                empleado_id=str(empleado.id),
                nombre_completo=f"{comando.nombre} {comando.apellido_paterno}",
                email_corporativo=comando.email,
                departamento_id=comando.departamento_id,
                numero_empleado=empleado.numero_empleado,
                puesto=empleado.puesto,
                fecha_ingreso=empleado.fecha_ingreso.isoformat(),
            ),
        )
        await broker.publicar(evento)
        await db.commit()
        return str(empleado.id)

# QUERY SIDE
class VistaEmpleado(Base):
    __tablename__ = "vista_empleados"
    id = Column(UUID, primary_key=True)
    nombre_completo = Column(String)
    email = Column(String)
    departamento_nombre = Column(String)
    puesto = Column(String)
    activo = Column(Boolean, default=True)
    ultima_actualizacion = Column(DateTime)

class ActualizadorVistaEmpleados:
    async def en_empleado_creado(self, evento) -> None:
        departamento = await self._repo.obtener_departamento(evento.payload.departamento_id)
        vista = VistaEmpleado(
            id=uuid.UUID(evento.payload.empleado_id),
            nombre_completo=evento.payload.nombre_completo,
            email=evento.payload.email_corporativo,
            departamento_nombre=departamento.nombre,
            puesto=evento.payload.puesto,
            ultima_actualizacion=evento.timestamp,
        )
        await self._db.merge(vista)
        await self._db.commit()
```

---

## Transactional Outbox Pattern

```python
class EventoOutbox(Base):
    __tablename__ = "eventos_outbox"
    id = Column(UUID, primary_key=True, default=uuid.uuid4)
    tipo = Column(String(100), nullable=False)
    payload = Column(JSONB, nullable=False)
    exchange = Column(String(100), nullable=False)
    routing_key = Column(String(100), nullable=False)
    creado_en = Column(DateTime(timezone=True), server_default=func.now())
    publicado_en = Column(DateTime(timezone=True), nullable=True)
    intentos = Column(Integer, default=0)

async def crear_empleado_con_outbox(comando, db) -> str:
    empleado = Empleado(**comando.model_dump())
    db.add(empleado)
    await db.flush()
    evento_outbox = EventoOutbox(
        tipo="empleado.creado",
        payload={...},
        exchange="eventos.rrhh",
        routing_key="empleado.creado",
    )
    db.add(evento_outbox)
    await db.commit()
    return str(empleado.id)

async def worker_outbox(db, broker) -> None:
    while True:
        eventos = await db.execute(
            select(EventoOutbox)
            .where(EventoOutbox.publicado_en.is_(None))
            .where(EventoOutbox.intentos < 5)
            .order_by(EventoOutbox.creado_en)
            .limit(100)
            .with_for_update(skip_locked=True)
        )
        for evento in eventos.scalars():
            try:
                await broker.publicar_raw(
                    tipo=evento.tipo, payload=evento.payload,
                    exchange=evento.exchange, routing_key=evento.routing_key,
                )
                evento.publicado_en = datetime.now(timezone.utc)
            except Exception as e:
                evento.intentos += 1
                logger.error("Error publicando evento %s: %s", evento.id, e)
        await db.commit()
        await asyncio.sleep(5)
```

---

## Eventual Consistency — Manejo en UI

```python
@router.post("/empleados", status_code=202)
async def crear_empleado(comando: ComandoCrearEmpleado, ...):
    empleado_id = await handler.manejar(comando, db, broker)
    return {
        "empleado_id": empleado_id,
        "estado": "procesando",
        "mensaje": "El empleado está siendo procesado. Estará disponible en segundos.",
        "consultar_en": f"/empleados/{empleado_id}/estado",
    }

@router.get("/empleados/{id}/estado")
async def estado_empleado(id: UUID, db: AsyncSession = Depends(get_db)):
    vista = await db.get(VistaEmpleado, id)
    if vista is None:
        return {"estado": "procesando"}
    return {"estado": "completado", "datos": vista}
```
