Arquitectura Hexagonal Modular en TypeScript
1. La Filosofía Modular y el Directorio core
En proyectos que tienden a crecer, agrupar todos los controladores en una carpeta y todos los modelos en otra genera un alto acoplamiento y dificulta la navegación. La solución es agrupar por casos de uso o dominios funcionales (ej. Usuarios, Cursos, Pagos).
Cada uno de estos módulos actuará como un pequeño sistema independiente con sus propias tres capas de la arquitectura hexagonal:
- Domain: Las reglas de negocio puras.
- Application: Los casos de uso que orquestan el dominio.
- Infrastructure: La implementación técnica (bases de datos, APIs externas, frameworks).
Además, para evitar repetir código entre módulos, existirá un directorio core. Aquí vivirán las "cosas recicladas": clases base, manejadores de errores globales, utilidades de fechas o interfaces compartidas que cualquier módulo puede consumir.
Estructura de Carpetas Base
Así se vería la raíz de nuestro proyecto (src/):
src/
├── core/ # Código compartido y reciclable
│ ├── errors/ # Clases de errores genéricos (ej. NotFoundError)
│ └── utils/ # Funciones utilitarias genéricas
└── modules/ # Nuestros módulos por funcionalidad (Features)
└── courses/ # Ejemplo: Módulo de Cursos
├── domain/ # Reglas de negocio e interfaces
├── application/ # Casos de uso (Servicios)
└── infrastructure/# Controladores, repositorios (DB), rutas
2. Empezando por el Centro: La Capa domain
La regla de oro de la Arquitectura Hexagonal es la regla de dependencia: las dependencias siempre apuntan hacia adentro. Por lo tanto, el directorio domain es el corazón de nuestro módulo y no debe importar absolutamente nada de application, infrastructure, ni librerías externas (como Express o el ORM). Aquí solo usamos TypeScript puro.
Dentro de domain normalmente definimos dos cosas principales:
- Entidades (Entities): Las clases que representan nuestros conceptos de negocio y sus reglas.
- Puertos (Ports / Interfaces): Contratos que definen cómo el dominio se comunicará con el mundo exterior (por ejemplo, cómo guardar datos), pero sin implementar el "cómo".
Ejemplo Práctico: El Dominio de un Curso
Imaginemos que estamos desarrollando un módulo para gestionar cursos. Dentro de src/modules/courses/domain/, tendríamos lo siguiente:
1. La Entidad (Course.ts)
Esta clase encapsula los datos y el comportamiento básico. Nota que tipamos el userId como un número para asegurar la integridad con nuestra base de datos relacional.
// src/modules/courses/domain/Course.ts
export class Course {
constructor(
public readonly id: string,
public readonly title: string,
public readonly userId: number, // Referencia al instructor
public readonly startDate: Date,
public readonly endDate: Date
) {}
// Las reglas de negocio viven aquí.
// Por ejemplo, validar que una fecha no sea incoherente.
public isValidDuration(): boolean {
// Comparamos usando solo la parte de la fecha, ignorando la hora
const start = new Date(this.startDate.toDateString());
const end = new Date(this.endDate.toDateString());
return start <= end;
}
}
2. El Repositorio / Puerto (CourseRepository.ts)
El dominio necesita guardar y buscar cursos, pero no le importa si usamos SQL, archivos de texto o memoria. Por eso, solo creamos una interfaz.
// src/modules/courses/domain/CourseRepository.ts
import { Course } from "./Course";
export interface CourseRepository {
save(course: Course): Promise<void>;
findById(id: string): Promise<Course | null>;
findByUserId(userId: number): Promise<Course[]>;
update(curse: Course): Promise<Course[]>;
delete(id: string): Promise<void>;
}
Con esto, la capa de dominio está completa y lista. Es 100% testeable sin necesidad de levantar una base de datos o un servidor.
Gracias por leer.