← Volver al listado de tecnologías

Capítulo 2: Principios de Diseño

Por: Alfred Pennyworth
arquitectura-hexagonaltypescriptprincipios-diseñoinversión-dependencias

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:

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:

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
DominioReglas de negocio, validacionesHTTP, SQL, JSON, frameworks
AplicaciónOrquestar, coordinarDetalles de BD, parseo de requests
InfraestructuraHTTP, BD, APIs externasReglas 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

  1. Migración sin riesgo: Cambia de Express a Fastify sin tocar el dominio
  2. Actualización fácil: Nuevas versiones de frameworks no afectan el core
  3. Testing simple: Testea sin levantar servidores HTTP
  4. 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:

  1. Inversión de Dependencias: El dominio define interfaces, la infraestructura las implementa
  2. Separación de Responsabilidades: Cada capa tiene una única responsabilidad clara
  3. Independencia de Frameworks: Los frameworks son plugins intercambiables
  4. Testabilidad: Facilita testing en todos los niveles sin infraestructura
  5. Sustitución de Liskov: Implementaciones intercambiables de puertos

Estos principios trabajan juntos para crear sistemas:

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 InversionInversión de DependenciasPrincipio donde módulos de alto nivel definen interfaces que bajo nivel implementa
Separation of ConcernsSeparación de ResponsabilidadesCada componente tiene una única responsabilidad bien definida
Framework IndependenceIndependencia de FrameworksLa lógica de negocio no depende de frameworks externos
TestabilityTestabilidadFacilidad para escribir tests en todos los niveles
Liskov SubstitutionSustitución de LiskovLas implementaciones de una interface son intercambiables
MockSimulaciónImplementación falsa de una interface para testing
Unit TestTest UnitarioTest que verifica una unidad de código aislada
Integration TestTest de IntegraciónTest que verifica la integración con infraestructura real

Referencias