← Volver al listado de tecnologías

Capítulo 4: Puertos Primarios (Driving)

Por: Alfred Pennyworth
arquitectura-hexagonaltypescriptpuertoscasos-usoaplicacion

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:

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:

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:

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:

Un caso de uso NO debe:

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:

  1. Puertos Primarios: Interfaces que definen qué puede hacer la aplicación
  2. Casos de Uso: Orquestación de dominio y coordinación con infraestructura
  3. Commands: Modifican el estado del sistema
  4. Queries: Solo consultan información
  5. DTOs: Transferencia de datos entre capas

Principios clave:

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 PortPuerto PrimarioInterface que permite usar el dominio (driving)
Use CaseCaso de UsoOrquestación de lógica de negocio
CommandComandoInput que modifica el estado del sistema
QueryConsultaInput que solo lee datos
DTOObjeto de TransferenciaDatos primitivos para transferir entre capas
Application ServiceServicio de AplicaciónImplementación de caso de uso
OrchestrationOrquestaciónCoordinación de entidades y servicios

Referencias