Añadir un módulo hexagonal¶
Esta guía es una receta paso a paso para crear un módulo funcional nuevo
en app/modules/{m}/. Asume que ya conoces la arquitectura de referencia; si
no es así, lee primero Arquitectura hexagonal.
El objetivo es que el módulo:
- respete las cuatro capas
domain/,application/,infrastructure/,interface/con la regla de dependencia; - no importe ningún otro módulo directamente (ADR-004), comunicándose con ellos solo a través de puertos compartidos o eventos de dominio;
- quede registrado en
app/main.py; - pase
make lint-arch(import-linter) en verde.
Términos
- Módulo: bounded context autocontenido bajo
app/modules/{m}/. - Puerto: interfaz abstracta (ABC) que define un contrato; el dominio o la application dependen del puerto, nunca de su implementación.
- Adaptador: implementación concreta de un puerto, siempre en
infrastructure/.
1. Crea el esqueleto de carpetas¶
Cada módulo replica internamente las cuatro capas:
app/modules/{m}/
├── __init__.py # docstring describiendo el bounded context
├── domain/ # entidades, value objects, eventos, puertos propios, excepciones
├── application/ # use cases, commands, queries, dtos, ports.py
├── infrastructure/ # models ORM, repositories, mappers, adapters
└── interface/ # routers FastAPI, schemas Pydantic
Empieza el __init__.py del módulo con un docstring que explique qué hace
el bounded context. El del módulo manager es un buen ejemplo
(app/modules/manager/__init__.py):
"""Manager "control de sala" bounded context.
Real-time dashboard of a restaurant's active tables: a per-table lifecycle
state machine, manager delivery confirmations, courtesy rounds, a kitchen
overload flag, and a typed SSE feed for the sala tablet. Owns no ORM — it
reads/writes other modules' state exclusively through shared ports (ADR-004).
"""
Tip
El docstring del módulo es el primer sitio donde dejar claro qué ORM posee
(o no). manager "owns no ORM": una pista inmediata de que toda su
persistencia ocurre vía puertos.
2. Implementa las capas (de dentro hacia fuera)¶
Sigue el orden de la Arquitectura hexagonal §6. Resumen por capa:
| Capa | Qué contiene | Reglas clave |
|---|---|---|
domain/ |
Entidades, value objects (frozen=True), eventos de dominio, excepciones, puertos propios (ABC) |
Lógica de negocio pura. No importa sqlalchemy, fastapi, pydantic ni otro módulo |
application/ |
Use cases, commands.py, queries.py, dtos.py, ports.py |
Orquesta el dominio vía puertos. Método de entrada async def execute(self, command/query) -> DTO |
infrastructure/ |
Modelos ORM (Model), repositorios, mappers/, adapters/ |
Implementa los puertos. Único sitio que toca SQLAlchemy y servicios externos |
interface/ |
router.py, schemas.py |
Adaptador HTTP delgado. Único sitio donde vive HTTPException |
Convenciones normativas (detalle en Convenciones
si existe, o en backend/AGENTS.md):
- Puertos con prefijo
I(IUsuarioRepository). - Modelos ORM con sufijo
Model; mappers con sufijoMapper. - Use cases con sufijo
UseCasee invocados conawait use_case.execute(...). StrEnumen vez de magic strings; value objectsfrozenpara los primitivos del borde.datetimesiempre timezone-aware; precios en céntimos (int).
3. Comunícate con otros módulos SOLO por puertos o eventos (ADR-004)¶
Esta es la regla que más se rompe. Está prohibido un import como:
# 🚫 PROHIBIDO dentro de app/modules/{m}/...
from app.modules.session.infrastructure.models.session_model import SessionModel
Las dos formas autorizadas de cruzar la frontera de un módulo:
Opción A — puerto compartido (síncrono, uno-a-uno)¶
Cuando un módulo necesita leer o escribir estado que otro módulo posee, se
declara un puerto en app/shared/application/ports.py (no en el módulo
consumidor). El módulo dueño del dato implementa el adaptador; el consumidor
solo conoce el puerto.
app/shared/application/ports.py usa un registro de factorías con get()
perezoso para evitar imports circulares: la implementación concreta se
registra en el arranque y los consumidores la obtienen con un get_*().
Opción B — eventos de dominio (asíncrono, fan-out 1→N)¶
Para reaccionar a hechos de otro módulo sin acoplarse, se publica un
DomainEvent y el otro módulo se suscribe. Detalle en
Arquitectura hexagonal §4.4.
Lo que make lint-arch bloquea
El contrato module-independence del .importlinter impide que cualquier
app.modules.X importe app.modules.Y. Solo tres archivos de bootstrap
están autorizados a tocar varios módulos: app/main.py,
app/core/event_wiring.py y app/worker/runner.py.
4. Ejemplo real: el módulo manager¶
El módulo manager orquesta el dashboard de control de sala sin poseer ORM
propio. Es el ejemplo canónico de comunicación 100% por puertos.
4.1 Puertos que define / consume¶
Todos viven en app/shared/application/ports.py:
| Puerto | Lo implementa el módulo… | Lo consume… |
|---|---|---|
IManagerSessionGateway |
session (ManagerSessionGatewayAdapter) |
manager (lee/escribe el ciclo de vida de la sesión) |
IManagerCourtesyGateway |
comanda (ManagerCourtesyGatewayAdapter) |
manager (regala rondas de cortesía a 0 €) |
IManagerOrderGateway |
comanda (ManagerOrderGatewayAdapter) |
manager (reconcilia hitos del ciclo de vida) |
IKitchenLoadGateway |
manager (InMemoryKitchenLoadGateway) |
chat (lee el flag de cocina saturada) |
Observa el patrón: el manager lee/escribe estado de session y comanda
sin importarlos, y a su vez expone IKitchenLoadGateway (un adaptador
in-memory propio) para que chat lo consuma. Cada salto es un puerto, nunca un
import cross-module.
4.2 Forma de un puerto compartido¶
Por ejemplo IManagerSessionGateway (resumido) en
app/shared/application/ports.py:
class IManagerSessionGateway(ABC):
"""Port for the manager module to read/write table-session lifecycle.
Implemented by an adapter inside the ``session`` module. Bound to a
single ``AsyncSession`` at construction time.
"""
@abstractmethod
async def list_active(self, restaurant_id: int) -> list[ManagerSessionView]: ...
@abstractmethod
async def get_by_token(self, session_token: str) -> ManagerSessionView | None: ...
@abstractmethod
async def apply_lifecycle(
self, session_token: str, fields: dict[str, Any]
) -> ManagerSessionView | None: ...
Los datos cruzan la frontera como DTOs frozen (ManagerSessionView,
KitchenLoadStatus, …), nunca como entidades ORM. El puerto define también su
par registro/getter:
def register_manager_session_gateway(
factory: Callable[[Any], IManagerSessionGateway],
) -> None: ...
def get_manager_session_gateway(db: Any) -> IManagerSessionGateway: ...
Los gateways ligados a una sesión de BD reciben un AsyncSession en la
factoría (Callable[[Any], ...]); los puramente in-memory, como
IKitchenLoadGateway, usan Callable[[], ...] sin sesión.
5. Registra el módulo en app/main.py¶
main.py es uno de los tres archivos de bootstrap autorizados a importar de
varios módulos. Allí se hacen dos cosas:
5.1 Incluir el router HTTP¶
from app.modules.manager.interface.admin_manager_router import (
router as admin_manager_router,
)
# ...
app.include_router(admin_manager_router, prefix="/api/v1/admin")
5.2 Cablear los adaptadores a los puertos¶
Importa cada adaptador concreto y regístralo con su register_*. Tal como
aparece en app/main.py para el manager:
from app.modules.session.infrastructure.adapters.manager_session_gateway import (
ManagerSessionGatewayAdapter,
)
from app.modules.comanda.infrastructure.adapters.manager_courtesy_gateway import (
ManagerCourtesyGatewayAdapter,
)
from app.modules.comanda.infrastructure.adapters.manager_order_gateway import (
ManagerOrderGatewayAdapter,
)
from app.modules.manager.infrastructure.adapters.kitchen_load import (
InMemoryKitchenLoadGateway,
)
register_manager_session_gateway(lambda db: ManagerSessionGatewayAdapter(db))
register_manager_courtesy_gateway(lambda db: ManagerCourtesyGatewayAdapter(db))
register_manager_order_gateway(lambda db: ManagerOrderGatewayAdapter(db))
register_kitchen_load_gateway(InMemoryKitchenLoadGateway.instance)
Las factorías ligadas a BD se registran con lambda db: Adapter(db); el
gateway in-memory se registra con su singleton (InMemoryKitchenLoadGateway.instance).
Si publicas o consumes eventos de dominio
Registra el register(bus) del módulo en app/core/event_wiring.py, no en
main.py. Y si añades un consumidor de Valkey Streams, va en
app/worker/runner.py.
6. Añade el módulo a los contratos del .importlinter¶
import-linter solo vigila los módulos listados en backend/.importlinter. Si
creas un módulo nuevo, añádelo a los tres contratos (containers de
hexagonal-layers, modules de module-independence y source_modules de
domain-purity). De lo contrario, el módulo quedaría sin enforcement.
7. Tests¶
Reflejan la estructura del módulo:
tests/{m}/domain/— unitarios puros, sin BD.tests/{m}/application/— use cases con repos in-memory.tests/{m}/e2e/— flujo completo HTTP → PostgreSQL real.
En tests fija EMBEDDINGS_ENABLED=false (lo hace tests/conftest.py) y nunca
dependas del modelo de embeddings real.
8. Valida con make lint-arch¶
El gate duro de arquitectura es import-linter. Desde backend/:
Esto ejecuta lint-imports --config .importlinter y comprueba los cuatro
contratos:
hexagonal-layers—interface > {application | infrastructure} > domain.module-independence— ningún módulo importa otro (ADR-004).domain-purity—domain/no importasqlalchemy,fastapinipydantic.no-app-old— prohíbe resucitar imports deapp.old.
El comando sale con código distinto de cero si hay violaciones, así que bloquea el merge en CI. Conviene además instalar el hook local:
Visualizar dependencias entre módulos
Si una violación no es obvia, make graph y make graph-cycles (requieren
graphviz) renderizan el grafo de dependencias y los ciclos.
Checklist rápido¶
- [ ] Carpeta
app/modules/{m}/condomain/,application/,infrastructure/,interface/. - [ ]
__init__.pycon docstring del bounded context. - [ ] Cero imports a otros
app.modules.X; comunicación vía puertos o eventos. - [ ] Puertos compartidos en
app/shared/application/ports.pycon suregister_*/get_*. - [ ] Router incluido y adaptadores cableados en
app/main.py. - [ ] Subscribers en
app/core/event_wiring.pyy consumidores enapp/worker/runner.py(si aplica). - [ ] Módulo añadido a los tres contratos de
backend/.importlinter. - [ ] Tests en
tests/{m}/{domain,application,e2e}/. - [ ]
make lint-archen verde.