Saltar a contenido

Arquitectura hexagonal (ADR-004)

Esta página explica por qué el backend de Camarero IA está organizado como una arquitectura hexagonal modular y cómo se mantiene esa disciplina de forma automática. Es una página de tipo explicación: no es un tutorial paso a paso, sino el modelo mental que necesitas para razonar sobre dónde vive cada pieza de código y por qué no puede importar a otra.

Decisión de referencia

El contrato arquitectónico descrito aquí es ADR-004 — Arquitectura hexagonal modular por funcionalidad, complementado por ADR-005 (comunicación cross-module por eventos de dominio). La fuente canónica vive en backend/docs/hexagonal.md; esta página la resume y la conecta con el código real.

Conceptos previos

  • Módulo funcional: un subconjunto autocontenido del sistema (p. ej. comanda, chat, menu, session). Cada módulo vive en app/modules/{modulo}/ y contiene sus propias cuatro capas.
  • Capa: una de las cuatro divisiones internas de un módulo (domain, application, infrastructure, interface).
  • Puerto (port): una interfaz abstracta (una clase ABC con métodos @abstractmethod) que declara qué necesita un módulo, sin decir cómo se implementa.
  • Adaptador (adapter): la implementación concreta de un puerto, que vive en la capa infrastructure del módulo que la posee.

Las cuatro capas y la regla del diamante

Cada módulo replica internamente las mismas cuatro capas. La regla de dependencia no es una pila lineal de cuatro niveles: es un diamante.

            ┌───────────────┐
            │   interface   │   FastAPI routers · schemas Pydantic
            └───────┬───────┘   worker consumers · event subscribers
                    │
        ┌───────────┴───────────┐
        ▼                       ▼
┌───────────────┐       ┌───────────────┐
│  application  │       │ infrastructure│   (HERMANOS — ninguno
│  use cases    │       │  repos · ORM  │    importa al otro)
│  DTOs · ports │       │  adapters     │
└───────┬───────┘       └───────┬───────┘
        │                       │
        └───────────┬───────────┘
                    ▼
            ┌───────────────┐
            │    domain     │   entidades · value objects
            └───────────────┘   domain events · puertos (ABCs)

La jerarquía exacta que se hace cumplir es:

interface > ( application | infrastructure ) > domain

  • application e infrastructure son hermanos: están ambos por encima de domain y por debajo de interface, pero ninguno importa al otro.
  • interface puede usar tanto application como infrastructure.
  • domain es la punta inferior del diamante: no importa nada de las capas exteriores.

application NO importa infrastructure

Esto es lo contraintuitivo del diamante. Un use case (capa application) nunca importa un repositorio SQLAlchemy concreto (capa infrastructure). En su lugar depende de un puerto (una ABC), y la implementación concreta se le inyecta desde la capa interface o desde el unit_of_work. Así la lógica de orquestación es testeable con repos in-memory, sin base de datos.

Responsabilidad de cada capa

Capa Responsabilidad Ejemplos en el repo
domain Lógica de negocio pura: entidades, value objects, eventos de dominio, excepciones y puertos (ABCs). Sin dependencias externas. comanda/domain/state_machine.py, comanda/domain/entities.py
application Orquestación de casos de uso. Recibe comandos/queries, coordina el dominio vía puertos, devuelve DTOs. use cases con async def execute(...)
infrastructure Implementaciones concretas de los puertos: repos SQLAlchemy, adapters de gateways, clientes externos, consumers de worker. chat/infrastructure/flows/llm_client.py
interface Adaptadores de transporte: routers FastAPI, schemas Pydantic, traducción de excepciones de dominio a HTTP. routers con prefix="/chat"

ADR-004: los módulos no se importan entre sí

La regla central de ADR-004 es simple de enunciar y estricta de cumplir:

Ningún módulo puede importar directamente de otro módulo. Si comanda necesita datos de menu, no escribe from app.modules.menu.infrastructure import .... Se comunica únicamente a través de un puerto compartido o de un evento de dominio.

Esto evita el acoplamiento involuntario que aparece cuando los módulos crecen, permite asignar ownership por equipo y deja la puerta abierta a extraer un módulo a un microservicio en el futuro.

Hay exactamente dos mecanismos de comunicación permitidos entre módulos:

  1. Puertos compartidos (síncrono, request/response) — descrito abajo.
  2. Eventos de dominio (asíncrono, fan-out 1→N) — ver ADR-005 en backend/docs/hexagonal.md §4.4.

Puertos compartidos: el registry con factory lazy

Cuando un puerto lo necesitan ≥ 2 módulos y su contrato es 100% infraestructural (no carga semántica de un dominio concreto), vive en app/shared/application/ports.py. Este fichero es el registry central de puertos cross-module.

El patrón evita imports circulares mediante un registry con factory lazy: cada puerto expone tres piezas.

  1. La interfaz del puerto (una ABC).
  2. Una función register_*(factory) que el módulo dueño llama una vez al arrancar (en app/main.py, tras importar todos los módulos) para registrar una factory que produce la implementación concreta.
  3. Una función get_*() que el consumidor invoca en tiempo de request para obtener la implementación, sin conocer nunca la clase concreta.
# app/shared/application/ports.py — patrón canónico (resumido)

_ROOM_DIRECTORY: Callable[[], IRoomDirectory] | None = None

def register_room_directory(factory: Callable[[], IRoomDirectory]) -> None:
    """Register a factory for the room directory (called once at startup)."""
    global _ROOM_DIRECTORY
    _ROOM_DIRECTORY = factory

def get_room_directory() -> IRoomDirectory:
    """Return the current room directory instance."""
    if _ROOM_DIRECTORY is None:
        raise RuntimeError(
            "No IRoomDirectory registered. Call register_room_directory at startup."
        )
    return _ROOM_DIRECTORY()

Dos sabores de factory

Algunas factories son sin argumentos (Callable[[], IPuerto]) — son singletons o stateless, p. ej. IRoomDirectory, ITableDirectory, IKitchenLoadGateway. Otras se enlazan a una sesión de base de datos en construcción (Callable[[Any], IPuerto]) y se obtienen con get_*(db) — p. ej. IMenuCatalogGateway, ISessionCartGateway, IBillingGateway. El consumidor pasa la sesión request-scoped.

Caso especial: IPaymentGateway

La interfaz IPaymentGateway vive en app/shared/application/ports.py (para que el adaptador PSP de infrastructure pueda implementarla sin importar su propio application/, lo que rompería la regla de capas), pero su registry (register_payment_gateway / get_payment_gateway, factory sin argumentos) vive en el domain/ports.py del módulo payment, no en shared/. El use case PayBillUseCase resuelve la implementación de forma lazy desde ahí, nunca desde infrastructure directamente.

Ejemplos de puertos reales en app/shared/application/ports.py

Estos son puertos que existen hoy en el código. Cada uno lo implementa un módulo (el adaptador vive en su infrastructure/) y lo consume otro, sin import directo entre ambos.

Puerto Implementado por Consumido por Para qué
IRestaurantAccessChecker restaurant routers admin modularizados Verificar el acceso multitenant de un admin a un restaurante sin importar require_restaurant_access.
IRoomDirectory restaurant table Resolver salas y datos de display del restaurante sin tocar su ORM.
IRestaurantInfoGateway restaurant chat Leer hechos del knowledge base para la skill consulta_local.
IComandaChecker comanda menu Decidir si un producto se puede soft-delete sin huérfanos.
IMenuCatalogGateway menu comanda Validar productos / extras / cursos sin tocar el ORM de menú.
ISessionCartGateway session comanda Leer/escribir el carrito al crear una comanda draft.
IConversationGateway chat comanda Leer y parchear la conversación (cards, fases, nudges).
IRestaurantPolicyGateway restaurant comanda Leer el order_approval_mode durante el submit.
IBillingGateway comanda payment Construir el Bill sin importar comanda/session.
IPaymentGateway adapter PSP (FakePaymentGateway) payment Cobrar una factura (Stripe/Redsys futuros caen aquí).
IChatProvisioningGateway chat session Get-or-create conversación + saludo inicial.
ITableDirectory table session Resolver el nombre de la mesa para el header del cliente.
IAuditTrailWriter audit varios admin Registrar acciones auditables.
IRecommendationEngineGateway recommendation chat Recomendaciones deterministas (el motor decide, el LLM redacta).
ITicketHistoryGateway comanda recommendation Tickets históricos para la matriz de co-ocurrencia.
ICoursingGateway comanda chat Leer/dirigir el coursing conversacional de la sesión.
IBehaviorEventRecorder analytics chat, comanda Emitir eventos de comportamiento del comensal (best-effort).
IManagerSessionGateway session manager Leer/escribir el lifecycle de la mesa para el dashboard de sala.
IManagerCourtesyGateway comanda manager Regalar una ronda gratis (comanda SERVED a 0 €).
IManagerOrderGateway comanda manager Señales de pedido para reconciliar el lifecycle.
IKitchenLoadGateway manager chat Flag de cocina saturada para orientar al asistente a platos rápidos.
IAdminAuthenticator / ICustomerAuthenticator auth app.core.deps Decodificar el bearer y cargar el usuario sin importar auth.
IRateLimiter app/infrastructure/rate_limiting/ varios Rate limiting persistente y multi-pod.

Regla de promoción a shared/application/

Un puerto se promueve a app/shared/application/ports.py solo si (1) lo usan dos o más módulos hoy (no especulativamente) y (2) su contrato es 100% infraestructural. En caso contrario permanece en el application/ports.py del módulo. shared/application/ aloja puertos; shared/domain/ aloja clases base que los módulos heredan (Entity, ValueObject, DomainEvent).

Pureza del dominio

La capa domain es el núcleo del módulo y debe permanecer pura: solo Python y las clases base de app/shared/domain/. En concreto, nada dentro de app/modules/{m}/domain/ puede importar:

  • sqlalchemy (el ORM es un detalle de infrastructure),
  • fastapi (el transporte HTTP es un detalle de interface),
  • pydantic (la validación de schemas es un detalle de interface),
  • ni ningún otro módulo (app.modules.X).

Esto garantiza que las entidades, value objects y state machines del dominio se pueden testear como Python puro, sin levantar base de datos ni servidor.

Cómo detectar una violación a ojo

Si abres cualquier fichero bajo domain/ y ves un import sqlalchemy, import fastapi, import pydantic o un from app.modules.otro..., es una violación de arquitectura. El linter la bloquea (ver abajo), pero conviene reconocerla en revisión de código.

Enforcement automático: import-linter

La disciplina no depende de la buena voluntad: se hace cumplir con import-linter, configurado en backend/.importlinter y ejecutado con:

make lint-arch

Este comando es un gate duro de CI (corre en push/PR a main) y también está cableado como hook local en .pre-commit-config.yaml (instálalo con pre-commit install). El fichero .importlinter define cuatro contratos:

Contrato Tipo Qué verifica
hexagonal-layers layers La regla del diamante interface > (application \| infrastructure) > domain en los 16 módulos.
module-independence independence Que los módulos no se importan entre sí (ADR-004).
domain-purity forbidden Que {m}.domain no importa sqlalchemy, fastapi ni pydantic.
no-app-old forbidden Que nadie re-importe app.old.* (legacy retirado en OLDKILL13).

El contrato de capas modela explícitamente el diamante con la sintaxis (application) | infrastructure, que le dice al linter que esas dos capas son independientes entre sí (hermanas), no una encima de la otra:

[importlinter:contract:hexagonal-layers]
name = Hexagonal layer rule (interface > {application|infrastructure} > domain)
type = layers
layers =
    interface
    (application) | infrastructure
    domain
containers =
    app.modules.comanda
    app.modules.chat
    ...

Otros gates de CI

make lint-arch es uno de los gates duros, junto con pytest tests/contract/ (contrato OpenAPI/SSE). make typecheck (mypy) está cableado pero hoy es no-bloqueante mientras se reduce el baseline.

Excepciones sancionadas a ADR-004

Hay exactamente tres ficheros de bootstrap autorizados a importar de todos los módulos a la vez, porque su única responsabilidad es ensamblar la aplicación, no contener lógica de negocio:

Fichero Responsabilidad
app/main.py Registra los routers HTTP de cada módulo y llama a las register_*() de los puertos compartidos al arrancar.
app/core/event_wiring.py Registra los subscribers de eventos de dominio de cada módulo en el bus.
app/worker/runner.py Descubre y arranca los consumidores de Valkey Streams de cada módulo.

Ningún cuarto candidato sin ADR

Ningún otro fichero puede importar de más de un módulo. Si aparece un cuarto candidato a esta lista, debe abrirse un nuevo ADR que lo justifique. El contrato module-independence de .importlinter reconoce únicamente estos tres ficheros como sancionados.

Flujo completo de una llamada cross-module

Para fijar el modelo, este es el recorrido de comanda necesitando validar un producto de menu sin importarlo:

  1. Arranque — menu registra su adaptador: register_menu_catalog_gateway(lambda db: MenuCatalogAdapter(db)) se llama en app/main.py.
  2. Request — un use case de comanda (capa application) necesita el producto. Llama a get_menu_catalog_gateway(db) y obtiene una IMenuCatalogGateway ligada a la sesión.
  3. Uso — invoca await gateway.get_product(product_id, restaurant_id) y recibe un ProductCatalogEntry (un DTO frozen), nunca una fila ORM de menu.
  4. Resultado — comanda valida sin haber importado jamás app.modules.menu.*. El linter pasa; el acoplamiento es solo contra el puerto en app/shared/application/ports.py.

Páginas relacionadas