Capítulo 2: Principios de Diseño
Capítulo 2: Principios de Diseño
Introducción
La Arquitectura Hexagonal no es solo una estructura de carpetas, sino la aplicación de principios de diseño fundamentales. En este capítulo exploraremos los principios que hacen posible la independencia, testabilidad y mantenibilidad del patrón.
1. Inversión de Dependencias (DIP)
El Principio
El Dependency Inversion Principle es el pilar de la arquitectura hexagonal. Establece:
Los módulos de alto nivel no deben depender de módulos de bajo nivel. Ambos deben depender de abstracciones.
Traducido a hexagonal:
- El dominio (alto nivel) no depende de infraestructura (bajo nivel)
- Ambos dependen de interfaces (puertos)
Arquitectura Tradicional (Dependencias hacia abajo)
┌─────────────┐
│ UI │
└──────┬──────┘
↓ depende de
┌──────▼──────┐
│ Negocio │
└──────┬──────┘
↓ depende de
┌──────▼──────┐
│ Base │
│ Datos │
└─────────────┘
Problema: Si cambias la BD, debes cambiar el negocio.
Arquitectura Hexagonal (Dependencias invertidas)
┌─────────────┐ ┌─────────────┐
│ UI │ │ Base │
└──────┬──────┘ │ Datos │
│ └──────┬──────┘
↓ implementa ↓ implementa
┌──────▼──────┐ ┌──────▼──────┐
│ Puerto │◄─── usa ─────│ Puerto │
│ (Interface)│ │ (Interface)│
└──────▲──────┘ └──────▲──────┘
│ define │ define
│ │
└─────────┬──────────────────┘
│
┌──────▼──────┐
│ Dominio │
│ Negocio │
└─────────────┘
Ventaja: El dominio no conoce UI ni BD. Define interfaces y otros las implementan.
Ejemplo de Inversión
// ❌ SIN inversión de dependencias
class UserService {
private db = new MySQLDatabase(); // ¡Dependencia directa!
async createUser(email: string) {
await this.db.query('INSERT INTO users...');
}
}
// No puedes cambiar a PostgreSQL sin reescribir UserService
// ✅ CON inversión de dependencias
interface UserRepository {
save(user: User): Promise<void>;
}
class UserService {
constructor(private repo: UserRepository) {} // ¡Depende de abstracción!
async createUser(email: string) {
const user = User.create(email);
await this.repo.save(user);
}
}
// Ahora puedes inyectar cualquier implementación
const service1 = new UserService(new MySQLRepository());
const service2 = new UserService(new PostgresRepository());
const service3 = new UserService(new InMemoryRepository()); // Para tests!
Beneficio Clave
La inversión de dependencias permite:
- Testear el dominio sin bases de datos reales
- Cambiar tecnologías sin afectar la lógica de negocio
- Desarrollar el dominio antes que la infraestructura
2. Separación de Responsabilidades (SoC)
El Principio
Cada componente debe tener una única responsabilidad bien definida.
En arquitectura hexagonal, las responsabilidades están claramente separadas:
Responsabilidades por Capa
// ========== DOMINIO ==========
// Responsabilidad: Reglas de negocio
class User {
private constructor(
readonly id: string,
private email: string,
private status: UserStatus
) {}
static create(id: string, email: string): User {
if (!email.includes('@')) {
throw new Error('Email inválido');
}
return new User(id, email, 'active');
}
suspend(): void {
if (this.status === 'suspended') {
throw new Error('Usuario ya suspendido');
}
this.status = 'suspended';
}
}
// ========== APLICACIÓN ==========
// Responsabilidad: Orquestar casos de uso
class SuspendUserUseCase {
constructor(private userRepo: UserRepository) {}
async execute(userId: string): Promise<void> {
const user = await this.userRepo.findById(userId);
if (!user) throw new Error('Usuario no encontrado');
user.suspend();
await this.userRepo.save(user);
}
}
// ========== INFRAESTRUCTURA ==========
// Responsabilidad: Detalles técnicos (HTTP, BD, etc)
class UserController {
constructor(private suspendUser: SuspendUserUseCase) {}
async handle(req: Request, res: Response): Promise<void> {
const userId = req.params.id;
await this.suspendUser.execute(userId);
res.status(200).json({ message: 'Usuario suspendido' });
}
}
Qué NO debe hacer cada capa
| Capa | ✅ SÍ hace | ❌ NO debe hacer |
|---|---|---|
| Dominio | Reglas de negocio, validaciones | HTTP, SQL, JSON, frameworks |
| Aplicación | Orquestar, coordinar | Detalles de BD, parseo de requests |
| Infraestructura | HTTP, BD, APIs externas | Reglas de negocio |
Ejemplo de Violación
// ❌ Dominio con responsabilidades de infraestructura
class User {
async save() {
// ¡NO! El dominio no debe saber de bases de datos
await db.query('INSERT INTO users...');
}
toJSON() {
// ¡NO! El dominio no debe saber de formato HTTP
return { id: this.id, email: this.email };
}
}
// ✅ Responsabilidades separadas
class User {
// Solo lógica de negocio
changeEmail(newEmail: string): void {
if (!newEmail.includes('@')) throw new Error('Email inválido');
this.email = newEmail;
}
}
// La infraestructura maneja persistencia
class PostgresUserRepository {
async save(user: User): Promise<void> {
await this.db.query('INSERT INTO users...');
}
}
// La infraestructura maneja serialización
class UserController {
toJSON(user: User) {
return { id: user.id, email: user.email };
}
}
3. Independencia de Frameworks
El Principio
La lógica de negocio no debe depender de frameworks externos. Los frameworks son herramientas, no arquitectura.
Frameworks como Plugins
En hexagonal, los frameworks son adaptadores intercambiables:
┌──────────┐
│ Dominio │
└────┬─────┘
│
┌───────┴───────┐
│ Interfaces │
└───────┬───────┘
│
┌─────────┴─────────┐
│ │
┌───▼────┐ ┌────▼───┐
│Express │ │Fastify │ ← Intercambiables
└────────┘ └────────┘
Ejemplo: Cambio de Framework
// ========== DOMINIO (no cambia) ==========
class User {
static create(email: string): User {
return new User(crypto.randomUUID(), email);
}
}
interface UserService {
createUser(email: string): Promise<User>;
}
class CreateUserUseCase implements UserService {
constructor(private repo: UserRepository) {}
async createUser(email: string): Promise<User> {
const user = User.create(email);
await this.repo.save(user);
return user;
}
}
// ========== ADAPTADOR 1: Express ==========
class ExpressUserController {
constructor(private service: UserService) {}
async create(req: Request, res: Response) {
const user = await this.service.createUser(req.body.email);
res.json({ id: user.id });
}
}
// ========== ADAPTADOR 2: Fastify ==========
class FastifyUserController {
constructor(private service: UserService) {}
async create(req: FastifyRequest, reply: FastifyReply) {
const user = await this.service.createUser(req.body.email);
reply.send({ id: user.id });
}
}
// ========== ADAPTADOR 3: CLI ==========
class CliUserController {
constructor(private service: UserService) {}
async create(args: string[]) {
const user = await this.service.createUser(args[0]);
console.log(`Usuario creado: ${user.id}`);
}
}
Nota: El dominio y caso de uso son exactamente iguales para Express, Fastify o CLI.
Beneficios
- Migración sin riesgo: Cambia de Express a Fastify sin tocar el dominio
- Actualización fácil: Nuevas versiones de frameworks no afectan el core
- Testing simple: Testea sin levantar servidores HTTP
- Longevidad: Tu dominio sobrevive a frameworks obsoletos
4. Testabilidad
El Principio
La arquitectura debe facilitar el testing en todos los niveles.
Pirámide de Testing en Hexagonal
/\
/ \ ← Tests E2E (Pocos)
/────\
/ \ ← Tests Integración (Algunos)
/────────\
/ \ ← Tests Unitarios (Muchos)
/────────────\
Tests Unitarios (Dominio)
Sin infraestructura, solo lógica pura:
describe('User', () => {
test('debería crear un usuario válido', () => {
const user = User.create('[email protected]');
expect(user.email).toBe('[email protected]');
});
test('debería rechazar email inválido', () => {
expect(() => User.create('invalid')).toThrow('Email inválido');
});
test('debería suspender usuario activo', () => {
const user = User.create('[email protected]');
user.suspend();
expect(user.status).toBe('suspended');
});
});
Tests de Casos de Uso (Aplicación)
Con mocks de repositorios:
describe('CreateUserUseCase', () => {
test('debería crear y guardar usuario', async () => {
const mockRepo: UserRepository = {
save: jest.fn(),
findById: jest.fn(),
};
const useCase = new CreateUserUseCase(mockRepo);
const user = await useCase.execute('[email protected]');
expect(mockRepo.save).toHaveBeenCalledWith(user);
expect(user.email).toBe('[email protected]');
});
});
Tests de Integración (Adaptadores)
Con infraestructura real:
describe('PostgresUserRepository', () => {
let db: TestDatabase;
let repo: PostgresUserRepository;
beforeAll(async () => {
db = await TestDatabase.create();
repo = new PostgresUserRepository(db.pool);
});
test('debería guardar y recuperar usuario', async () => {
const user = User.create('[email protected]');
await repo.save(user);
const retrieved = await repo.findById(user.id);
expect(retrieved?.email).toBe('[email protected]');
});
});
Ventaja: Testing Aislado
// Testear SOLO el dominio (sin BD, sin HTTP)
test('lógica de negocio', () => {
const user = User.create('[email protected]');
expect(user.isActive()).toBe(true);
});
// Testear SOLO el caso de uso (con mock)
test('orquestación', async () => {
const mockRepo = new InMemoryUserRepository();
const useCase = new CreateUserUseCase(mockRepo);
await useCase.execute('[email protected]');
});
// Testear SOLO el adaptador (con infraestructura real)
test('persistencia', async () => {
const repo = new PostgresUserRepository(realDb);
await repo.save(user);
});
5. Principio de Sustitución de Liskov (LSP)
Aplicado a Puertos
Cualquier implementación de un puerto debe ser intercambiable:
interface UserRepository {
save(user: User): Promise<void>;
findById(id: string): Promise<User | null>;
}
// Todas estas implementaciones son intercambiables
class PostgresUserRepository implements UserRepository { /* ... */ }
class MongoUserRepository implements UserRepository { /* ... */ }
class InMemoryUserRepository implements UserRepository { /* ... */ }
class RedisUserRepository implements UserRepository { /* ... */ }
// El caso de uso funciona con CUALQUIERA
const useCase = new CreateUserUseCase(repository); // ¡No importa cuál!
Ejemplo de Violación
// ❌ Implementación que rompe el contrato
class BadRepository implements UserRepository {
async save(user: User): Promise<void> {
if (user.email.includes('test')) {
throw new Error('No guardo emails de test'); // ¡Comportamiento inesperado!
}
// ...
}
}
// Esto rompe el principio porque el caso de uso espera que save() funcione siempre
6. Ejemplo Completo: Todos los Principios Juntos
// ========== DOMINIO ==========
// SRP: Solo reglas de negocio
class Order {
private constructor(
readonly id: string,
private status: 'pending' | 'confirmed' | 'cancelled'
) {}
static create(): Order {
return new Order(crypto.randomUUID(), 'pending');
}
confirm(): void {
if (this.status !== 'pending') {
throw new Error('Solo se pueden confirmar pedidos pendientes');
}
this.status = 'confirmed';
}
}
// ========== PUERTOS ==========
// DIP: Abstracciones definidas por el dominio
interface OrderRepository {
save(order: Order): Promise<void>;
findById(id: string): Promise<Order | null>;
}
interface NotificationService {
send(email: string, message: string): Promise<void>;
}
// ========== APLICACIÓN ==========
// SRP: Solo orquestación
class ConfirmOrderUseCase {
constructor(
private orderRepo: OrderRepository,
private notifications: NotificationService
) {}
async execute(orderId: string, userEmail: string): Promise<void> {
const order = await this.orderRepo.findById(orderId);
if (!order) throw new Error('Pedido no encontrado');
order.confirm();
await this.orderRepo.save(order);
await this.notifications.send(userEmail, 'Pedido confirmado');
}
}
// ========== INFRAESTRUCTURA ==========
// Independencia de frameworks: Adaptadores intercambiables
class PostgresOrderRepository implements OrderRepository {
constructor(private db: Pool) {}
async save(order: Order): Promise<void> {
await this.db.query('UPDATE orders SET status = $1 WHERE id = $2',
[order.status, order.id]);
}
async findById(id: string): Promise<Order | null> {
const result = await this.db.query('SELECT * FROM orders WHERE id = $1', [id]);
return result.rows[0] ? Order.fromDB(result.rows[0]) : null;
}
}
class EmailNotificationService implements NotificationService {
async send(email: string, message: string): Promise<void> {
await emailClient.send({ to: email, body: message });
}
}
// ========== TESTS ==========
// Testabilidad: Sin infraestructura
test('confirmar pedido', async () => {
const mockRepo = new InMemoryOrderRepository();
const mockNotif = new MockNotificationService();
const useCase = new ConfirmOrderUseCase(mockRepo, mockNotif);
await useCase.execute('order-123', '[email protected]');
expect(mockNotif.wasCalled).toBe(true);
});
7. Conclusión
Los principios de diseño de la arquitectura hexagonal son:
- Inversión de Dependencias: El dominio define interfaces, la infraestructura las implementa
- Separación de Responsabilidades: Cada capa tiene una única responsabilidad clara
- Independencia de Frameworks: Los frameworks son plugins intercambiables
- Testabilidad: Facilita testing en todos los niveles sin infraestructura
- Sustitución de Liskov: Implementaciones intercambiables de puertos
Estos principios trabajan juntos para crear sistemas:
- ✅ Mantenibles: Cambios aislados
- ✅ Testeables: Sin infraestructura
- ✅ Flexibles: Cambio de tecnologías sin dolor
- ✅ Longevos: Sobreviven a frameworks obsoletos
En el próximo capítulo exploraremos el modelado del dominio: entidades, value objects y agregados.
Glosario del Capítulo
| Término (Inglés) | Término (Español) | Definición |
|---|---|---|
| Dependency Inversion | Inversión de Dependencias | Principio donde módulos de alto nivel definen interfaces que bajo nivel implementa |
| Separation of Concerns | Separación de Responsabilidades | Cada componente tiene una única responsabilidad bien definida |
| Framework Independence | Independencia de Frameworks | La lógica de negocio no depende de frameworks externos |
| Testability | Testabilidad | Facilidad para escribir tests en todos los niveles |
| Liskov Substitution | Sustitución de Liskov | Las implementaciones de una interface son intercambiables |
| Mock | Simulación | Implementación falsa de una interface para testing |
| Unit Test | Test Unitario | Test que verifica una unidad de código aislada |
| Integration Test | Test de Integración | Test que verifica la integración con infraestructura real |