Backend Architecture Series — Jocoso.cl eCommerce · #01
Clean Architecture in NestJS: From Fat Module to 4 Independent Layers
How to separate domain, application, infrastructure, and interfaces from day 1
The Problem: The Monolithic Module
When building a typical NestJS module, the natural temptation is to put everything in auth.service.ts: validate passwords, emit JWTs, query the database, and throw HTTP exceptions. This pattern works for prototypes, but in a real ecommerce that must integrate multiple channels (web, MercadoLibre, mobile app) it becomes an obstacle: any change to the database provider or the JWT library breaks the business logic.
The Architectural Decision
■ Technical Decision
Adopt Clean Architecture + DDD: the domain depends on nothing external. Each bounded context (auth, stock, payments) has its own 4 layers:
domain/application/infrastructure/interfaces.
The 4 Layers Explained
1. Domain — the business core
Contains entities with pure business logic, repository interfaces (contracts), and domain services. It imports no framework or external library. A User entity with a private constructor and 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 — use cases
Orchestrates the business flow without knowing Prisma, Express, or NestJS. It only talks to domain interfaces via dependency injection 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 already registered');
const hash = await this.bcrypt.hash(dto.password);
await this.users.save(User.create(dto.email, hash));
}
}3. Infrastructure — concrete adapters
Implements the domain contracts with real technologies: Prisma, JWT, bcrypt, speakeasy. If tomorrow we migrate from Prisma to another ORM, only this layer changes.
// 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 — HTTP controllers
Receive the request, delegate to the use case, and return the response. Zero business logic — they are translators between HTTP and 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);
}
}The Module as Pure Wiring
The NestJS module contains no logic — it only connects tokens to implementations:
// 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 {}The Dependency Rule
Domain imports nothing external. Application only knows Domain. Infrastructure implements Domain contracts. Interfaces delegates to Application. This rule is enforced by convention in TypeScript: if a file in domain/ imports from infrastructure/, the PR fails in code review.
■ Trade-offs
MORE files and folders vs REAL separation of concerns. The initial overhead is high (~30% more files), but in an ecommerce that scales to multiple channels the investment pays off: the domain is tested without Prisma, without NestJS, without a database. Tests are 10x faster and the code survives infrastructure migrations without touching business logic.
Conclusion
This architecture prepares the system for microservices: if tomorrow we separate auth into its own service, the domain is extracted without changes. The investment in structure pays dividends when the team grows or when MercadoLibre demands integrations we didn't anticipate at the start.