NestJS Clean Architecture Skill
When generating or reviewing NestJS code, always enforce Clean Architecture's layered boundary rules. Every file created must belong to exactly one layer, and dependencies must always point inward.
Layer Map
src/
├── domain/ # Enterprise business rules (no framework deps)
│ ├── entities/
│ ├── value-objects/
│ ├── repositories/ # Interfaces only
│ └── events/
├── application/ # Application business rules (orchestration)
│ ├── use-cases/
│ ├── dtos/
│ ├── ports/ # Interfaces for infra services
│ └── mappers/
├── infrastructure/ # Frameworks, DB, external services
│ ├── database/
│ │ ├── entities/ # TypeORM/Prisma models
│ │ ├── repositories/# Concrete implementations
│ │ └── migrations/
│ ├── http/
│ │ ├── controllers/
│ │ ├── guards/
│ │ └── interceptors/
│ ├── messaging/
│ └── config/
└── shared/ # Cross-cutting concerns
├── exceptions/
├── decorators/
└── utils/
Dependency Rules
- Domain → imports NOTHING from application, infrastructure, or NestJS
- Application → imports Domain only; never imports infrastructure or HTTP
- Infrastructure → imports Application + Domain; implements their interfaces
- Presentation (Controllers) → imports Application DTOs and Use-Cases only
- Use
@nestjs/commononly in infrastructure and presentation layers
If a domain entity needs to emit an event, use a domain event interface defined inside domain/events/ — never import EventEmitter2 directly in the domain.
Domain Layer
Entity pattern
// domain/entities/user.entity.ts
export class User {
private constructor(
public readonly id: UserId,
public readonly email: Email,
private _name: string,
private _status: UserStatus,
) {}
static create(props: CreateUserProps): Result<User, DomainError> {
const emailOrError = Email.create(props.email);
if (emailOrError.isFailure) return Result.fail(emailOrError.error);
const id = UserId.generate();
return Result.ok(
new User(id, emailOrError.value, props.name, UserStatus.ACTIVE),
);
}
deactivate(): Result<void, DomainError> {
if (this._status === UserStatus.INACTIVE) {
return Result.fail(new DomainError("User already inactive"));
}
this._status = UserStatus.INACTIVE;
return Result.ok();
}
get status(): UserStatus {
return this._status;
}
}
Value Object pattern
// domain/value-objects/email.vo.ts
export class Email {
private constructor(private readonly value: string) {}
static create(raw: string): Result<Email, DomainError> {
if (!raw || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(raw)) {
return Result.fail(new DomainError("Invalid email format"));
}
return Result.ok(new Email(raw.toLowerCase().trim()));
}
toString(): string {
return this.value;
}
}
Repository Interface (Domain layer)
// domain/repositories/user.repository.interface.ts
export interface IUserRepository {
findById(id: UserId): Promise<User | null>;
findByEmail(email: Email): Promise<User | null>;
save(user: User): Promise<void>;
delete(id: UserId): Promise<void>;
}
export const USER_REPOSITORY = Symbol("IUserRepository");
Application Layer
Use-Case pattern
// application/use-cases/create-user/create-user.use-case.ts
@Injectable()
export class CreateUserUseCase {
constructor(
@Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository,
@Inject(EMAIL_SERVICE) private readonly emailService: IEmailService,
) {}
async execute(
dto: CreateUserDto,
): Promise<Result<UserResponseDto, AppError>> {
const email = Email.create(dto.email);
if (email.isFailure)
return Result.fail(new AppError.ValidationError(email.error.message));
const exists = await this.userRepo.findByEmail(email.value);
if (exists)
return Result.fail(
new AppError.ConflictError("Email already registered"),
);
const userOrError = User.create({ email: email.value, name: dto.name });
if (userOrError.isFailure)
return Result.fail(
new AppError.ValidationError(userOrError.error.message),
);
await this.userRepo.save(userOrError.value);
await this.emailService.sendWelcome(dto.email);
return Result.ok(UserMapper.toResponse(userOrError.value));
}
}
DTO pattern (Application layer)
// application/dtos/create-user.dto.ts
export class CreateUserDto {
@IsEmail()
@MaxLength(255)
email: string;
@IsString()
@MinLength(2)
@MaxLength(100)
name: string;
}
Infrastructure Layer
Repository Implementation
// infrastructure/database/repositories/user.repository.ts
@Injectable()
export class UserRepository implements IUserRepository {
constructor(
@InjectRepository(UserOrmEntity)
private readonly repo: Repository<UserOrmEntity>,
) {}
async findById(id: UserId): Promise<User | null> {
const orm = await this.repo.findOne({ where: { id: id.toString() } });
return orm ? UserMapper.toDomain(orm) : null;
}
async save(user: User): Promise<void> {
const orm = UserMapper.toOrm(user);
await this.repo.save(orm);
}
}
Module wiring
// infrastructure/user.module.ts
@Module({
imports: [TypeOrmModule.forFeature([UserOrmEntity])],
controllers: [UserController],
providers: [
CreateUserUseCase,
{ provide: USER_REPOSITORY, useClass: UserRepository },
{ provide: EMAIL_SERVICE, useClass: SmtpEmailService },
],
})
export class UserModule {}
Presentation Layer
Controller pattern
// infrastructure/http/controllers/user.controller.ts
@Controller("users")
@UseGuards(JwtAuthGuard)
export class UserController {
constructor(private readonly createUser: CreateUserUseCase) {}
@Post()
@HttpCode(HttpStatus.CREATED)
async create(@Body() dto: CreateUserDto): Promise<UserResponseDto> {
const result = await this.createUser.execute(dto);
if (result.isFailure) throw AppErrorMapper.toHttpException(result.error);
return result.value;
}
}
Result Pattern (Shared)
Always use a typed Result monad — never throw raw errors across layer boundaries.
// shared/result.ts
export class Result<T, E extends Error = Error> {
private constructor(
private readonly _isOk: boolean,
private readonly _value?: T,
private readonly _error?: E,
) {}
static ok<T>(value: T): Result<T, never> {
return new Result<T, never>(true, value);
}
static fail<E extends Error>(error: E): Result<never, E> {
return new Result<never, E>(false, undefined, error);
}
get isSuccess(): boolean {
return this._isOk;
}
get isFailure(): boolean {
return !this._isOk;
}
get value(): T {
if (!this._isOk) throw new Error("Cannot get value of failure");
return this._value!;
}
get error(): E {
if (this._isOk) throw new Error("Cannot get error of success");
return this._error!;
}
}
Review Checklist
When reviewing NestJS code for clean architecture compliance:
- Layer boundaries — Does any domain file import from
@nestjs/*, TypeORM, or Prisma? If yes, reject. - Dependency direction — Do all dependencies point inward (toward domain)? Outer layers may depend on inner; never the reverse.
- Use-case size — Does each use-case do exactly one thing? If a use-case has more than one public method, split it.
- Repository abstraction — Are all DB calls behind an interface defined in the domain? No direct TypeORM usage in use-cases.
- DTO placement — Are DTOs in
application/dtos/? Never in domain or directly in controllers. - Result propagation — Are errors propagated via
Result<T,E>instead of thrown exceptions crossing layer boundaries? - Mapper responsibility — Do mappers live in
application/mappers/? Domain entities must never know about ORM models. - Module cohesion — Is each NestJS module a vertical slice (one bounded context)? Avoid horizontal modules like a global
ServicesModule.
How to provide feedback
- Point to the exact layer violation with the file path
- Explain which architectural rule is broken and why it matters (testability, replaceability)
- Suggest the correct location or interface to introduce
- Prefer showing a corrected snippet over just describing the fix