Clean Architecture en NestJS: cómo la aplico en un ERP real
Sin el ejemplo de usuarios de siempre: la estructura de carpetas, el repositorio como contrato, casos de uso que no conocen a TypeORM y tests sin base de datos, con código de un ERP en producción.
, actualizado el 3 de octubre de 2026
En diciembre de 2024 escribí aquí una guía de Clean Architecture en NestJS con el ejemplo de siempre: un módulo de usuarios y un caso de uso para crearlos. Era correcta, pero era teoría.
Desde entonces, esa arquitectura sostiene el backend de UVA Cloud, un ERP en la nube con contabilidad, inventario, ventas y facturación electrónica. Esta es la versión reescrita, con código del proyecto.
La regla, en una frase
Las dependencias apuntan hacia adentro: la lógica de negocio no conoce a NestJS, ni a TypeORM, ni a HTTP. Son ellos los que la conocen a ella.
Todo lo demás (capas, carpetas, interfaces) es una forma de hacer cumplir esa regla.
La estructura: tres capas por módulo
Cada módulo de NestJS es un área del negocio (contactos, ventas, inventario…) y adentro tiene siempre las mismas tres capas:
src/uva/contactos/
├── domain/ ← el negocio: no conoce TypeORM ni HTTP
│ ├── model/ Contacto y sus reglas
│ ├── repository/ contratos de acceso a datos (interfaces)
│ ├── usecases/ ContactoCrud…
│ └── factories/ creación válida de modelos
├── data/ ← los detalles
│ ├── local/ entidades TypeORM
│ └── repository/ implementaciones de los contratos
├── presentation/ ← la puerta HTTP
│ ├── controllers/
│ ├── dtos/
│ └── tests/
└── contactos.module.ts
Uso domain / data / presentation en vez del clásico domain / application / infrastructure porque es la misma estructura que uso en las apps con Kotlin y en el frontend: quien entiende un módulo entiende todos. Los nombres importan menos que la dirección de las dependencias.
Cómo se comunican esos módulos entre sí sin conocerse lo cuento en Monolito modular: por qué no empecé un ERP con microservicios.
El repositorio es un contrato, no una clase de TypeORM
El dominio define qué necesita, no cómo se consigue. El problema en NestJS es que las interfaces de TypeScript desaparecen al compilar, y la inyección de dependencias necesita algo que exista en tiempo de ejecución.
El truco: la interfaz y su token de inyección se llaman igual. TypeScript permite que un tipo y una constante compartan nombre, así que desde fuera se usan como una sola cosa:
// domain/repository/contacto-repository.ts
export interface ContactoRepository {
getOne(accountId: string, id: string): Promise<Contacto | null>;
findByIdentification(accountId: string, identificacion: string): Promise<Contacto | null>;
create(accountId: string, model: Contacto): Promise<Contacto | null>;
// update, delete, getAll…
}
export const ContactoRepository = Symbol('ContactoRepository');
El módulo es el único lugar donde el contrato se conecta con TypeORM:
// contactos.module.ts
providers: [
{ provide: ContactoRepository, useClass: ContactoRepositoryImpl },
ContactoCrud,
],
El caso de uso solo tiene negocio
El caso de uso pide el repositorio por su contrato y no sabe qué hay detrás:
// domain/usecases/contacto-crud.ts (simplificado)
@Injectable()
export class ContactoCrud {
constructor(@Inject(ContactoRepository) private readonly repository: ContactoRepository) {}
async create(ctx: SecurityContext, model: Contacto): Promise<Either<ContactoCreateError, Contacto>> {
// La identificación duplicada es una regla de negocio, no un error de la base
const existing = await this.repository.findByIdentification(ctx.accountId, model.identificacion);
if (existing) {
return Either.left(CrudError.exists(ContactoConstants.Ident));
}
const created = await this.repository.create(ctx.accountId, model);
return created ? Either.right(created) : Either.left(CrudError.notCreated(ContactoConstants.ModelName));
}
}
Ni un Repository<ContactoEntity>, ni un QueryBuilder, ni un HttpException. Los errores de negocio salen como valor (un Either: error a la izquierda, resultado a la derecha) en lugar de excepciones, pero eso da para otro post.
El controller solo traduce
El controller convierte el DTO en modelo, llama al caso de uso y convierte el resultado en respuesta. No decide nada:
// presentation/controllers/clientes.controller.ts
@Post()
async create(@Body() dto: CreateClienteDto): Promise<ServerResp<ContactoCreateError, ReadClienteDto>> {
const result = await this.crud.create(this.ctx, CreateClienteDto.toModel(dto));
return result.fold(
(error) => ServerResp.error({ error }),
(data) => ServerResp.data({ data: ReadClienteDto.fromModel(data) }),
);
}
Si mañana el mismo caso de uso se llama desde una cola de mensajes o desde un comando de consola, la lógica no cambia: solo se escribe otra puerta.
El modelo no es la entidad de TypeORM
Contacto (dominio) y ContactoEntity (TypeORM) son clases distintas. La entidad sabe de columnas, relaciones y joins; el modelo, del negocio. Se cruzan en un solo lugar, el repositorio, y siempre a través de la fábrica del dominio:
// data/local/contacto.entity.ts
static toModel(entity: ContactoEntity | null): Contacto | null {
return entity ? ContactoFactory.create(entity, true) : null;
}
La fábrica es la única forma de crear un Contacto válido, venga de la base, de un DTO o de una importación desde Excel. Si cambia una regla de creación, cambia en un solo archivo.
Probar sin base de datos
Aquí se cobra todo lo anterior. Como el caso de uso pide el repositorio por su token, en el test se reemplaza la implementación por un objeto con funciones simuladas, sin tocar una línea del caso de uso:
// presentation/tests/contacto-crud.spec.ts (Vitest)
const module = await Test.createTestingModule({
providers: [
ContactoCrud,
{ provide: ContactoRepository, useValue: mockRepository },
],
}).compile();
it('should return error when identificacion already exists', async () => {
mockRepository.findByIdentification.mockResolvedValueOnce(createContacto()); // ya existe
const result = await crud.create(ctx, createContacto());
expect(result.isLeft()).toBe(true);
});
Sin PostgreSQL, sin Docker y sin datos de prueba en una base: el test corre en milisegundos y prueba exactamente la regla.
Lo que no es “puro”, y por qué lo acepto
- Los casos de uso usan
@Injectable()e@Inject()de NestJS. Un purista diría que el dominio no debe conocer el framework. En la práctica, un decorador no acopla la lógica a nada: el caso de uso se prueba y se entiende sin NestJS. Evitarlo costaría código que no protege de nada. - La entidad de TypeORM implementa la forma del modelo (
ContactoEntity implements Contacto). Si alguien agrega un campo al modelo y olvida la columna, el compilador avisa.
Clean Architecture es un medio, no un examen.
Cuándo no la usaría
Para un CRUD de cuatro tablas, un prototipo o un script interno, esta estructura es ceremonia: más archivos por funcionalidad y más tiempo al principio.
Se paga sola cuando el sistema crece y vive años, con reglas de negocio de verdad y más de una persona tocando el código. En un ERP, la pregunta no es si conviene, sino cuánto cuesta no tenerla.
Si tu empresa está en un sistema que creció sin estas fronteras y cada cambio rompe algo lejos, escríbeme.