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:

typescript
// 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:

typescript
// 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.

typescript
// 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.

typescript
// 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:

typescript
// 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.