Arquitectura hexagonal (Ports & Adapters)
Idea central
Sección titulada «Idea central»El dominio (las reglas de negocio) no debería depender de qué base de datos usas, qué framework HTTP expone la API, o qué proveedor manda los emails. Esos son detalles de infraestructura, intercambiables. El dominio no es intercambiable — es la razón de ser de la app.
┌── Adapter HTTP (Express, Fastify) ──┐ │ │Adapter DB ───┤ ┌──────────────┐ ├─── Adapter cola (SQS, BullMQ)(Prisma, SQL) │ │ DOMINIO │ │ └─────────►│ (use cases, │◄───────────┘ │ entidades) │ └──────┬───────┘ │ puertos (interfaces) que el dominio define y controlaEl dominio vive en el centro y define los puertos que necesita. La infraestructura vive afuera e implementa esos puertos. La dependencia siempre apunta hacia adentro: infraestructura depende de dominio, nunca al revés.
Puertos y adaptadores
Sección titulada «Puertos y adaptadores»- Puerto — una interfaz que el dominio define, en sus propios términos. Ej:
interface UserRepository { findById(id: string): Promise<User | null> }. - Adaptador — una implementación concreta de un puerto. Ej:
PrismaUserRepository implements UserRepository, oInMemoryUserRepository implements UserRepositorypara tests.
El dominio nunca importa PrismaUserRepository. Solo conoce UserRepository.
Ejemplo: un caso de uso que no sabe qué ORM existe
Sección titulada «Ejemplo: un caso de uso que no sabe qué ORM existe»export interface UserRepository { findById(id: string): Promise<User | null> save(user: User): Promise<void>}import type { UserRepository } from "../ports/user-repository"
export async function deactivateUser(repo: UserRepository, userId: string) { const user = await repo.findById(userId) if (!user) throw new Error("Usuario no encontrado")
user.deactivate() // regla de negocio, vive en la entidad await repo.save(user)}El caso de uso recibe el repositorio por parámetro, tipado por la interfaz — no importa @prisma/client en ningún lado. Eso permite testearlo así:
const repo = new InMemoryUserRepository([existingUser])
await deactivateUser(repo, existingUser.id)
expect(repo.findById(existingUser.id)).resolves.toMatchObject({ active: false })Sin base de datos real, sin mocks de framework, sin levantar nada — solo el dominio y una implementación en memoria del puerto.
Cuándo NO usar esto
Sección titulada «Cuándo NO usar esto»Es over-engineering para un CRUD chico. Definir puertos, adaptadores y una carpeta domain/ separada para una app que solo lee y escribe filas sin reglas de negocio reales es puro ceremonial — más archivos, misma funcionalidad.
Vale la pena cuando el dominio tiene reglas que sobreviven cambios de infraestructura: lógica que seguiría siendo cierta aunque mañana cambies de Postgres a Mongo, o de REST a gRPC. Si esa lógica no existe o es trivial, layered architecture (ver Arquitectura en capas) alcanza y sobra.