La Pregunta que Nadie Hace con Honestidad
Toda charla de arquitectura empieza con el camino feliz. Diagramas limpios, separación perfecta, sin código legacy.
Aquí la pregunta real: ¿deberías usar Arquitectura Hexagonal en tu próximo servicio FastAPI?
Mi respuesta honesta: probablemente no para tu primera versión, definitivamente para la segunda.
¿Qué Es la Arquitectura Hexagonal?
Alistair Cockburn la nombró en 2005. La idea es simple:
Tu aplicación tiene un núcleo — la lógica de negocio. Todo lo demás (HTTP, bases de datos, colas, sistemas de archivos) se conecta a ese núcleo a través de puertos (interfaces) bien definidos. Los adaptadores implementan esos puertos.
El hexágono es solo una metáfora visual. Lo que importa es la regla de dependencia: los adaptadores dependen de los puertos, nunca al revés.
┌─────────────────────────┐
HTTP ──────► Puerto (Entrada) │
│ │
│ Núcleo de Aplicación │
│ (Python Puro) │
│ │
│ Puerto (Salida) ◄── DB │
└─────────────────────────┘
Un Ejemplo Real con FastAPI
Supongamos que estás construyendo un servicio de facturas. Así se ve en la práctica.
El Puerto (interfaz)
# app/domain/ports/factura_repository.py
from abc import ABC, abstractmethod
from typing import Optional
from app.domain.entities import Factura
class FacturaRepository(ABC):
"""Puerto de salida — el núcleo define lo que necesita, no cómo se hace."""
@abstractmethod
async def guardar(self, factura: Factura) -> Factura:
...
@abstractmethod
async def buscar_por_id(self, factura_id: str) -> Optional[Factura]:
...
El Adaptador (implementación)
# app/infrastructure/persistence/postgres_factura_repository.py
from sqlalchemy.ext.asyncio import AsyncSession
from app.domain.ports.factura_repository import FacturaRepository
from app.domain.entities import Factura
from app.infrastructure.models import FacturaModel
class PostgresFacturaRepository(FacturaRepository):
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def guardar(self, factura: Factura) -> Factura:
model = FacturaModel.from_domain(factura)
self._session.add(model)
await self._session.flush()
return model.to_domain()
async def buscar_por_id(self, factura_id: str) -> Factura | None:
result = await self._session.get(FacturaModel, factura_id)
return result.to_domain() if result else None
El Caso de Uso (núcleo de aplicación)
# app/application/use_cases/crear_factura.py
from dataclasses import dataclass
from app.domain.ports.factura_repository import FacturaRepository
from app.domain.entities import Factura
from decimal import Decimal
@dataclass
class CrearFacturaCommand:
cliente_id: str
total: Decimal
class CrearFacturaUseCase:
def __init__(self, repository: FacturaRepository) -> None:
self._repo = repository
async def execute(self, cmd: CrearFacturaCommand) -> Factura:
factura = Factura.crear(
cliente_id=cmd.cliente_id,
total=cmd.total,
)
return await self._repo.guardar(factura)
El Handler FastAPI (adaptador de entrada)
# app/infrastructure/web/routers/facturas.py
from fastapi import APIRouter, Depends
from app.application.use_cases.crear_factura import (
CrearFacturaUseCase,
CrearFacturaCommand,
)
from app.infrastructure.dependencies import get_factura_use_case
router = APIRouter(prefix="/facturas")
@router.post("/", status_code=201)
async def crear_factura(
body: CrearFacturaRequest,
use_case: CrearFacturaUseCase = Depends(get_factura_use_case),
):
factura = await use_case.execute(
CrearFacturaCommand(
cliente_id=body.cliente_id,
total=body.total,
)
)
return FacturaResponse.from_domain(factura)
Los Trade-offs Reales
Lo que ganas
Testeabilidad sin base de datos. Puedes cambiar PostgresFacturaRepository por una implementación en memoria en los tests:
class InMemoryFacturaRepository(FacturaRepository):
def __init__(self) -> None:
self._store: dict[str, Factura] = {}
async def guardar(self, factura: Factura) -> Factura:
self._store[factura.id] = factura
return factura
async def buscar_por_id(self, factura_id: str) -> Factura | None:
return self._store.get(factura_id)
Los tests de casos de uso corren en milisegundos sin dependencia de base de datos.
Infraestructura intercambiable. Cuando migramos de un monolito a microservicios en Orbis 8, cambiamos los adaptadores HTTP por adaptadores orientados a eventos. La lógica de negocio central no cambió en absoluto.
Lo que pierdes
Velocidad de desarrollo inicial. Estás escribiendo 3–5 veces más archivos que una ruta CRUD simple de FastAPI. models.py, entities.py, repository.py, use_case.py, router.py, schemas.py, dependencies.py. Es mucha ceremonia.
Descubribilidad. Los devs juniors tienen dificultades para trazar el flujo desde la petición HTTP → base de datos. "¿Dónde está la lógica real?" es una pregunta que responderás muchas veces.
Cuándo Usarla de Verdad
| Escenario | ¿Usar Hexagonal? |
|---|---|
| MVP / prototipo | ❌ No — ve rápido, valida la idea |
| Herramienta CRUD interna | ❌ No — la simplicidad gana |
| Dominio de negocio central (facturación, inventario) | ✅ Sí |
| Servicio con múltiples adaptadores (HTTP + CLI + cola) | ✅ Sí |
| Equipo de 3+ personas trabajando en el mismo dominio | ✅ Sí |
| Necesitas cambiar la base de datos más adelante | ✅ Sí |
El Patrón que Yo Realmente Uso
En producción, no aplico hexagonal en todos lados. Uso un híbrido pragmático:
- Hexagonal estricto para el dominio de negocio central (facturas, inventario, reglas de precios)
- FastAPI + SQLAlchemy simple para endpoints CRUD sin lógica de negocio
- Entidades de dominio compartidas — los dataclasses
Factura,Orden,Clienteson siempre Python puro
Esto significa que las partes que cambian están protegidas, y las partes que son estables se mantienen simples.
Conclusión
La Arquitectura Hexagonal es una herramienta, no una religión. La verdadera disciplina está en saber cuándo tu código ha cruzado el umbral donde la estructura extra se paga a sí misma.
Para mí, la señal es: "¿Puedo testear este caso de uso sin una base de datos en ejecución?" Si la respuesta es no, y la lógica no es trivial, es momento de agregar los límites.
Anterior en esta serie: Construyendo un ERP Multi-Tenant con Arquitectura Limpia y Next.js