Backend Architecture Series — Jocoso.cl eCommerce · #01
Clean Architecture en NestJS: De módulo gordo a 4 capas independientes
Cómo separar dominio, aplicación, infraestructura e interfaces desde el día 1
El problema: el módulo monolítico
Cuando se construye un módulo NestJS típico, la tentación natural es poner todo en auth.service.ts: validar contraseñas, emitir JWT, consultar la base de datos y lanzar excepciones HTTP. Este patrón funciona para prototipos, pero en un ecommerce real que debe integrar múltiples canales (web, MercadoLibre, app móvil) se convierte en un obstáculo: cualquier cambio de proveedor de BD o de librería JWT rompe la lógica de negocio.
La decisión arquitectónica
■ Decisión Técnica
Adoptar Clean Architecture + DDD: el dominio no depende de nada externo. Cada bounded context (auth, stock, payments) tiene sus propias 4 capas:
domain/application/infrastructure/interfaces.
Las 4 capas explicadas
1. Domain — el núcleo del negocio
Contiene entidades con lógica de negocio pura, interfaces de repositorio (contratos) y servicios de dominio. No importa ningún framework ni librería externa. Una entidad User con constructor privado y factory method:
// domain/auth/entities/user.entity.ts
export class User {
private constructor(private props: UserProps) {}
static create(email: string, passwordHash: string): User {
return new User({
id: crypto.randomUUID(),
email,
passwordHash,
role: Role.CUSTOMER,
isActive: true,
twoFactorEnabled: false,
});
}
static reconstitute(props: UserProps): User {
return new User(props);
}
}2. Application — casos de uso
Orquesta el flujo de negocio sin conocer Prisma, Express ni NestJS. Solo habla con interfaces del dominio mediante inyección de dependencias por tokens:
// application/auth/use-cases/register.usecase.ts
@Injectable()
export class RegisterUseCase {
constructor(
@Inject(USER_REPOSITORY)
private readonly users: IUserRepository,
private readonly bcrypt: BcryptService,
) {}
async execute(dto: RegisterDto): Promise<void> {
const exists = await this.users.findByEmail(dto.email);
if (exists) throw new ConflictException('Email ya registrado');
const hash = await this.bcrypt.hash(dto.password);
await this.users.save(User.create(dto.email, hash));
}
}3. Infrastructure — adaptadores concretos
Implementa los contratos del dominio con tecnologías reales: Prisma, JWT, bcrypt, speakeasy. Si mañana migramos de Prisma a otro ORM, solo cambia esta capa.
// infrastructure/auth/user.prisma-repo.ts
@Injectable()
export class UserPrismaRepository implements IUserRepository {
constructor(private readonly prisma: PrismaService) {}
async findByEmail(email: string): Promise<User | null> {
const row = await this.prisma.user.findUnique({ where: { email } });
return row ? User.reconstitute(row as UserProps) : null;
}
}4. Interfaces — controladores HTTP
Reciben la request, delegan al caso de uso y retornan la respuesta. Cero lógica de negocio — son traductores entre HTTP y Application.
// interfaces/http/auth/auth.controller.ts
@Controller('auth')
export class AuthController {
constructor(private readonly register: RegisterUseCase) {}
@Post('register')
async registerUser(@Body() dto: RegisterDto) {
return this.register.execute(dto);
}
}El módulo como pure wiring
El módulo NestJS no contiene ninguna lógica — solo conecta tokens con implementaciones:
// modules/auth/auth.module.ts
@Module({
providers: [
RegisterUseCase, LoginUseCase, RefreshUseCase,
{ provide: USER_REPOSITORY, useClass: UserPrismaRepository },
{ provide: REFRESH_TOKEN_REPOSITORY, useClass: RefreshTokenPrismaRepository },
{ provide: TOKEN_SERVICE, useClass: JwtTokenService },
],
controllers: [AuthController],
})
export class AuthModule {}La regla de dependencias
Domain no importa nada externo. Application solo conoce Domain. Infrastructure implementa contratos de Domain. Interfaces delega a Application. Esta regla se valida por convención en TypeScript: si un archivo en domain/ importa desde infrastructure/, el PR falla en code review.
■ Trade-offs
MÁS archivos y carpetas vs SEPARACIÓN real de concerns. El overhead inicial es alto (~30% más archivos), pero en un ecommerce que escala a múltiples canales la inversión se recupera: el dominio se prueba sin Prisma, sin NestJS, sin base de datos. Los tests son 10x más rápidos y el código sobrevive migraciones de infraestructura sin tocar la lógica de negocio.
Conclusión
Esta arquitectura prepara el sistema para microservicios: si mañana separamos auth en su propio servicio, el dominio se extrae sin cambios. La inversión en estructura paga dividendos cuando el equipo crece o cuando MercadoLibre exige integraciones que no imaginábamos al inicio.