← Volver al listado de tecnologías
Capítulo 6: Comunicación entre Capas
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:
- Datos primitivos: string, number, boolean, arrays, objetos planos
- Sin lógica: Solo transportan datos, sin comportamiento
- Serializables: Pueden convertirse a JSON fácilmente
- Independientes: No dependen del dominio
¿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:
- ✅ Control sobre qué datos exponer
- ✅ Versionado de APIs independiente del dominio
- ✅ Protección de lógica interna
- ✅ Facilita testing
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:
| Capa | Qué Valida | Ejemplo |
|---|---|---|
| Adaptador | Formato de datos, tipos | ¿Es un email válido? |
| Caso de Uso | Reglas de aplicación | ¿Email ya existe? |
| Dominio | Invariantes 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:
- DTOs: Objetos simples para transferir datos entre capas
- Mapeo: Transformación entre entidades, DTOs y requests/responses
- Validación en capas: Sintáctica, aplicación e invariantes
- Manejo de errores: Jerarquía de errores y propagación controlada
- Flujo completo: Desde HTTP request hasta la base de datos
- Result Pattern: Alternativa explícita a excepciones
Principios clave:
- ✅ DTOs son datos primitivos sin lógica
- ✅ Cada capa valida aspectos diferentes
- ✅ Errores del dominio se propagan hacia arriba
- ✅ Mapeo explícito en fronteras de capas
- ✅ Control total sobre inputs y outputs
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 |
|---|---|---|
| DTO | Objeto de Transferencia de Datos | Objeto simple que transporta datos entre capas |
| Mapper | Mapeador | Componente que transforma entre representaciones |
| Validation | Validación | Verificación de que los datos cumplen reglas |
| Error Handling | Manejo de Errores | Estrategia para procesar y responder a errores |
| Command | Comando | Input que modifica el estado |
| Query | Consulta | Input que solo lee datos |
| Result Pattern | Patrón Result | Tipo que encapsula éxito o error explícitamente |