Saltar a contenido

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 sufijo Mapper.
  • Use cases con sufijo UseCase e invocados con await use_case.execute(...).
  • StrEnum en vez de magic strings; value objects frozen para los primitivos del borde.
  • datetime siempre 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/:

make lint-arch

Esto ejecuta lint-imports --config .importlinter y comprueba los cuatro contratos:

  1. hexagonal-layers — interface > {application | infrastructure} > domain.
  2. module-independence — ningún módulo importa otro (ADR-004).
  3. domain-purity — domain/ no importa sqlalchemy, fastapi ni pydantic.
  4. no-app-old — prohíbe resucitar imports de app.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:

pre-commit install

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}/ con domain/, application/, infrastructure/, interface/.
  • [ ] __init__.py con 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.py con su register_* / get_*.
  • [ ] Router incluido y adaptadores cableados en app/main.py.
  • [ ] Subscribers en app/core/event_wiring.py y consumidores en app/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-arch en verde.

Páginas relacionadas