La Capa de Aplicación en Arquitectura Hexagonal con TypeScript
1. El Director de Orquesta: ¿Qué hace la capa de Aplicación?
Si la capa de Dominio contiene el corazón y las reglas absolutas de nuestro negocio, la capa de Aplicación actúa como el director de orquesta. Su trabajo es recibir una petición del exterior (como un usuario queriendo hacer algo), buscar las piezas necesarias en el dominio, hacer que interactúen y guardar el resultado.
Reglas de oro para esta capa:
- Cero lógica de negocio pura: No decide cómo se calcula un dato ni valida internamente una entidad; eso se lo delega a las clases del Dominio.
- Ignorancia del framework: No sabe si la petición viene de Express, de un script de consola o de GraphQL. No importa ni
reqnires. - Inyección de Dependencias: Utiliza las interfaces (Puertos) definidas en el Dominio para comunicarse con el exterior, sin saber qué base de datos hay detrás.
2. Los Casos de Uso (Use Cases) y el Principio de Responsabilidad Única
En lugar de tener "Servicios" gigantes con miles de líneas de código que hacen de todo, en esta arquitectura dividimos cada acción específica en su propio archivo. A esto le llamamos Caso de Uso.
Crear un archivo por acción (CreateCourseUseCase.ts, UpdateCourseUseCase.ts, EnrollStudentUseCase.ts) hace que el código sea predecible. Si falla la inscripción de un estudiante, sabes exactamente qué archivo revisar sin miedo a romper la creación de cursos.
Estructura de Carpetas
Siguiendo el enfoque modular por feature, la estructura se ve así:
src/
└── modules/
└── courses/
├── domain/
└── application/ # Aquí viven nuestros Casos de Uso y DTOs
├── CreateCourseUseCase.ts
└── EnrollStudentUseCase.ts
3. Ejemplo Práctico: Creando un Curso y Validando Fechas
Vamos a ver cómo se ve un caso de uso real. Nota cómo usamos un DTO (Data Transfer Object) para definir exactamente qué datos necesitamos recibir, en lugar de aceptar un objeto genérico.
CreateCourseUseCase.ts
import { Course } from "../domain/Course";
import { CourseRepository } from "../domain/CourseRepository";
// 1. Definimos el DTO para establecer un contrato estricto de entrada
export interface CreateCourseDTO {
id: string;
title: string;
userId: number; // Mantenemos el tipado estricto como número
startDate: Date;
endDate: Date;
}
export class CreateCourseUseCase {
// 2. Inyectamos la dependencia a través del Puerto (Interfaz)
constructor(private readonly courseRepository: CourseRepository) {}
public async execute(data: CreateCourseDTO): Promise<void> {
// 3. Instanciamos la Entidad del dominio
const course = new Course(
data.id,
data.title,
data.userId,
data.startDate,
data.endDate
);
// 4. Delegamos la lógica a la Entidad (Ej: validar que la fecha de inicio
// no supere a la de fin, comparando solo días y no horas exactas).
if (!course.isValidDuration()) {
throw new Error("La fecha de inicio no puede ser posterior a la fecha de fin.");
}
// 5. Persistimos los datos usando la abstracción
await this.courseRepository.save(course);
}
}
4. Orquestación Compleja: Manejando Escenarios Específicos
El poder de la capa de aplicación resalta cuando orquestamos reglas más complejas sin ensuciar las entidades.
Imagina un caso de uso para inscribir estudiantes o gestionar grupos (EnrollStudentUseCase.ts). Aquí es donde llamarías a tus repositorios para verificar, por ejemplo, los cruces de horarios. La capa de aplicación es perfecta para manejar la lógica de esos escenarios "borde", como asegurarte de no lanzar un error de conflicto de horario de manera incorrecta si, por ejemplo, solo queda un estudiante activo en un grupo específico.
El flujo siempre es el mismo:
- Recibir DTO.
- Obtener datos a través de Puertos.
- Ejecutar reglas de Dominio.
- Guardar resultados a través de Puertos.
5. Ejemplo Práctico 2: Consultando Información (El Caso de Uso GET)
Mientras que un caso de uso de creación o actualización suele involucrar reglas de negocio complejas (como validar fechas), los casos de uso de consulta (GET) suelen ser mucho más directos. Su objetivo principal es pedirle a la infraestructura (a través del puerto) que recupere la información y, si es necesario, formatearla o validar que exista.
Vamos a implementar un caso de uso para obtener todos los cursos de un instructor específico.
GetCoursesByUserUseCase.ts
import { Course } from "../domain/Course";
import { CourseRepository } from "../domain/CourseRepository";
export class GetCoursesByUserUseCase {
// 1. Inyectamos la misma dependencia (el Puerto)
constructor(private readonly courseRepository: CourseRepository) {}
// 2. El método execute recibe el dato necesario.
// Al ser un solo parámetro, podemos omitir un DTO complejo, pero
// mantenemos estrictamente el tipo número para el userId.
public async execute(userId: number): Promise<Course[]> {
// 3. Opcional: Validaciones de aplicación simples (no de negocio puro)
if (!userId || userId <= 0) {
throw new Error("El ID del usuario proporcionado no es válido.");
}
// 4. Delegamos la búsqueda al repositorio
const courses = await this.courseRepository.findByUserId(userId);
// 5. Opcional: Podríamos lanzar un error si queremos que sea obligatorio
// que el usuario tenga cursos, o simplemente devolver el array vacío.
if (courses.length === 0) {
console.warn(`No se encontraron cursos para el usuario ${userId}`);
}
return courses;
}
}
¿Por qué hacer un archivo entero solo para un GET?
A primera vista, puede parecer código repetitivo ("boilerplate") crear una clase solo para llamar a un método del repositorio. Sin embargo, esto tiene beneficios a largo plazo:
- Escalabilidad: Si en el futuro necesitas que al pedir los cursos de un usuario también se calcule cuántos alumnos tiene inscritos en total (cruzando datos con otro repositorio), el lugar perfecto para hacer esa suma es este Caso de Uso, sin ensuciar el controlador.
- Consistencia: Todo tu sistema se comunica de la misma forma. El controlador de Express siempre llama a un Caso de Uso y llama a su método
execute(), sin importar si es un POST, PUT, DELETE o GET.
Gracias por leer.