CULTIVA IA Backend · CQRS Python 3.11 · FastAPI Event-Driven

LeadFlow — Arquitectura CQRS

Separación de modelos de escritura y lectura para un SaaS B2B de gestión de leads con 50+ comerciales concurrentes

Cliente: LeadFlow SaaS
Stack: Python 3.11 + FastAPI + PostgreSQL
Patrón: CQRS + Event Sourcing (parcial)
Generado por: CULTIVA IA
4
Commands
Capture · Assign · Qualify · Close
3
Queries
Dashboard · ByComercial · Funnel
4
Domain Events
Inmutables · Versionados
< 200ms
Lag máx. proyecciones
SLA de consistencia

Diagrama de Arquitectura

🌐 Cliente Web
React SPA / Mobile
⚡ Command API
POST / PUT / DELETE
🔍 Query API
GET — solo lectura
🛠 Command Handlers
Validación + Lógica
📊 Query Handlers
Solo SELECT
✍️ Write Model
Agregados normalizados
→ eventos →
📋 Read Model
Vistas desnormalizadas
🗄 PostgreSQL (write)
leads · events
🗄 PostgreSQL (read)
lead_views · funnel_stats
⚡ Escrituras aisladas — 🔄 Proyecciones asíncronas — 🔍 Lecturas desnormalizadas sin bloqueos

Componentes y Responsabilidades

Componente Lado Clase Python Responsabilidad en LeadFlow
Command Write CaptureLeadCommand, CloseLeadCommand… Intención de cambio de estado; inmutables, con UUID propio
CommandBus Write CommandBus Enruta cada comando a su handler registrado
CommandHandler Write CaptureLeadHandler, QualifyLeadHandler… Valida, ejecuta la lógica de negocio y persiste eventos
DomainEvent Ambos LeadCaptured, LeadAssigned, LeadClosed… Registro inmutable de lo que ocurrió (pasado), con versión y timestamp
EventStore Write EventStore Persiste el stream de eventos de cada Lead en la tabla events
Projector Read LeadViewProjector, FunnelProjector Escucha eventos y actualiza las vistas desnormalizadas en la tabla lead_views
Query Read GetLeadDashboardQuery, GetConversionFunnelQuery… Parámetros de búsqueda tipados; nunca modifica estado
QueryHandler Read LeadDashboardHandler, FunnelHandler… SELECT optimizado contra la vista desnormalizada; devuelve DTOs

Implementación — Comandos

Commands — LeadFlow
from dataclasses import dataclass, field
from datetime import datetime
import uuid

@dataclass
class Command:
    command_id: str = field(
        default_factory=lambda: str(uuid.uuid4())
    )
    timestamp: datetime = field(
        default_factory=datetime.utcnow
    )

@dataclass
class CaptureLeadCommand(Command):
    nombre: str = ""
    empresa: str = ""
    email: str = ""
    fuente: str = ""  # "web" | "linkedin" | "referido"

@dataclass
class AssignLeadCommand(Command):
    lead_id: str = ""
    comercial_id: str = ""

@dataclass
class QualifyLeadCommand(Command):
    lead_id: str = ""
    puntuacion: int = 0   # 0–100
    etapa: str = ""        # "calificado" | "propuesta"

@dataclass
class CloseLeadCommand(Command):
    lead_id: str = ""
    resultado: str = ""  # "ganado" | "perdido"
    motivo: str = ""
CaptureLeadHandler
from abc import ABC, abstractmethod

class CommandHandler(ABC):
    @abstractmethod
    async def handle(self, command): ...

class CaptureLeadHandler(CommandHandler):
    def __init__(self, repo, event_store):
        self.repo = repo
        self.event_store = event_store

    async def handle(self, cmd: CaptureLeadCommand) -> str:
        # 1. Validar
        if not cmd.email:
            raise ValueError("Email requerido")
        if await self.repo.email_exists(cmd.email):
            raise ValueError("Lead duplicado")

        # 2. Crear agregado
        lead = Lead.create(
            nombre=cmd.nombre,
            empresa=cmd.empresa,
            email=cmd.email,
            fuente=cmd.fuente,
        )

        # 3. Persistir eventos
        await self.event_store.append(
            stream_id=f"Lead-{lead.id}",
            events=lead.uncommitted_events
        )

        # 4. Retornar ID
        return lead.id


class CommandBus:
    def __init__(self):
        self._map = {}

    def register(self, cmd_cls, handler):
        self._map[cmd_cls] = handler

    async def dispatch(self, command):
        h = self._map.get(type(command))
        if not h:
            raise ValueError("Sin handler")
        return await h.handle(command)

Implementación — Queries

Queries — LeadFlow
from dataclasses import dataclass
from typing import Optional, List

@dataclass
class GetLeadDashboardQuery:
    """Resumen ejecutivo para el manager."""
    periodo_dias: int = 30

@dataclass
class GetLeadsByComercialQuery:
    comercial_id: str
    etapa: Optional[str] = None
    page: int = 1
    page_size: int = 20

@dataclass
class GetConversionFunnelQuery:
    """Embudos de conversión por etapa."""
    equipo_id: Optional[str] = None
    periodo_dias: int = 30

# ──── DTOs de resultado ────
@dataclass
class DashboardView:
    leads_nuevos: int
    leads_calificados: int
    leads_cerrados_ganados: int
    tasa_conversion: float
    valor_pipeline: float

@dataclass
class LeadView:
    lead_id: str
    nombre: str
    empresa: str
    etapa: str
    puntuacion: int
    comercial: str
    dias_en_etapa: int

@dataclass
class FunnelStageView:
    etapa: str
    total: int
    conversion_pct: float
    tiempo_medio_dias: float
LeadDashboardHandler
class LeadDashboardHandler:
    def __init__(self, read_db):
        self.db = read_db

    async def handle(
        self, q: GetLeadDashboardQuery
    ) -> DashboardView:
        # SELECT directo sobre vista desnormalizada
        # — sin tocar el write model —
        row = await self.db.fetchrow("""
            SELECT
                COUNT(*) FILTER (
                    WHERE etapa = 'nuevo'
                    AND created_at > now() - $1::interval
                ) AS nuevos,
                COUNT(*) FILTER (
                    WHERE etapa = 'calificado'
                ) AS calificados,
                COUNT(*) FILTER (
                    WHERE resultado = 'ganado'
                    AND closed_at > now() - $1::interval
                ) AS ganados,
                SUM(valor_estimado) AS pipeline
            FROM lead_views
        """, f"{q.periodo_dias} days")
        total = row['nuevos'] + row['calificados']
        tasa = (
            row['ganados'] / total * 100
            if total > 0 else 0.0
        )
        return DashboardView(
            leads_nuevos=row['nuevos'],
            leads_calificados=row['calificados'],
            leads_cerrados_ganados=row['ganados'],
            tasa_conversion=round(tasa, 1),
            valor_pipeline=row['pipeline'] or 0.0
        )

FastAPI — Endpoints Separados

Rutas de escritura y lectura completamente aisladas
from fastapi import FastAPI, HTTPException, Depends, Query
from pydantic import BaseModel
from typing import Optional

app = FastAPI(title="LeadFlow API")

# ── Pydantic requests ──────────────────────────────────────────────────
class CaptureLeadRequest(BaseModel):
    nombre: str; empresa: str; email: str; fuente: str

class QualifyRequest(BaseModel):
    puntuacion: int; etapa: str

# ── Dependency injection ───────────────────────────────────────────────
def cmd_bus() -> CommandBus: return app.state.command_bus
def qry_bus() -> QueryBus:    return app.state.query_bus

# ╔══════════════════════════════════════════════════════════════════════╗
# ║  WRITE API — muta estado, retorna IDs, dispara eventos              ║
# ╚══════════════════════════════════════════════════════════════════════╝
@app.post("/leads", status_code=201)
async def capture_lead(
    body: CaptureLeadRequest,
    bus: CommandBus = Depends(cmd_bus)
):
    lead_id = await bus.dispatch(CaptureLeadCommand(**body.dict()))
    return {"lead_id": lead_id}

@app.put("/leads/{lead_id}/assign/{comercial_id}")
async def assign_lead(lead_id: str, comercial_id: str, bus = Depends(cmd_bus)):
    await bus.dispatch(AssignLeadCommand(lead_id=lead_id, comercial_id=comercial_id))
    return {"status": "assigned"}

@app.put("/leads/{lead_id}/qualify")
async def qualify_lead(lead_id: str, body: QualifyRequest, bus = Depends(cmd_bus)):
    await bus.dispatch(QualifyLeadCommand(lead_id=lead_id, **body.dict()))
    return {"status": "qualified"}

@app.delete("/leads/{lead_id}")
async def close_lead(lead_id: str, resultado: str, motivo: str = "", bus = Depends(cmd_bus)):
    await bus.dispatch(CloseLeadCommand(lead_id=lead_id, resultado=resultado, motivo=motivo))
    return {"status": resultado}

# ╔══════════════════════════════════════════════════════════════════════╗
# ║  READ API — solo GET, nunca muta estado                             ║
# ╚══════════════════════════════════════════════════════════════════════╝
@app.get("/dashboard")
async def dashboard(
    periodo: int = Query(30, ge=1, le=365),
    bus = Depends(qry_bus)
):
    return await bus.dispatch(GetLeadDashboardQuery(periodo_dias=periodo))

@app.get("/comerciales/{comercial_id}/leads")
async def leads_by_comercial(
    comercial_id: str,
    etapa: Optional[str] = None,
    page: int = 1, page_size: int = 20,
    bus = Depends(qry_bus)
):
    return await bus.dispatch(
        GetLeadsByComercialQuery(comercial_id, etapa, page, page_size)
    )

@app.get("/funnel")
async def conversion_funnel(
    equipo_id: Optional[str] = None,
    periodo: int = 30,
    bus = Depends(qry_bus)
):
    return await bus.dispatch(
        GetConversionFunnelQuery(equipo_id=equipo_id, periodo_dias=periodo)
    )

Flujo de Eventos — CaptureLeadCommand

1
POST /leads
El cliente envía CaptureLeadRequest con nombre, empresa, email y fuente
2
CommandBus.dispatch()
Busca CaptureLeadHandler en el registro y llama handle()
3
CaptureLeadHandler.handle()
Valida email único, crea el agregado Lead, genera evento LeadCaptured
4
EventStore.append()
Persiste LeadCaptured en tabla events con stream_id Lead-{uuid}, versión 1
5
LeadViewProjector
Escucha LeadCaptured, hace INSERT en lead_views con datos desnormalizados
6
GET /dashboard disponible
Tras propagación (< 200ms), las queries ya reflejan el nuevo lead sin bloqueos
Domain Events — LeadFlow
@dataclass
class DomainEvent:
    event_id: str = field(default_factory=lambda: str(uuid.uuid4()))
    occurred_at: datetime = field(default_factory=datetime.utcnow)
    version: int = 1

@dataclass
class LeadCaptured(DomainEvent):
    lead_id: str = ""
    email: str = ""
    fuente: str = ""

@dataclass
class LeadAssigned(DomainEvent):
    lead_id: str = ""
    comercial_id: str = ""

@dataclass
class LeadQualified(DomainEvent):
    lead_id: str = ""
    puntuacion: int = 0
    etapa: str = ""

@dataclass
class LeadClosed(DomainEvent):
    lead_id: str = ""
    resultado: str = ""
    motivo: str = ""
SLA de Consistencia
lead_views (perfil individual)
≤ 50 ms
Proyección prioritaria
dashboard (KPIs ejecutivos)
≤ 200 ms
Tolerancia media
funnel_stats (reporting)
≤ 2 s
Batch cada 2 s
ranking_comerciales
≤ 30 s
Cache con TTL

Esquema SQL — Write vs Read

Write Model — Normalizado
-- Agregados y eventos; escrituras rápidas
CREATE TABLE leads (
    id          UUID PRIMARY KEY,
    email       TEXT UNIQUE NOT NULL,
    estado      TEXT NOT NULL DEFAULT 'nuevo',
    version     INT NOT NULL DEFAULT 1,
    created_at  TIMESTAMPTZ DEFAULT now()
);

CREATE TABLE events (
    global_pos  BIGSERIAL PRIMARY KEY,
    stream_id   TEXT NOT NULL,       -- "Lead-{uuid}"
    event_type  TEXT NOT NULL,       -- "LeadCaptured"
    payload     JSONB NOT NULL,
    version     INT NOT NULL,
    occurred_at TIMESTAMPTZ DEFAULT now(),
    UNIQUE (stream_id, version)      -- optimistic lock
);

CREATE INDEX ON events(stream_id);
CREATE INDEX ON events(global_pos);
Read Model — Desnormalizado
-- Vista materializada para queries rápidos
CREATE TABLE lead_views (
    lead_id           UUID PRIMARY KEY,
    nombre            TEXT,
    empresa           TEXT,
    email             TEXT,
    fuente            TEXT,
    etapa             TEXT,          -- pipeline actual
    puntuacion        SMALLINT,
    comercial_nombre  TEXT,          -- desnormalizado
    comercial_equipo  TEXT,          -- desnormalizado
    valor_estimado    NUMERIC(12,2),
    resultado         TEXT,          -- "ganado"|"perdido"|NULL
    dias_en_etapa     INT,
    created_at        TIMESTAMPTZ,
    updated_at        TIMESTAMPTZ,
    last_event_ver    INT NOT NULL DEFAULT 0
);

-- Índices para los filtros más comunes
CREATE INDEX ON lead_views(comercial_nombre, etapa);
CREATE INDEX ON lead_views(etapa, created_at DESC);
CREATE INDEX ON lead_views(puntuacion DESC);

Buenas Prácticas

Hacer

Separar modelos — Write model normalizado para integridad; read model desnormalizado para velocidad
Versionar eventosversion: int en cada evento para migraciones y auditoría
Validar en CommandHandler — antes de cualquier persistencia; nunca en la capa de routing
Definir SLA de consistencia — cada proyección tiene su tolerancia aceptada y documentada
Poder reconstruir proyeccionesrebuild_projection() disponible para errores o cambios de schema
Optimistic locking — UNIQUE(stream_id, version) previene escrituras concurrentes corruptas

Evitar

Queries en Commands — un command handler nunca consulta el read model; solo escribe
Acoplar schemas — si añades columnas al write model, las proyecciones evolucionan de forma independiente
Sobreingeniería inicial — empieza con un solo PostgreSQL; separa físicamente solo si el volumen lo justifica
Ignorar el lag — siempre comunicar al frontend cuándo los datos pueden ser eventualmente consistentes
Lógica de negocio en proyectores — los projectors solo transforman y persisten; no deciden nada
Mutar eventos pasados — los eventos son inmutables; para corregir, emite un nuevo evento compensatorio