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 enapp/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
ABCcon 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
infrastructuredel 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
applicationeinfrastructureson hermanos: están ambos por encima dedomainy por debajo deinterface, pero ninguno importa al otro.interfacepuede usar tantoapplicationcomoinfrastructure.domaines 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
comandanecesita datos demenu, no escribefrom 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:
- Puertos compartidos (síncrono, request/response) — descrito abajo.
- 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.
- La interfaz del puerto (una
ABC). - Una función
register_*(factory)que el módulo dueño llama una vez al arrancar (enapp/main.py, tras importar todos los módulos) para registrar una factory que produce la implementación concreta. - 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 deinfrastructure),fastapi(el transporte HTTP es un detalle deinterface),pydantic(la validación de schemas es un detalle deinterface),- 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:
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:
- Arranque —
menuregistra su adaptador:register_menu_catalog_gateway(lambda db: MenuCatalogAdapter(db))se llama enapp/main.py. - Request — un use case de
comanda(capaapplication) necesita el producto. Llama aget_menu_catalog_gateway(db)y obtiene unaIMenuCatalogGatewayligada a la sesión. - Uso — invoca
await gateway.get_product(product_id, restaurant_id)y recibe unProductCatalogEntry(un DTOfrozen), nunca una fila ORM demenu. - Resultado —
comandavalida sin haber importado jamásapp.modules.menu.*. El linter pasa; el acoplamiento es solo contra el puerto enapp/shared/application/ports.py.