Clean Architecture Hexagonal / Ports & Adapters Domain-Driven Design

Arquitectura Backend — LeadFlow SaaS

Guía de refactorización del módulo de gestión de leads · Python 3.12 · FastAPI · PostgreSQL

Cliente
LeadFlow
SaaS B2B · Automatización de leads
para agencias de marketing digital
🏛
1. Clean Architecture — Módulo de Leads
Regla de dependencias: Las dependencias apuntan siempre hacia adentro. La capa Dominio no importa nada de capas externas. Los Casos de Uso solo conocen el Dominio. Los Adaptadores implementan las interfaces definidas en el Dominio.
Frameworks & Drivers
Capa Externa — Tecnología
FastAPI routers, SQLAlchemy ORM, HubSpot SDK, Redis, Slack Webhooks
FastAPI Router SQLAlchemy Models HubSpot SDK Redis Client SlackWebhook
↑ dependency boundary ↑
Interface Adapters
Controladores, Repositorios, Presenters
Traducen entre el mundo exterior y los Casos de Uso
LeadController PostgresLeadRepository HubSpotCRMAdapter SlackNotificationAdapter
↑ dependency boundary ↑
Use Cases
Casos de Uso — Reglas de Aplicación
Orquestan entidades y coordinan adaptadores a través de puertos abstractos
CaptureLeadUseCase EnrichLeadUseCase DistributeLeadUseCase ScoreLeadUseCase
↑ dependency boundary ↑
Entities / Domain
Dominio Puro — Sin imports externos
Modelos de negocio, value objects, interfaces (puertos) e invariantes del dominio
Lead (Aggregate) LeadScore (VO) Email (VO) ILeadRepository ICRMPort
📁 leadflow/lead_management/ — Estructura de carpetas Shell
lead_management/
domain/ # sin imports externos
entities/
lead.py # aggregate root
enrichment.py
value_objects/
email.py
lead_score.py
phone.py
interfaces/ # puertos (ABC)
i_lead_repository.py
i_crm_port.py
i_enrichment_service.py
i_notification_port.py
events/
lead_captured.py
lead_scored.py
lead_distributed.py
use_cases/ # solo importa de domain/
capture_lead.py
enrich_lead.py
score_lead.py
distribute_lead.py
adapters/ # implementaciones concretas
repositories/
postgres_lead_repository.py
in_memory_lead_repository.py
crm/
hubspot_crm_adapter.py
salesforce_crm_adapter.py
enrichment/
openai_enrichment_adapter.py
notifications/
slack_notification_adapter.py
email_notification_adapter.py
api/ # FastAPI routers (capa más externa)
routes.py
schemas.py # Pydantic, solo aqui
dependencies.py
2. Hexagonal Architecture — Puertos y Adaptadores
Driving Adapters
FastAPI Router
REST Controller
Redis Consumer
Queue Listener
CLI Script
Batch Import
Domain Core
Lead Management
Domain
Puertos de entrada
ILeadCapturePort ILeadEnrichPort
Puertos de salida
ILeadRepository ICRMPort IEnrichmentService INotificationPort
Driven Adapters
PostgresLeadRepo
ILeadRepository
HubSpotCRMAdapter
ICRMPort
OpenAIEnrichment
IEnrichmentService
SlackNotification
INotificationPort
domain/interfaces/i_crm_port.py Python
from abc import ABC, abstractmethod
from dataclasses import dataclass
from ..entities.lead import Lead


@dataclass
class CRMContact:
    """DTO de salida — no depende de ningún SDK externo."""
    external_id: str
    synced_at: str
    crm_url: str


class ICRMPort(ABC):
    """Puerto de salida para sincronización con CRM.

    El dominio define el contrato; el adaptador lo implementa.
    Si mañana cambiamos de HubSpot a Salesforce, solo cambia el adaptador.
    """

    @abstractmethod
    async def push_lead(self, lead: Lead) -> CRMContact:
        """Envía un lead al CRM. Lanza CRMConnectionError si falla."""
        ...

    @abstractmethod
    async def update_lead_score(self, external_id: str, score: int) -> bool:
        """Actualiza el score de un contacto ya existente en el CRM."""
        ...
adapters/crm/hubspot_crm_adapter.py Python
from hubspot import HubSpot  # SDK solo en esta capa
from ...domain.interfaces.i_crm_port import ICRMPort, CRMContact
from ...domain.entities.lead import Lead


class HubSpotCRMAdapter(ICRMPort):
    """Implementación concreta del puerto ICRMPort usando HubSpot SDK."""

    def __init__(self, api_key: str) -> None:
        self._client = HubSpot(access_token=api_key)

    async def push_lead(self, lead: Lead) -> CRMContact:
        props = {
            "email": str(lead.email),
            "firstname": lead.first_name,
            "lastname": lead.last_name,
            "leadscore_cultiva": str(lead.score.value),
            "source_channel": lead.source.value,
        }
        response = await self._client.crm.contacts.basic_api.create(
            simple_public_object_input_for_create={"properties": props}
        )
        return CRMContact(
            external_id=response.id,
            synced_at=response.created_at.isoformat(),
            crm_url=f"https://app.hubspot.com/contacts/{response.id}",
        )

    async def update_lead_score(self, external_id: str, score: int) -> bool:
        await self._client.crm.contacts.basic_api.update(
            contact_id=external_id,
            simple_public_object_input={"properties": {"leadscore_cultiva": str(score)}}
        )
        return True
🧩
3. Domain-Driven Design — Patrones Tácticos
Aggregate Root
Lead
Boundary de consistencia. Solo accesible desde fuera a través del aggregate root. Contiene la identidad estable.
lead.capture(source, email, phone)
lead.enrich(ai_data)
lead.distribute(crm_id)
Value Objects
Email · LeadScore · Phone
Inmutables. Identificados por sus atributos. Validan invariantes en el constructor. No pueden crearse en estado inválido.
Email("bad@") → ValueError
LeadScore(101) → ValueError
LeadScore(85).is_hot → True
Domain Events
LeadCaptured · LeadScored
Cosas que pasaron en el dominio. Permiten coordinación sin acoplar aggregates. El event bus los distribuye.
LeadCaptured(lead_id, source, ts)
LeadScored(lead_id, score, model_v)
Repository
ILeadRepository
Abstrae el almacenamiento. El dominio define la interfaz; el adaptador PostgreSQL la implementa. Tests usan InMemory.
await repo.save(lead)
await repo.find_by_email(email)
await repo.find_hot_leads(min_score=80)
Bounded Context
LeadMgmt vs. Billing
Cada contexto tiene su propio modelo. Order context no importa User de Identity: usa AgencyId (VO) + ACL explícita.
LeadMgmt.AgencyId (VO)
→ ACL → Billing.Agency (Entity)
Ubiquitous Language
Glosario del dominio
El código usa exactamente los términos del negocio: Lead, Score, Distribution, Agency, Channel. No "user" ni "record".
capture() no create()
distribute() no send()
enrich() no update_with_ai()
domain/entities/lead.py Python
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timezone
from enum import Enum
from uuid import uuid4
# ⚠ Solo imports de stdlib y otros módulos del dominio
from .value_objects.email import Email
from .value_objects.lead_score import LeadScore


class LeadStatus(Enum):
    NEW = "new"
    ENRICHED = "enriched"
    SCORED = "scored"
    DISTRIBUTED = "distributed"


class LeadSource(Enum):
    WEB_FORM = "web_form"
    LINKEDIN = "linkedin"
    META_ADS = "meta_ads"


@dataclass
class Lead:
    """Aggregate Root del contexto Lead Management."""
    id: str
    email: Email
    first_name: str
    last_name: str
    source: LeadSource
    status: LeadStatus
    score: LeadScore | None
    agency_id: str
    captured_at: datetime
    _events: list = field(default_factory=list, repr=False)

    @classmethod
    def capture(
        cls,
        email: str,
        first_name: str,
        last_name: str,
        source: LeadSource,
        agency_id: str,
    ) -> Lead:
        """Factory method — único punto de creación de un lead."""
        lead = cls(
            id=str(uuid4()),
            email=Email(email),  # valida aquí, no en el controller
            first_name=first_name,
            last_name=last_name,
            source=source,
            status=LeadStatus.NEW,
            score=None,
            agency_id=agency_id,
            captured_at=datetime.now(timezone.utc),
        )
        lead._events.append(LeadCaptured(lead_id=lead.id, source=source))
        return lead

    def apply_score(self, score: int, model_version: str) -> None:
        """Aplica el scoring de IA. Cambia el estado del aggregate."""
        self.score = LeadScore(score)
        self.status = LeadStatus.SCORED
        self._events.append(LeadScored(
            lead_id=self.id, score=score, model_version=model_version
        ))

    def pull_events(self) -> list:
        events, self._events = self._events, []
        return events
🧪
4. Tests sin Base de Datos — In-Memory Adapters
Criterio de correctitud: Si los tests de casos de uso necesitan una base de datos real, la lógica de negocio ha escapado hacia la capa de infraestructura. Con Clean Architecture, todos los use cases se prueban con repositorios en memoria.
tests/unit/test_capture_lead.py Python — Test sin BD, sin Docker, sin red
import pytest
from lead_management.adapters.repositories.in_memory_lead_repository import InMemoryLeadRepository
from lead_management.use_cases.capture_lead import CaptureLeadUseCase, CaptureLeadRequest
from lead_management.domain.entities.lead import LeadSource


@pytest.fixture
def use_case():
    return CaptureLeadUseCase(lead_repository=InMemoryLeadRepository())


@pytest.mark.asyncio
async def test_capture_lead_from_web_form(use_case):
    request = CaptureLeadRequest(
        email="carlos@agencianexo.com",
        first_name="Carlos",
        last_name="Morales",
        source=LeadSource.WEB_FORM,
        agency_id="agency-nexo-001",
    )
    result = await use_case.execute(request)

    assert result.success is True
    assert result.lead.email == "carlos@agencianexo.com"
    assert result.lead.status.value == "new"
    assert result.lead.id is not None
    assert len(result.lead.pull_events()) == 1  # LeadCaptured emitted


@pytest.mark.asyncio
async def test_duplicate_email_same_agency_rejected(use_case):
    req = CaptureLeadRequest(
        email="carlos@agencianexo.com", first_name="Carlos",
        last_name="M", source=LeadSource.WEB_FORM, agency_id="agency-nexo-001"
    )
    await use_case.execute(req)  # primer capture
    result = await use_case.execute(req)  # segundo — debe rechazarse

    assert result.success is False
    assert "already exists" in result.error


@pytest.mark.asyncio
async def test_invalid_email_rejected_at_boundary(use_case):
    """El VO Email debe lanzar ValueError antes de llegar al repositorio."""
    req = CaptureLeadRequest(
        email="not-an-email", first_name="X", last_name="Y",
        source=LeadSource.META_ADS, agency_id="agency-nexo-001"
    )
    result = await use_case.execute(req)

    assert result.success is False
    assert "Invalid email" in result.error
PASSED tests/unit/test_capture_lead.py
test_capture_lead_from_web_form (0.003s)
test_duplicate_email_same_agency_rejected (0.001s)
test_invalid_email_rejected_at_boundary (0.001s)
tests/unit/test_score_lead.py::test_ai_score_applied_to_lead (0.002s)
tests/unit/test_distribute_lead.py::test_hubspot_push_uses_in_memory_adapter (0.002s)
tests/unit/test_distribute_lead.py::test_slack_notified_on_hot_lead (0.001s)
6 passed in 0.014s — 0 database connections, 0 Docker containers, 0 network calls
🔧
5. Troubleshooting — Problemas del Monolito LeadFlow
Tests de use cases requieren PostgreSQL en CI
La lógica de negocio estaba en los modelos SQLAlchemy. Los tests levantaban una BD real.
✓ Solución: Mover toda la lógica a Lead (aggregate). Crear InMemoryLeadRepository. Tests ahora corren en 14ms sin conexión.
ImportError circular: routes/ ↔ models/
from models.lead import Lead dentro de use_cases/ que también importa de routes/.
✓ Solución: use_cases/ solo importa de domain/. Los adapters implementan las interfaces. routes/ importa use_cases y adapters, nunca al revés.
Column() de SQLAlchemy en la entidad Lead
La entidad Lead tenía decoradores de SQLAlchemy, haciendo el dominio dependiente del ORM.
✓ Solución: Crear LeadOrmModel separado en adapters/repositories/. Método _to_entity() mapea ORM → dominio puro sin contaminación.
Toda la lógica de puntuación en el controller FastAPI
El endpoint POST /leads tenía 200 líneas con lógica de scoring, llamadas a HubSpot y envío de Slack.
✓ Solución: Extraído a ScoreLeadUseCase + DistributeLeadUseCase. El controller ahora hace: parse → call use_case → return response (3 líneas).
Context bleed: Order context importa User de Identity
LeadMgmt importaba Agency desde el contexto de Billing, creando acoplamiento entre contextos.
✓ Solución: LeadMgmt tiene su propio AgencyId (Value Object). ACL explícita para traducir entre contextos al cruzar boundaries.