← Volver al listado de tecnologías

Capítulo 6: Comunicación entre Capas

Por: Alfred Pennyworth
arquitectura-hexagonaltypescriptdtosmapeovalidacion

Capítulo 6: Comunicación entre Capas

Introducción

Las capas de la arquitectura hexagonal deben comunicarse de forma clara y controlada. En este capítulo aprenderás cómo transferir datos entre capas, validar inputs y manejar errores de forma consistente.

1. DTOs (Data Transfer Objects)

¿Qué son los DTOs?

Los DTOs son objetos simples que transfieren datos entre capas:

¿Por qué usar DTOs?

// ❌ SIN DTOs: Exponer entidades directamente
class UserController {
  async getUser(req: Request, res: Response) {
    const user = await this.userRepo.findById(req.params.id);
    res.json(user); // ¡Expone la entidad completa con métodos privados!
  }
}

// ✅ CON DTOs: Control total sobre la respuesta
class UserController {
  async getUser(req: Request, res: Response) {
    const user = await this.getUser.execute({ userId: req.params.id });
    const dto = this.toDTO(user);
    res.json(dto); // Solo datos que queremos exponer
  }
}

Ventajas:

Tipos de DTOs

Command DTOs (Input)

// Input para crear usuario
interface CreateUserCommand {
  email: string;
  name: string;
  password: string;
}

// Input para actualizar perfil
interface UpdateProfileCommand {
  userId: string;
  name?: string;
  bio?: string;
}

Query DTOs (Input)

// Input para consultar usuarios
interface SearchUsersQuery {
  name?: string;
  email?: string;
  page: number;
  pageSize: number;
}

// Input simple
interface GetUserQuery {
  userId: string;
}

Response DTOs (Output)

// Output de usuario
interface UserDTO {
  id: string;
  email: string;
  name: string;
  createdAt: string; // ISO date string
}

// Output paginado
interface PaginatedUsersDTO {
  users: UserDTO[];
  total: number;
  page: number;
  pageSize: number;
}

2. Mapeo entre Capas

Dirección del Mapeo

HTTP Request → Command/Query DTO → Entidad del Dominio

                                   (Procesamiento)

Entidad del Dominio → Response DTO → HTTP Response

Mapeo: Adaptador → Caso de Uso

// infrastructure/api/user.controller.ts
class UserController {
  constructor(private createUser: CreateUserUseCase) {}

  async create(req: Request, res: Response) {
    // Mapeo: HTTP Request → Command DTO
    const command: CreateUserCommand = {
      email: req.body.email,
      name: req.body.name,
      password: req.body.password
    };

    // Ejecutar caso de uso
    const result = await this.createUser.execute(command);

    // Mapeo: Response DTO → HTTP Response
    res.status(201).json(result);
  }
}

Mapeo: Caso de Uso → Dominio

// application/use-cases/create-user.usecase.ts
class CreateUserUseCase {
  async execute(command: CreateUserCommand): Promise<UserDTO> {
    // Mapeo: Command → Entidad
    const user = User.create(
      crypto.randomUUID(),
      command.email,
      command.name,
      await this.hashPassword(command.password)
    );

    await this.userRepo.save(user);

    // Mapeo: Entidad → DTO
    return this.toDTO(user);
  }

  private toDTO(user: User): UserDTO {
    return {
      id: user.userId,
      email: user.userEmail,
      name: user.userName,
      createdAt: user.createdAt.toISOString()
    };
  }
}

Mappers Dedicados

Para evitar repetición, crea mappers reutilizables:

// application/mappers/user.mapper.ts
export class UserMapper {
  static toDTO(user: User): UserDTO {
    return {
      id: user.userId,
      email: user.userEmail,
      name: user.userName,
      createdAt: user.createdAt.toISOString()
    };
  }

  static toDTOList(users: User[]): UserDTO[] {
    return users.map(user => this.toDTO(user));
  }

  static toPaginatedDTO(
    users: User[],
    total: number,
    page: number,
    pageSize: number
  ): PaginatedUsersDTO {
    return {
      users: this.toDTOList(users),
      total,
      page,
      pageSize
    };
  }
}

// Uso en caso de uso
class GetUserUseCase {
  async execute(query: GetUserQuery): Promise<UserDTO> {
    const user = await this.userRepo.findById(query.userId);
    if (!user) throw new UserNotFoundError(query.userId);
    return UserMapper.toDTO(user);
  }
}

3. Validación en Cada Capa

Principio: Validación en Capas

Cada capa valida diferentes aspectos:

CapaQué ValidaEjemplo
AdaptadorFormato de datos, tipos¿Es un email válido?
Caso de UsoReglas de aplicación¿Email ya existe?
DominioInvariantes de negocio¿Password cumple política?

Validación en Adaptador (Sintáctica)

// infrastructure/api/validators/user.validator.ts
import { z } from 'zod';

const CreateUserSchema = z.object({
  email: z.string().email('Email inválido'),
  name: z.string().min(2, 'Nombre debe tener al menos 2 caracteres'),
  password: z.string().min(8, 'Password debe tener al menos 8 caracteres')
});

class UserController {
  async create(req: Request, res: Response) {
    // Validación sintáctica
    const validation = CreateUserSchema.safeParse(req.body);

    if (!validation.success) {
      return res.status(400).json({
        error: 'Validación fallida',
        details: validation.error.errors
      });
    }

    const command = validation.data;
    const result = await this.createUser.execute(command);
    res.status(201).json(result);
  }
}

Validación en Caso de Uso (Aplicación)

// application/use-cases/create-user.usecase.ts
class CreateUserUseCase {
  async execute(command: CreateUserCommand): Promise<UserDTO> {
    // Validación de aplicación: email único
    const existingUser = await this.userRepo.findByEmail(command.email);
    if (existingUser) {
      throw new EmailAlreadyExistsError(command.email);
    }

    // Validación de aplicación: nombre no permitido
    if (this.isBlockedName(command.name)) {
      throw new BlockedNameError(command.name);
    }

    const user = User.create(
      crypto.randomUUID(),
      command.email,
      command.name,
      await this.hashPassword(command.password)
    );

    await this.userRepo.save(user);
    return UserMapper.toDTO(user);
  }

  private isBlockedName(name: string): boolean {
    const blocked = ['admin', 'root', 'system'];
    return blocked.includes(name.toLowerCase());
  }
}

Validación en Dominio (Invariantes)

// domain/user.entity.ts
class User {
  private constructor(
    private readonly id: string,
    private email: Email,
    private name: Name,
    private password: HashedPassword
  ) {}

  static create(
    id: string,
    email: string,
    name: string,
    hashedPassword: string
  ): User {
    // Validaciones de dominio en Value Objects
    return new User(
      id,
      Email.create(email),      // Valida formato de email
      Name.create(name),        // Valida longitud y caracteres
      HashedPassword.create(hashedPassword) // Valida hash
    );
  }
}

// domain/value-objects/email.vo.ts
class Email {
  private constructor(private readonly value: string) {}

  static create(email: string): Email {
    if (!email || email.trim().length === 0) {
      throw new InvalidEmailError('Email no puede estar vacío');
    }

    const normalized = email.toLowerCase().trim();

    if (!this.isValid(normalized)) {
      throw new InvalidEmailError(`Email inválido: ${email}`);
    }

    return new Email(normalized);
  }

  private static isValid(email: string): boolean {
    const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    return regex.test(email);
  }

  toString(): string {
    return this.value;
  }
}

4. Manejo de Errores

Jerarquía de Errores

// domain/errors/base.error.ts
export abstract class DomainError extends Error {
  constructor(message: string) {
    super(message);
    this.name = this.constructor.name;
  }
}

// domain/errors/validation.error.ts
export class ValidationError extends DomainError {
  constructor(message: string) {
    super(message);
  }
}

// domain/errors/not-found.error.ts
export class NotFoundError extends DomainError {
  constructor(entity: string, id: string) {
    super(`${entity} con id ${id} no encontrado`);
  }
}

// domain/errors/user.errors.ts
export class UserNotFoundError extends NotFoundError {
  constructor(userId: string) {
    super('Usuario', userId);
  }
}

export class EmailAlreadyExistsError extends ValidationError {
  constructor(email: string) {
    super(`Email ${email} ya está registrado`);
  }
}

export class InvalidEmailError extends ValidationError {
  constructor(message: string) {
    super(message);
  }
}

Manejo en Casos de Uso

// application/use-cases/update-user.usecase.ts
class UpdateUserUseCase {
  async execute(command: UpdateUserCommand): Promise<UserDTO> {
    // Los errores del dominio se propagan
    const user = await this.userRepo.findById(command.userId);

    if (!user) {
      throw new UserNotFoundError(command.userId);
    }

    if (command.name) {
      user.changeName(command.name); // Puede lanzar ValidationError
    }

    if (command.email) {
      // Validación de aplicación
      const exists = await this.userRepo.existsWithEmail(command.email);
      if (exists) {
        throw new EmailAlreadyExistsError(command.email);
      }
      user.changeEmail(command.email); // Puede lanzar InvalidEmailError
    }

    await this.userRepo.save(user);
    return UserMapper.toDTO(user);
  }
}

Manejo en Adaptadores

// infrastructure/api/middleware/error-handler.ts
export function errorHandler(
  error: Error,
  req: Request,
  res: Response,
  next: NextFunction
) {
  console.error('Error:', error);

  // Errores de validación (400)
  if (error instanceof ValidationError) {
    return res.status(400).json({
      error: 'Validación fallida',
      message: error.message
    });
  }

  // Errores de no encontrado (404)
  if (error instanceof NotFoundError) {
    return res.status(404).json({
      error: 'No encontrado',
      message: error.message
    });
  }

  // Errores de dominio genéricos (400)
  if (error instanceof DomainError) {
    return res.status(400).json({
      error: 'Error de dominio',
      message: error.message
    });
  }

  // Error desconocido (500)
  return res.status(500).json({
    error: 'Error interno del servidor',
    message: 'Ocurrió un error inesperado'
  });
}

// Uso en Express
app.use(errorHandler);

5. Ejemplo Completo: Flujo de Petición

Veamos un flujo completo desde HTTP hasta el dominio y vuelta:

Request: POST /users

// ========== 1. HTTP REQUEST ==========
// POST /users
// Body: { "email": "[email protected]", "name": "Juan", "password": "secret123" }

// ========== 2. ADAPTADOR PRIMARIO ==========
// infrastructure/api/user.controller.ts
class UserController {
  constructor(private createUser: CreateUserUseCase) {}

  async create(req: Request, res: Response, next: NextFunction) {
    try {
      // Validación sintáctica
      const validation = CreateUserSchema.safeParse(req.body);
      if (!validation.success) {
        return res.status(400).json({ errors: validation.error.errors });
      }

      // Mapeo: Request → Command
      const command: CreateUserCommand = validation.data;

      // Invocar caso de uso
      const userDTO = await this.createUser.execute(command);

      // Respuesta
      res.status(201).json(userDTO);
    } catch (error) {
      next(error);
    }
  }
}

// ========== 3. CASO DE USO ==========
// application/use-cases/create-user.usecase.ts
class CreateUserUseCase {
  constructor(
    private userRepo: UserRepository,
    private passwordHasher: PasswordHasher,
    private eventBus: EventBus
  ) {}

  async execute(command: CreateUserCommand): Promise<UserDTO> {
    // Validación de aplicación
    const exists = await this.userRepo.existsWithEmail(command.email);
    if (exists) {
      throw new EmailAlreadyExistsError(command.email);
    }

    // Mapeo: Command → Dominio
    const hashedPassword = await this.passwordHasher.hash(command.password);
    const user = User.create(
      crypto.randomUUID(),
      command.email,
      command.name,
      hashedPassword
    );

    // Persistencia
    await this.userRepo.save(user);

    // Evento de dominio
    await this.eventBus.publish(new UserCreatedEvent(user.userId));

    // Mapeo: Dominio → DTO
    return UserMapper.toDTO(user);
  }
}

// ========== 4. DOMINIO ==========
// domain/user.entity.ts
class User {
  private constructor(
    private readonly id: string,
    private email: Email,
    private name: Name,
    private password: HashedPassword,
    private readonly createdAt: Date
  ) {}

  static create(
    id: string,
    email: string,
    name: string,
    hashedPassword: string
  ): User {
    // Validación de invariantes (en VOs)
    return new User(
      id,
      Email.create(email),
      Name.create(name),
      HashedPassword.create(hashedPassword),
      new Date()
    );
  }

  get userId(): string { return this.id; }
  get userEmail(): string { return this.email.toString(); }
  get userName(): string { return this.name.toString(); }
  get createdAt(): Date { return this.createdAt; }
}

// ========== 5. PUERTO SECUNDARIO ==========
// application/ports/output/user.repository.ts
interface UserRepository {
  save(user: User): Promise<void>;
  existsWithEmail(email: string): Promise<boolean>;
}

// ========== 6. ADAPTADOR SECUNDARIO ==========
// infrastructure/persistence/postgres-user.repository.ts
class PostgresUserRepository implements UserRepository {
  constructor(private pool: Pool) {}

  async save(user: User): Promise<void> {
    await this.pool.query(
      'INSERT INTO users (id, email, name, password, created_at) VALUES ($1, $2, $3, $4, $5)',
      [user.userId, user.userEmail, user.userName, user.password, user.createdAt]
    );
  }

  async existsWithEmail(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;
  }
}

// ========== 7. RESPONSE ==========
// HTTP 201 Created
// Body: {
//   "id": "550e8400-e29b-41d4-a716-446655440000",
//   "email": "[email protected]",
//   "name": "Juan",
//   "createdAt": "2024-03-24T10:30:00.000Z"
// }

6. Result Pattern (Alternativa a Excepciones)

Problema con Excepciones

// ❌ Con excepciones: control de flujo implícito
async function createUser(command: CreateUserCommand): Promise<UserDTO> {
  const exists = await userRepo.existsWithEmail(command.email);
  if (exists) {
    throw new EmailAlreadyExistsError(command.email); // ¿Quién captura esto?
  }
  // ...
}

Solución: Result Type

// Tipo Result
type Result<T, E = Error> =
  | { success: true; value: T }
  | { success: false; error: E };

// Helper functions
function success<T>(value: T): Result<T> {
  return { success: true, value };
}

function failure<E>(error: E): Result<never, E> {
  return { success: false, error };
}

// Uso en caso de uso
class CreateUserUseCase {
  async execute(
    command: CreateUserCommand
  ): Promise<Result<UserDTO, ValidationError | EmailAlreadyExistsError>> {
    const exists = await this.userRepo.existsWithEmail(command.email);
    if (exists) {
      return failure(new EmailAlreadyExistsError(command.email));
    }

    const userResult = User.create(
      crypto.randomUUID(),
      command.email,
      command.name,
      await this.hashPassword(command.password)
    );

    if (!userResult.success) {
      return failure(userResult.error);
    }

    await this.userRepo.save(userResult.value);
    return success(UserMapper.toDTO(userResult.value));
  }
}

// Uso en controlador
class UserController {
  async create(req: Request, res: Response) {
    const result = await this.createUser.execute(command);

    if (!result.success) {
      // Manejo explícito de errores
      if (result.error instanceof EmailAlreadyExistsError) {
        return res.status(409).json({ error: result.error.message });
      }
      return res.status(400).json({ error: result.error.message });
    }

    res.status(201).json(result.value);
  }
}

7. Conclusión

En este capítulo aprendiste:

  1. DTOs: Objetos simples para transferir datos entre capas
  2. Mapeo: Transformación entre entidades, DTOs y requests/responses
  3. Validación en capas: Sintáctica, aplicación e invariantes
  4. Manejo de errores: Jerarquía de errores y propagación controlada
  5. Flujo completo: Desde HTTP request hasta la base de datos
  6. Result Pattern: Alternativa explícita a excepciones

Principios clave:

En el próximo capítulo veremos cómo organizar el código en una estructura clara.

Glosario del Capítulo

Término (Inglés)Término (Español)Definición
DTOObjeto de Transferencia de DatosObjeto simple que transporta datos entre capas
MapperMapeadorComponente que transforma entre representaciones
ValidationValidaciónVerificación de que los datos cumplen reglas
Error HandlingManejo de ErroresEstrategia para procesar y responder a errores
CommandComandoInput que modifica el estado
QueryConsultaInput que solo lee datos
Result PatternPatrón ResultTipo que encapsula éxito o error explícitamente

Referencias