← Volver al listado de tecnologías

Capítulo 5: Puertos Secundarios (Driven)

Por: Alfred Pennyworth
arquitectura-hexagonaltypescriptrepositoriosadaptadorespersistencia

Capítulo 5: Puertos Secundarios (Driven)

Introducción

Los puertos secundarios (driven ports) son las interfaces que el dominio necesita del mundo exterior. Son la forma en que el dominio se comunica con bases de datos, APIs externas, sistemas de archivos, etc., sin depender de ellos.

1. ¿Qué son los Puertos Secundarios?

Definición

Un puerto secundario es una interface que:

Flujo de Invocación

Caso de Uso → Puerto (Interface) → Adaptador → Tecnología Externa
  (UseCase)     (Interface)      (Repository)    (PostgreSQL/HTTP/etc)

Diferencia con Puertos Primarios

AspectoPuerto PrimarioPuerto Secundario
DirecciónExterior → DominioDominio → Exterior
NombreDriving PortDriven Port
Quién usaAdaptadores usan el dominioDominio usa adaptadores
EjemploHTTP ControllerDatabase Repository

2. Tipos de Puertos Secundarios

2.1 Repositorios

Abstraen la persistencia de agregados:

// application/ports/output/user.repository.ts
interface UserRepository {
  save(user: User): Promise<void>;
  findById(id: string): Promise<User | null>;
  findByEmail(email: string): Promise<User | null>;
  delete(id: string): Promise<void>;
}

2.2 Servicios Externos

Abstraen comunicación con sistemas externos:

// application/ports/output/email.service.ts
interface EmailService {
  sendWelcomeEmail(to: string, name: string): Promise<void>;
  sendPasswordReset(to: string, token: string): Promise<void>;
}

// application/ports/output/payment.service.ts
interface PaymentService {
  charge(amount: Money, cardToken: string): Promise<PaymentResult>;
  refund(transactionId: string): Promise<void>;
}

2.3 Proveedores de Identidad

Abstraen generación de identificadores:

// application/ports/output/id-generator.ts
interface IdGenerator {
  generate(): string;
}

2.4 Fecha/Hora

Abstraen el tiempo (importante para testing):

// application/ports/output/clock.ts
interface Clock {
  now(): Date;
}

3. Patrón Repository

Principios del Repository

  1. Por agregado: Un repository por raíz de agregado
  2. Abstracción: El dominio no conoce la BD
  3. Colección: Actúa como una colección en memoria
  4. Solo la raíz: Solo se persiste la raíz del agregado

Diseño de Repository

// domain/user.entity.ts
class User {
  private constructor(
    private readonly id: string,
    private email: Email,
    private status: UserStatus
  ) {}

  // ... métodos de negocio
}

// application/ports/output/user.repository.ts
interface UserRepository {
  // Guardar (crear o actualizar)
  save(user: User): Promise<void>;

  // Buscar por ID
  findById(id: string): Promise<User | null>;

  // Buscar por criterio único
  findByEmail(email: string): Promise<User | null>;

  // Buscar múltiples
  findByStatus(status: UserStatus): Promise<User[]>;

  // Búsqueda con paginación
  findAll(options: {
    skip: number;
    take: number;
  }): Promise<{ items: User[]; total: number }>;

  // Eliminar
  delete(id: string): Promise<void>;

  // Verificación de existencia
  exists(email: string): Promise<boolean>;
}

Operaciones Comunes

interface Repository<T> {
  // CRUD básico
  save(entity: T): Promise<void>;
  findById(id: string): Promise<T | null>;
  delete(id: string): Promise<void>;

  // Búsqueda
  findAll(): Promise<T[]>;
  findByCriteria(criteria: Criteria): Promise<T[]>;

  // Verificación
  exists(id: string): Promise<boolean>;
}

4. Implementación de Adaptadores

4.1 Adaptador In-Memory (Testing)

// infrastructure/persistence/in-memory-user.repository.ts
class InMemoryUserRepository implements UserRepository {
  private users: Map<string, User> = new Map();

  async save(user: User): Promise<void> {
    this.users.set(user.userId, user);
  }

  async findById(id: string): Promise<User | null> {
    return this.users.get(id) ?? null;
  }

  async findByEmail(email: string): Promise<User | null> {
    for (const user of this.users.values()) {
      if (user.userEmail === email) {
        return user;
      }
    }
    return null;
  }

  async delete(id: string): Promise<void> {
    this.users.delete(id);
  }

  async exists(email: string): Promise<boolean> {
    return (await this.findByEmail(email)) !== null;
  }

  // Útil para testing
  clear(): void {
    this.users.clear();
  }
}

4.2 Adaptador PostgreSQL

// infrastructure/persistence/postgres-user.repository.ts
import { Pool } from 'pg';

class PostgresUserRepository implements UserRepository {
  constructor(private readonly pool: Pool) {}

  async save(user: User): Promise<void> {
    const query = `
      INSERT INTO users (id, email, name, status, created_at)
      VALUES ($1, $2, $3, $4, $5)
      ON CONFLICT (id) DO UPDATE
      SET email = $2, name = $3, status = $4
    `;

    await this.pool.query(query, [
      user.userId,
      user.userEmail,
      user.userName,
      user.userStatus,
      user.createdAt
    ]);
  }

  async findById(id: string): Promise<User | null> {
    const result = await this.pool.query(
      'SELECT * FROM users WHERE id = $1',
      [id]
    );

    if (result.rows.length === 0) return null;

    return this.toDomain(result.rows[0]);
  }

  async findByEmail(email: string): Promise<User | null> {
    const result = await this.pool.query(
      'SELECT * FROM users WHERE email = $1',
      [email]
    );

    if (result.rows.length === 0) return null;

    return this.toDomain(result.rows[0]);
  }

  async delete(id: string): Promise<void> {
    await this.pool.query('DELETE FROM users WHERE id = $1', [id]);
  }

  async exists(email: string): Promise<boolean> {
    const result = await this.pool.query(
      'SELECT EXISTS(SELECT 1 FROM users WHERE email = $1)',
      [email]
    );
    return result.rows[0].exists;
  }

  // Mapeo DB → Dominio
  private toDomain(row: any): User {
    return User.fromPersistence({
      id: row.id,
      email: row.email,
      name: row.name,
      status: row.status,
      createdAt: row.created_at
    });
  }
}

4.3 Adaptador MongoDB

// infrastructure/persistence/mongo-user.repository.ts
import { Collection, Db } from 'mongodb';

class MongoUserRepository implements UserRepository {
  private collection: Collection;

  constructor(db: Db) {
    this.collection = db.collection('users');
  }

  async save(user: User): Promise<void> {
    await this.collection.updateOne(
      { _id: user.userId },
      {
        $set: {
          email: user.userEmail,
          name: user.userName,
          status: user.userStatus,
          createdAt: user.createdAt
        }
      },
      { upsert: true }
    );
  }

  async findById(id: string): Promise<User | null> {
    const doc = await this.collection.findOne({ _id: id });
    return doc ? this.toDomain(doc) : null;
  }

  async findByEmail(email: string): Promise<User | null> {
    const doc = await this.collection.findOne({ email });
    return doc ? this.toDomain(doc) : null;
  }

  async delete(id: string): Promise<void> {
    await this.collection.deleteOne({ _id: id });
  }

  async exists(email: string): Promise<boolean> {
    const count = await this.collection.countDocuments({ email });
    return count > 0;
  }

  private toDomain(doc: any): User {
    return User.fromPersistence({
      id: doc._id,
      email: doc.email,
      name: doc.name,
      status: doc.status,
      createdAt: doc.createdAt
    });
  }
}

5. Servicios Externos

5.1 Puerto para Email

// application/ports/output/email.service.ts
interface EmailService {
  send(params: SendEmailParams): Promise<void>;
}

interface SendEmailParams {
  to: string;
  subject: string;
  body: string;
}

5.2 Adaptador con SendGrid

// infrastructure/email/sendgrid-email.service.ts
import sgMail from '@sendgrid/mail';

class SendGridEmailService implements EmailService {
  constructor(apiKey: string) {
    sgMail.setApiKey(apiKey);
  }

  async send(params: SendEmailParams): Promise<void> {
    await sgMail.send({
      to: params.to,
      from: '[email protected]',
      subject: params.subject,
      html: params.body
    });
  }
}

5.3 Adaptador Mock (Testing)

// infrastructure/email/mock-email.service.ts
class MockEmailService implements EmailService {
  public sentEmails: SendEmailParams[] = [];

  async send(params: SendEmailParams): Promise<void> {
    this.sentEmails.push(params);
  }

  clear(): void {
    this.sentEmails = [];
  }

  wasSentTo(email: string): boolean {
    return this.sentEmails.some(e => e.to === email);
  }
}

6. Mapeo: Dominio ↔ Persistencia

Estrategia 1: Método en la Entidad

class User {
  // ...

  // Para persistencia
  toPersistence(): UserPersistence {
    return {
      id: this.id,
      email: this.email.toString(),
      status: this.status,
      createdAt: this.createdAt
    };
  }

  // Desde persistencia
  static fromPersistence(data: UserPersistence): User {
    return new User(
      data.id,
      Email.create(data.email),
      data.status,
      data.createdAt
    );
  }
}

Estrategia 2: Mapper Dedicado

// infrastructure/persistence/user.mapper.ts
class UserMapper {
  static toDomain(row: UserRow): User {
    return User.fromPersistence({
      id: row.id,
      email: row.email,
      name: row.name,
      status: row.status,
      createdAt: new Date(row.created_at)
    });
  }

  static toPersistence(user: User): UserRow {
    return {
      id: user.userId,
      email: user.userEmail,
      name: user.userName,
      status: user.userStatus,
      created_at: user.createdAt.toISOString()
    };
  }
}

// Uso en repository
class PostgresUserRepository implements UserRepository {
  async findById(id: string): Promise<User | null> {
    const row = await this.queryById(id);
    return row ? UserMapper.toDomain(row) : null;
  }
}

7. Transacciones

Puerto para Transacciones

// application/ports/output/transaction.ts
interface TransactionManager {
  runInTransaction<T>(work: () => Promise<T>): Promise<T>;
}

Implementación PostgreSQL

class PostgresTransactionManager implements TransactionManager {
  constructor(private readonly pool: Pool) {}

  async runInTransaction<T>(work: () => Promise<T>): Promise<T> {
    const client = await this.pool.connect();

    try {
      await client.query('BEGIN');
      const result = await work();
      await client.query('COMMIT');
      return result;
    } catch (error) {
      await client.query('ROLLBACK');
      throw error;
    } finally {
      client.release();
    }
  }
}

Uso en Caso de Uso

class TransferMoneyUseCase {
  constructor(
    private readonly accountRepo: AccountRepository,
    private readonly transactionManager: TransactionManager
  ) {}

  async execute(command: TransferMoneyCommand): Promise<void> {
    await this.transactionManager.runInTransaction(async () => {
      const fromAccount = await this.accountRepo.findById(command.fromId);
      const toAccount = await this.accountRepo.findById(command.toId);

      fromAccount.withdraw(command.amount);
      toAccount.deposit(command.amount);

      await this.accountRepo.save(fromAccount);
      await this.accountRepo.save(toAccount);
    });
  }
}

8. Testing con Puertos Secundarios

Test Unitario (con Mocks)

describe('CreateUserUseCase', () => {
  test('debería crear usuario si email no existe', async () => {
    // Arrange
    const mockRepo: UserRepository = {
      save: jest.fn(),
      findByEmail: jest.fn().mockResolvedValue(null),
      findById: jest.fn(),
      delete: jest.fn(),
      exists: jest.fn()
    };

    const useCase = new CreateUserUseCase(mockRepo);

    // Act
    await useCase.execute({ email: '[email protected]', name: 'Test' });

    // Assert
    expect(mockRepo.save).toHaveBeenCalled();
  });
});

Test de Integración (con Adaptador Real)

describe('PostgresUserRepository', () => {
  let pool: Pool;
  let repository: PostgresUserRepository;

  beforeAll(async () => {
    pool = new Pool({ connectionString: 'postgresql://test...' });
    repository = new PostgresUserRepository(pool);
  });

  afterAll(async () => {
    await pool.end();
  });

  test('debería guardar y recuperar usuario', async () => {
    const user = User.create('[email protected]', 'Test');

    await repository.save(user);
    const retrieved = await repository.findById(user.userId);

    expect(retrieved?.userEmail).toBe('[email protected]');
  });
});

9. Composición Final

// infrastructure/composition/app.composition.ts
function createApp() {
  // Infraestructura
  const pool = new Pool({ connectionString: process.env.DATABASE_URL });
  const emailClient = new SendGridEmailService(process.env.SENDGRID_KEY);

  // Adaptadores secundarios
  const userRepository = new PostgresUserRepository(pool);
  const emailService = emailClient;

  // Casos de uso
  const createUser = new CreateUserUseCase(userRepository, emailService);
  const getUser = new GetUserUseCase(userRepository);

  // Adaptadores primarios
  const userController = new UserController(createUser, getUser);

  return { userController };
}

10. Conclusión

En este capítulo aprendiste:

  1. Puertos Secundarios: Interfaces que el dominio necesita del exterior
  2. Repositorios: Abstracción de persistencia de agregados
  3. Servicios Externos: Abstracción de APIs y sistemas externos
  4. Adaptadores: Implementaciones concretas intercambiables
  5. Mapeo: Transformación entre dominio y persistencia
  6. Transacciones: Gestión de operaciones atómicas

Principios clave:

En el próximo capítulo veremos cómo comunicar las capas con DTOs y manejo de errores.

Glosario del Capítulo

Término (Inglés)Término (Español)Definición
Secondary PortPuerto SecundarioInterface que el dominio necesita (driven)
RepositoryRepositorioAbstracción de persistencia de agregados
AdapterAdaptadorImplementación concreta de un puerto
MapperMapeadorTransforma entre dominio y persistencia
TransactionTransacciónOperación atómica con rollback
MockSimulaciónImplementación falsa para testing
In-MemoryEn MemoriaAlmacenamiento temporal sin BD

Referencias