← Volver al listado de tecnologías
Capítulo 5: Puertos Secundarios (Driven)
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:
- Define qué necesita el dominio del exterior
- Es definida por el dominio (no por infraestructura)
- Es implementada por adaptadores secundarios
- Es usada por casos de uso
Flujo de Invocación
Caso de Uso → Puerto (Interface) → Adaptador → Tecnología Externa
(UseCase) (Interface) (Repository) (PostgreSQL/HTTP/etc)
Diferencia con Puertos Primarios
| Aspecto | Puerto Primario | Puerto Secundario |
|---|---|---|
| Dirección | Exterior → Dominio | Dominio → Exterior |
| Nombre | Driving Port | Driven Port |
| Quién usa | Adaptadores usan el dominio | Dominio usa adaptadores |
| Ejemplo | HTTP Controller | Database 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
- Por agregado: Un repository por raíz de agregado
- Abstracción: El dominio no conoce la BD
- Colección: Actúa como una colección en memoria
- 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:
- Puertos Secundarios: Interfaces que el dominio necesita del exterior
- Repositorios: Abstracción de persistencia de agregados
- Servicios Externos: Abstracción de APIs y sistemas externos
- Adaptadores: Implementaciones concretas intercambiables
- Mapeo: Transformación entre dominio y persistencia
- Transacciones: Gestión de operaciones atómicas
Principios clave:
- ✅ El dominio define las interfaces
- ✅ Una interface por tipo de servicio
- ✅ Adaptadores intercambiables
- ✅ Testing con mocks e in-memory
- ✅ Mapeo explícito dominio ↔ persistencia
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 Port | Puerto Secundario | Interface que el dominio necesita (driven) |
| Repository | Repositorio | Abstracción de persistencia de agregados |
| Adapter | Adaptador | Implementación concreta de un puerto |
| Mapper | Mapeador | Transforma entre dominio y persistencia |
| Transaction | Transacción | Operación atómica con rollback |
| Mock | Simulación | Implementación falsa para testing |
| In-Memory | En Memoria | Almacenamiento temporal sin BD |