La arquitectura hexagonal (también conocida como Ports & Adapters) suele explicarse en teoría, pero pocas veces se muestra con un ejemplo tangible. En este artículo vamos a construir, paso a paso, una aplicación en Flask que expone un puerto para consumir distintos proveedores de IA (OpenAI, Anthropic/Claude, o cualquier otro) sin que el dominio de la aplicación dependa de ninguno de ellos en particular.
El objetivo es demostrar empíricamente que, si el diseño está bien hecho, cambiar de proveedor de IA debería ser tan simple como cambiar una línea de configuración, sin tocar la lógica de negocio.
¿Qué es la arquitectura hexagonal y por qué aplicarla en Flask?
La arquitectura hexagonal propone separar el núcleo de dominio (las reglas de negocio) de los detalles técnicos (frameworks, bases de datos, APIs externas). Esa separación se logra mediante puertos (interfaces que definen qué necesita el dominio) y adaptadores (implementaciones concretas que satisfacen esos puertos).
Ya habíamos analizado este patrón en profundidad al estudiar cómo Netflix lo aplica en producción.
Artículo Relacionado: La ingeniería detrás de Netflix: su arquitectura hexagonal
Flask es un framework minimalista, lo cual lo hace ideal para este ejercicio: no impone una estructura de carpetas ni un ORM específico, así que nosotros decidimos exactamente dónde viven el dominio, los puertos y los adaptadores.
El problema: acoplar tu aplicación a un proveedor de IA específico
Es común ver código Flask donde una ruta llama directamente al SDK de OpenAI o de Anthropic. Funciona, pero genera un acoplamiento fuerte: si mañana quieres migrar a otro proveedor, comparar costos, o hacer fallback entre modelos, terminas modificando la lógica de negocio mezclada con detalles de infraestructura.
Este es exactamente el tipo de problema que los principios SOLID —en particular la inversión de dependencias— buscan resolver.
Artículo Relacionado: Principios SOLID con Python: Single Responsibility
La solución: un puerto para el proveedor de IA
Definimos una interfaz abstracta que representa “lo que el dominio necesita de un proveedor de IA”, sin saber nada sobre HTTP, tokens de API ni formatos de respuesta propietarios.
# domain/ports/ai_provider_port.py
from abc import ABC, abstractmethod
class AIProviderPort(ABC):
@abstractmethod
def generate_response(self, prompt: str, system: str | None = None) -> str:
"""Genera una respuesta de texto a partir de un prompt."""
raise NotImplementedError
Este puerto vive en el dominio y no importa ni Flask, ni requests, ni ningún SDK externo. Es Python puro.
Implementando la arquitectura hexagonal en Flask paso a paso
Con el puerto definido, la estructura de carpetas del proyecto queda así:
proyecto/
├── domain/
│ ├── ports/
│ │ └── ai_provider_port.py
│ └── services/
│ └── chat_service.py
├── adapters/
│ ├── driven/
│ │ ├── openai_adapter.py
│ │ └── anthropic_adapter.py
│ └── driving/
│ └── flask_routes.py
├── config.py
└── app.py
El caso de uso (servicio de dominio) solo conoce el puerto, nunca una implementación concreta:
# domain/services/chat_service.py
from domain.ports.ai_provider_port import AIProviderPort
class ChatService:
def __init__(self, ai_provider: AIProviderPort):
self._ai_provider = ai_provider
def responder(self, pregunta: str) -> str:
system_prompt = "Eres un asistente técnico conciso."
return self._ai_provider.generate_response(pregunta, system=system_prompt)
Fíjate que ChatService recibe el puerto por inyección de dependencias en el constructor. No sabe, ni le importa, si detrás hay OpenAI, Claude o un modelo local.
Adaptadores concretos: OpenAI y Anthropic Claude
Ahora implementamos dos adaptadores que cumplen el contrato del puerto. Si ya integraste Claude en un proyecto Flask anteriormente, esta parte te resultará muy familiar.
Artículo Relacionado: Integración de Claude API con Flask: tutorial paso a paso
# adapters/driven/anthropic_adapter.py
import anthropic
from domain.ports.ai_provider_port import AIProviderPort
class AnthropicAdapter(AIProviderPort):
def __init__(self, api_key: str, model: str = "claude-sonnet-4-6"):
self._client = anthropic.Anthropic(api_key=api_key)
self._model = model
def generate_response(self, prompt: str, system: str | None = None) -> str:
message = self._client.messages.create(
model=self._model,
max_tokens=500,
system=system or "",
messages=[{"role": "user", "content": prompt}],
)
return message.content[0].text
# adapters/driven/openai_adapter.py
from openai import OpenAI
from domain.ports.ai_provider_port import AIProviderPort
class OpenAIAdapter(AIProviderPort):
def __init__(self, api_key: str, model: str = "gpt-4o"):
self._client = OpenAI(api_key=api_key)
self._model = model
def generate_response(self, prompt: str, system: str | None = None) -> str:
messages = []
if system:
messages.append({"role": "system", "content": system})
messages.append({"role": "user", "content": prompt})
completion = self._client.chat.completions.create(
model=self._model,
messages=messages,
)
return completion.choices[0].message.content
Ambos adaptadores implementan exactamente el mismo método generate_response. Desde la perspectiva del dominio, son intercambiables.
El adaptador Flask: exponiendo el caso de uso vía HTTP
Flask actúa como adaptador de entrada (driving adapter): traduce una petición HTTP en una llamada al caso de uso del dominio.
# adapters/driving/flask_routes.py
from flask import Blueprint, request, jsonify
from domain.services.chat_service import ChatService
chat_bp = Blueprint("chat", __name__)
def init_chat_routes(chat_service: ChatService):
@chat_bp.route("/api/chat", methods=["POST"])
def chat():
data = request.get_json()
pregunta = data.get("pregunta", "")
respuesta = chat_service.responder(pregunta)
return jsonify({"respuesta": respuesta})
return chat_bp
La ruta no sabe nada de OpenAI ni de Anthropic; solo conversa con ChatService, que a su vez conversa con el puerto. Si ya construiste un mantenedor CRUD con Flask, notarás que el patrón de blueprints es el mismo, solo que aquí lo aprovechamos para mantener la separación de capas.
Artículo Relacionado: Tutorial de Flask con Python: crear un mantenedor
Composición: el punto donde todo se conecta
El único lugar donde el dominio “se entera” de qué adaptador se está usando es en el punto de entrada de la aplicación, mediante configuración:
# app.py
import os
from flask import Flask
from adapters.driving.flask_routes import init_chat_routes
from adapters.driven.anthropic_adapter import AnthropicAdapter
from adapters.driven.openai_adapter import OpenAIAdapter
from domain.services.chat_service import ChatService
def create_app():
app = Flask(__name__)
proveedor = os.getenv("AI_PROVIDER", "anthropic")
if proveedor == "openai":
ai_provider = OpenAIAdapter(api_key=os.getenv("OPENAI_API_KEY"))
else:
ai_provider = AnthropicAdapter(api_key=os.getenv("ANTHROPIC_API_KEY"))
chat_service = ChatService(ai_provider)
app.register_blueprint(init_chat_routes(chat_service))
return app
if __name__ == "__main__":
create_app().run(debug=True)
Cambiar de proveedor es literalmente cambiar una variable de entorno. No se toca el dominio, no se toca la ruta Flask, y ningún test unitario del caso de uso necesita reescribirse.
Ventajas demostradas empíricamente con este ejemplo
- Testabilidad: puedes crear un
FakeAIProviderque implemente el puerto y probarChatServicesin llamar a ninguna API real. - Flexibilidad de proveedor: agregar un tercer adaptador (por ejemplo, un modelo local con Ollama) no requiere modificar el dominio.
- Resiliencia: se puede envolver el puerto en un adaptador con lógica de fallback entre proveedores, algo especialmente útil si además trabajas con búsqueda semántica y bases de datos vectoriales.
Artículo Relacionado: Bases de datos vectoriales en Python
Este mismo patrón de puertos y adaptadores es, en esencia, el que sostiene arquitecturas de microservicios más grandes, donde cada servicio expone contratos claros hacia el exterior.
Artículo Relacionado: ¿Qué son y para qué sirven los microservicios?
Conclusión
Construir un ejemplo concreto de arquitectura hexagonal en Flask con un puerto para proveedores de IA no es solo un ejercicio académico: es una defensa práctica contra el vendor lock-in y un camino directo hacia código más testeable y mantenible. Si te interesa profundizar en el porqué de este patrón antes de aplicarlo, vale la pena revisar también su primo cercano.
Artículo Relacionado: ¿Qué es Clean Architecture y cuáles son sus beneficios y desventajas?
Para profundizar en los detalles técnicos de cada SDK, puedes revisar la documentación oficial de Flask, de la API de OpenAI y de la API de Anthropic, así como el módulo abc de Python, que es la base para definir los puertos como clases abstractas.