Implementa la arquitectura hexagonal (ports and adapters) en tu aplicación web para desacoplar la lógica de negocio de los frameworks, bases de datos y servicios externos. Con la estructura de carpetas, los patrones de repositorio y caso de uso, y cómo migrar desde una arquitectura acoplada sin parar el desarrollo.
Cuándo usarlo: Arquitectura hexagonal, clean architecture, DDD, ports and adapters, testing
Herramienta recomendada: Claude
Eres un Software Architect con experiencia implementando arquitecturas hexagonales en aplicaciones Node.js, Python y PHP que han pasado de código imposible de testear a suites de tests con 80%+ de cobertura y migración de infraestructura sin reescribir la lógica de negocio. Stack: - Lenguaje/framework: [Node.js+Express / Python+FastAPI / PHP+Laravel / otro] - Estado actual: [arquitectura acoplada en controladores / MVC sin capa de dominio / queremos mejorar testabilidad] - Tamaño del proyecto: [pequeño <10k LOC / medio 10-50k LOC / grande >50k LOC] - Objetivo: [hacer el código testeable / poder cambiar la BD sin tocar lógica / preparar para escalar el equipo] ## Arquitectura Hexagonal — [Tu proyecto] ### 🧠 El problema que resuelve la arquitectura hexagonal **El problema del código acoplado:** ```javascript // ❌ El controlador hace todo: HTTP + lógica + base de datos class UserController { async register(req, res) { // Validación del request HTTP if (!req.body.email) return res.status(400).json({ error: 'Email required' }) // Lógica de negocio mezclada con la infra const existingUser = await db.query('SELECT * FROM users WHERE email = ?', [req.body.email]) if (existingUser) return res.status(409).json({ error: 'Email already exists' }) // Acceso directo a la base de datos const user = await db.query('INSERT INTO users (email, ...) VALUES (?, ...)', [...]) // Envío de email directamente await sendgrid.send({ to: req.body.email, ... }) res.json({ user }) } } ``` **El resultado:** no puedes testear la lógica sin una base de datos real y un servidor HTTP activo. ### 🏗️ La estructura de la arquitectura hexagonal ``` src/ ├── domain/ # El núcleo — sin dependencias externas │ ├── entities/ # Las entidades del negocio │ │ └── User.js │ ├── repositories/ # Las interfaces (ports) │ │ └── UserRepository.js # define el contrato, sin implementación │ └── use-cases/ # La lógica de negocio pura │ └── RegisterUser.js │ ├── application/ # Orquestación │ └── services/ │ └── UserService.js │ └── infrastructure/ # Los adapters — implementaciones concretas ├── http/ # El adapter HTTP (Express, Fastify...) │ └── controllers/ │ └── UserController.js ├── persistence/ # El adapter de base de datos │ └── PostgresUserRepository.js └── email/ # El adapter de email └── SendgridEmailService.js ``` ### 📐 Los puertos y adaptadores en código **El puerto (la interfaz — en el dominio):** ```javascript // domain/repositories/UserRepository.js // No importa nada externo — es una interfaz pura export class UserRepository { async findByEmail(email) { throw new Error('Not implemented') } async save(user) { throw new Error('Not implemented') } } ``` **El caso de uso (lógica de negocio pura — en el dominio):** ```javascript // domain/use-cases/RegisterUser.js export class RegisterUser { constructor(userRepository, emailService) { this.userRepository = userRepository // inyectado — puede ser real o mock this.emailService = emailService } async execute({ email, password }) { const existing = await this.userRepository.findByEmail(email) if (existing) throw new Error('Email already registered') const user = new User({ email, password: await hash(password) }) await this.userRepository.save(user) await this.emailService.sendWelcome(user) return user } } // Este caso de uso es 100% testeable sin base de datos ni HTTP ``` **El adaptador de base de datos:** ```javascript // infrastructure/persistence/PostgresUserRepository.js import { UserRepository } from '../../domain/repositories/UserRepository.js' export class PostgresUserRepository extends UserRepository { constructor(db) { super(); this.db = db } async findByEmail(email) { return this.db.query('SELECT * FROM users WHERE email = $1', [email]) } async save(user) { return this.db.query('INSERT INTO users ... VALUES ...', [...]) } } ``` **El test — sin infraestructura real:** ```javascript // tests/use-cases/RegisterUser.test.js import { RegisterUser } from '../../domain/use-cases/RegisterUser.js' test('registers a new user', async () => { const mockRepo = { findByEmail: jest.fn().mockResolvedValue(null), // no está registrado save: jest.fn().mockResolvedValue(true), } const mockEmail = { sendWelcome: jest.fn() } const useCase = new RegisterUser(mockRepo, mockEmail) const user = await useCase.execute({ email: 'test@test.com', password: '123456' }) expect(mockRepo.save).toHaveBeenCalledWith(expect.objectContaining({ email: 'test@test.com' })) expect(mockEmail.sendWelcome).toHaveBeenCalledWith(user) }) // Sin base de datos. Sin HTTP. En milisegundos. ``` ### 📅 Cómo migrar desde arquitectura acoplada sin parar el desarrollo La estrategia de "strangler fig" para migrar módulo a módulo sin una reescritura big bang.