← Volver al listado de tecnologías
Capítulo 4: Puertos Primarios (Driving)
Capítulo 4: Puertos Primarios (Driving)
Introducción
Los puertos primarios (o driving ports) son las interfaces que permiten al mundo exterior usar el dominio. Son el punto de entrada a la aplicación. En este capítulo aprenderás a diseñarlos correctamente.
1. ¿Qué son los Puertos Primarios?
Definición
Un puerto primario es una interface que:
- Define qué puede hacer la aplicación
- Es definida por el dominio/aplicación (no por infraestructura)
- Es implementada por casos de uso
- Es usada por adaptadores primarios (UI, API, CLI, etc.)
Flujo de Invocación
Mundo Exterior → Adaptador → Puerto (Interface) → Caso de Uso → Dominio
(HTTP) (Controller) (Interface) (UseCase) (Entities)
Analogía
Piensa en un cajero automático:
- Puerto primario: Botones e interfaz (qué puedes hacer)
- Adaptador primario: La pantalla táctil física
- Caso de uso: Lógica de retiro, depósito, consulta
- Dominio: Tu cuenta bancaria
2. Estructura de la Capa de Aplicación
La capa de aplicación contiene:
src/
├── domain/ # Entidades, VOs, Agregados
│ ├── user.entity.ts
│ └── email.vo.ts
│
├── application/ # Puertos y Casos de Uso
│ ├── ports/
│ │ └── input/ # Puertos primarios
│ │ └── user-service.port.ts
│ │
│ └── use-cases/ # Implementaciones
│ ├── create-user.usecase.ts
│ └── get-user.usecase.ts
│
└── infrastructure/ # Adaptadores
└── api/
└── user.controller.ts
3. Diseño de Puertos Primarios
Principio: Una Interface por Caso de Uso
// ❌ MAL: Interface grande con todo
interface UserService {
createUser(data: any): Promise<User>;
updateUser(id: string, data: any): Promise<User>;
deleteUser(id: string): Promise<void>;
getUser(id: string): Promise<User>;
listUsers(): Promise<User[]>;
activateUser(id: string): Promise<void>;
suspendUser(id: string): Promise<void>;
}
// ✅ BIEN: Una interface por caso de uso
interface CreateUserUseCase {
execute(command: CreateUserCommand): Promise<User>;
}
interface GetUserUseCase {
execute(query: GetUserQuery): Promise<User>;
}
interface SuspendUserUseCase {
execute(command: SuspendUserCommand): Promise<void>;
}
Ventajas:
- Segregación de interfaces (ISP)
- Fácil de testear cada caso de uso
- Cambios aislados
Commands y Queries
Command: Modifica el estado del sistema
// Command: Cambia el estado
interface CreateUserCommand {
email: string;
name: string;
password: string;
}
interface CreateUserUseCase {
execute(command: CreateUserCommand): Promise<{ userId: string }>;
}
Query: Solo consulta, no modifica
// Query: Solo consulta
interface GetUserQuery {
userId: string;
}
interface GetUserUseCase {
execute(query: GetUserQuery): Promise<UserDTO>;
}
DTOs (Data Transfer Objects)
Los puertos usan DTOs para transferir datos:
// DTO de entrada
interface CreateUserCommand {
email: string;
name: string;
password: string;
}
// DTO de salida
interface UserDTO {
id: string;
email: string;
name: string;
createdAt: string; // ISO date string
}
// UseCase con DTOs
interface CreateUserUseCase {
execute(command: CreateUserCommand): Promise<UserDTO>;
}
Importante: Los DTOs son datos primitivos (string, number, boolean), no objetos del dominio.
4. Implementación de Casos de Uso
Anatomía de un Caso de Uso
class CreateUserUseCase implements CreateUserUseCase {
constructor(
// Inyección de dependencias (puertos secundarios)
private readonly userRepository: UserRepository,
private readonly passwordHasher: PasswordHasher
) {}
async execute(command: CreateUserCommand): Promise<UserDTO> {
// 1. Validar input
this.validateCommand(command);
// 2. Verificar reglas de negocio
const existingUser = await this.userRepository.findByEmail(command.email);
if (existingUser) {
throw new Error('Email ya registrado');
}
// 3. Crear entidad de dominio
const hashedPassword = await this.passwordHasher.hash(command.password);
const user = User.create(
crypto.randomUUID(),
command.email,
command.name,
hashedPassword
);
// 4. Persistir
await this.userRepository.save(user);
// 5. Retornar DTO
return this.toDTO(user);
}
private validateCommand(command: CreateUserCommand): void {
if (!command.email || !command.email.includes('@')) {
throw new Error('Email inválido');
}
if (!command.name || command.name.trim().length === 0) {
throw new Error('Nombre requerido');
}
if (!command.password || command.password.length < 8) {
throw new Error('Password debe tener al menos 8 caracteres');
}
}
private toDTO(user: User): UserDTO {
return {
id: user.userId,
email: user.userEmail,
name: user.userName,
createdAt: user.createdAt.toISOString()
};
}
}
Responsabilidades de un Caso de Uso
Un caso de uso debe:
- ✅ Validar inputs
- ✅ Orquestar entidades del dominio
- ✅ Coordinar con puertos secundarios (repos, servicios)
- ✅ Transformar entidades a DTOs
Un caso de uso NO debe:
- ❌ Contener lógica de negocio (va en el dominio)
- ❌ Conocer detalles de HTTP, DB, etc.
- ❌ Instanciar adaptadores directamente
5. Ejemplo Completo: Gestión de Pedidos
Dominio
// domain/order.entity.ts
class Order {
private constructor(
private readonly id: string,
private readonly customerId: string,
private items: OrderItem[],
private status: 'draft' | 'confirmed'
) {}
static create(customerId: string): Order {
return new Order(crypto.randomUUID(), customerId, [], 'draft');
}
addItem(productId: string, quantity: number, price: number): void {
if (this.status !== 'draft') {
throw new Error('No se pueden agregar items a order confirmada');
}
this.items.push(new OrderItem(productId, quantity, price));
}
confirm(): void {
if (this.items.length === 0) {
throw new Error('No se puede confirmar order vacía');
}
this.status = 'confirmed';
}
get orderId(): string { return this.id; }
get orderStatus(): string { return this.status; }
get total(): number {
return this.items.reduce((sum, item) => sum + item.total, 0);
}
}
class OrderItem {
constructor(
readonly productId: string,
readonly quantity: number,
readonly price: number
) {}
get total(): number {
return this.quantity * this.price;
}
}
Puerto Primario (Command)
// application/ports/input/place-order.port.ts
interface PlaceOrderCommand {
customerId: string;
items: Array<{
productId: string;
quantity: number;
price: number;
}>;
}
interface PlaceOrderUseCase {
execute(command: PlaceOrderCommand): Promise<{ orderId: string }>;
}
Caso de Uso
// application/use-cases/place-order.usecase.ts
class PlaceOrderUseCaseImpl implements PlaceOrderUseCase {
constructor(
private readonly orderRepository: OrderRepository,
private readonly inventoryService: InventoryService
) {}
async execute(command: PlaceOrderCommand): Promise<{ orderId: string }> {
// 1. Validar
if (!command.customerId) throw new Error('Customer requerido');
if (command.items.length === 0) throw new Error('Items requeridos');
// 2. Verificar inventario (puerto secundario)
for (const item of command.items) {
const available = await this.inventoryService.checkStock(
item.productId,
item.quantity
);
if (!available) {
throw new Error(`Producto ${item.productId} sin stock`);
}
}
// 3. Crear entidad del dominio
const order = Order.create(command.customerId);
for (const item of command.items) {
order.addItem(item.productId, item.quantity, item.price);
}
// 4. Confirmar pedido (lógica de dominio)
order.confirm();
// 5. Persistir (puerto secundario)
await this.orderRepository.save(order);
// 6. Reservar inventario (puerto secundario)
for (const item of command.items) {
await this.inventoryService.reserve(item.productId, item.quantity);
}
return { orderId: order.orderId };
}
}
Adaptador Primario (API REST)
// infrastructure/api/order.controller.ts
class OrderController {
constructor(private readonly placeOrder: PlaceOrderUseCase) {}
async placeOrder(req: Request, res: Response): Promise<void> {
try {
// Mapeo de HTTP a Command
const command: PlaceOrderCommand = {
customerId: req.body.customerId,
items: req.body.items
};
// Invocar puerto primario
const result = await this.placeOrder.execute(command);
// Respuesta HTTP
res.status(201).json(result);
} catch (error) {
if (error instanceof Error) {
res.status(400).json({ error: error.message });
} else {
res.status(500).json({ error: 'Error interno' });
}
}
}
}
6. Query Use Cases
Diferencia Command vs Query
// COMMAND: Modifica estado
interface CreateUserCommand {
email: string;
name: string;
}
interface CreateUserUseCase {
execute(command: CreateUserCommand): Promise<{ userId: string }>;
}
// QUERY: Solo lee
interface GetUserQuery {
userId: string;
}
interface GetUserUseCase {
execute(query: GetUserQuery): Promise<UserDTO>;
}
Implementación de Query
class GetUserUseCaseImpl implements GetUserUseCase {
constructor(private readonly userRepository: UserRepository) {}
async execute(query: GetUserQuery): Promise<UserDTO> {
const user = await this.userRepository.findById(query.userId);
if (!user) {
throw new Error('Usuario no encontrado');
}
return {
id: user.userId,
email: user.userEmail,
name: user.userName,
createdAt: user.createdAt.toISOString()
};
}
}
// Query más compleja
interface SearchUsersQuery {
name?: string;
email?: string;
page: number;
limit: number;
}
class SearchUsersUseCaseImpl implements SearchUsersUseCase {
constructor(private readonly userRepository: UserRepository) {}
async execute(query: SearchUsersQuery): Promise<{
users: UserDTO[];
total: number;
}> {
const result = await this.userRepository.search({
name: query.name,
email: query.email,
skip: (query.page - 1) * query.limit,
take: query.limit
});
return {
users: result.items.map(user => this.toDTO(user)),
total: result.total
};
}
private toDTO(user: User): UserDTO {
return {
id: user.userId,
email: user.userEmail,
name: user.userName,
createdAt: user.createdAt.toISOString()
};
}
}
7. Composición y Testing
Testing de Casos de Uso
describe('PlaceOrderUseCase', () => {
test('debería crear y confirmar order', async () => {
// Arrange
const mockOrderRepo: OrderRepository = {
save: jest.fn(),
findById: jest.fn()
};
const mockInventory: InventoryService = {
checkStock: jest.fn().mockResolvedValue(true),
reserve: jest.fn()
};
const useCase = new PlaceOrderUseCaseImpl(mockOrderRepo, mockInventory);
const command: PlaceOrderCommand = {
customerId: 'customer-123',
items: [
{ productId: 'prod-1', quantity: 2, price: 10 }
]
};
// Act
const result = await useCase.execute(command);
// Assert
expect(result.orderId).toBeDefined();
expect(mockOrderRepo.save).toHaveBeenCalled();
expect(mockInventory.reserve).toHaveBeenCalledWith('prod-1', 2);
});
test('debería fallar si no hay stock', async () => {
const mockInventory: InventoryService = {
checkStock: jest.fn().mockResolvedValue(false),
reserve: jest.fn()
};
const useCase = new PlaceOrderUseCaseImpl(mockOrderRepo, mockInventory);
await expect(useCase.execute(command)).rejects.toThrow('sin stock');
});
});
Composición (Inyección de Dependencias)
// infrastructure/composition/order-composition.ts
function createOrderModule(
orderRepository: OrderRepository,
inventoryService: InventoryService
) {
// Caso de uso
const placeOrderUseCase = new PlaceOrderUseCaseImpl(
orderRepository,
inventoryService
);
// Adaptador
const orderController = new OrderController(placeOrderUseCase);
return {
placeOrderUseCase,
orderController
};
}
// Uso
const orderModule = createOrderModule(
new PostgresOrderRepository(pool),
new HttpInventoryService(httpClient)
);
app.post('/orders', (req, res) =>
orderModule.orderController.placeOrder(req, res)
);
8. Conclusión
En este capítulo aprendiste:
- Puertos Primarios: Interfaces que definen qué puede hacer la aplicación
- Casos de Uso: Orquestación de dominio y coordinación con infraestructura
- Commands: Modifican el estado del sistema
- Queries: Solo consultan información
- DTOs: Transferencia de datos entre capas
Principios clave:
- ✅ Una interface por caso de uso (ISP)
- ✅ DTOs para entrada y salida
- ✅ Validación en casos de uso
- ✅ Lógica de negocio en el dominio
- ✅ Testing sin infraestructura
En el próximo capítulo veremos los puertos secundarios y cómo el dominio se comunica con el exterior.
Glosario del Capítulo
| Término (Inglés) | Término (Español) | Definición |
|---|---|---|
| Primary Port | Puerto Primario | Interface que permite usar el dominio (driving) |
| Use Case | Caso de Uso | Orquestación de lógica de negocio |
| Command | Comando | Input que modifica el estado del sistema |
| Query | Consulta | Input que solo lee datos |
| DTO | Objeto de Transferencia | Datos primitivos para transferir entre capas |
| Application Service | Servicio de Aplicación | Implementación de caso de uso |
| Orchestration | Orquestación | Coordinación de entidades y servicios |