Todos los artículos

// Arquitectura que aguanta

Arquitectura Hexagonal en Python: Cuándo Usarla y Cuándo Es Excesiva

Un análisis práctico de la arquitectura Ports & Adapters en servicios Python FastAPI — patrones reales, trade-offs, y la respuesta honesta a '¿realmente necesito esto?'

20 de febrero de 20265 min de lectura

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.

terminal
           ┌─────────────────────────┐
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)

python
# 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)

python
# 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)

python
# 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)

python
# 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:

python
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:

  1. Hexagonal estricto para el dominio de negocio central (facturas, inventario, reglas de precios)
  2. FastAPI + SQLAlchemy simple para endpoints CRUD sin lógica de negocio
  3. Entidades de dominio compartidas — los dataclasses Factura, Orden, Cliente son 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

Referencias rápidas

Vista general

Recursos externos

Incluye recursos adicionales en el frontmatter para que aparezcan aquí.

Más en esta serie

Serie: Arquitectura de Software Avanzada

// newsletter

¿Te sirvió este artículo?

Recibe los siguientes en tu inbox. Sin spam, cancela cuando quieras.

Discusión

Escrito por Jorge Ochoa. ¿Encontraste un error?

Abrir en GitHub