DEV Community

Kuba
Kuba

Posted on

Clean Architecture in NestJS — A Practical Guide

Building NestJS apps is easy. Building NestJS apps that are still easy to change six months later is a different story.

Clean Architecture gives you a way to structure your code so that business logic doesn't bleed into HTTP handlers, database queries don't dictate your domain model, and swapping infrastructure (say, Postgres → SQLite, or REST → gRPC) doesn't require rewriting half the app.

This guide is practical. We'll build a small feature — managing User entities — and walk through each layer with real code.


The core idea

Clean Architecture revolves around one rule: dependencies point inward.

┌──────────────────────────────────┐
│          Infrastructure          │  HTTP, DB, external APIs
│   ┌──────────────────────────┐   │
│   │      Application         │  Use cases / orchestration
│   │   ┌──────────────────┐   │   │
│   │   │     Domain       │  Entities, ports
│   │   └──────────────────┘   │   │
│   └──────────────────────────┘   │
└──────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode
  • Domain — pure business logic, no framework dependencies
  • Application — use cases that orchestrate domain objects
  • Infrastructure — NestJS controllers, TypeORM repositories, anything "real"

The domain knows nothing about NestJS. The application knows nothing about Express. Infrastructure wires everything together.


Folder structure

src/
  users/
    domain/
      user.entity.ts
      user.repository.ts        ← port (interface)
    application/
      commands/
        create-user.command.ts
        create-user.handler.ts
      queries/
        get-user.query.ts
        get-user.handler.ts
    infrastructure/
      persistence/
        typeorm-user.repository.ts
        user.orm-entity.ts
      http/
        users.controller.ts
        dto/
          create-user.dto.ts
    users.module.ts
Enter fullscreen mode Exit fullscreen mode

If you're using CQRS (and you should consider it for anything beyond CRUD), separate commands and queries from the start. Retrofitting this later is painful.


1. Domain layer

The domain layer holds your entities and the interfaces (ports) that infrastructure must implement.

domain/user.entity.ts

export class User {
  constructor(
    public readonly id: string,
    public readonly email: string,
    public readonly name: string,
    private readonly createdAt: Date,
  ) {}

  static create(email: string, name: string): User {
    return new User(
      crypto.randomUUID(),
      email,
      name,
      new Date(),
    );
  }

  isOlderThan(days: number): boolean {
    const cutoff = new Date();
    cutoff.setDate(cutoff.getDate() - days);
    return this.createdAt < cutoff;
  }
}
Enter fullscreen mode Exit fullscreen mode

No decorators, no @Column(), no @Injectable(). Just a plain class with behavior. This is intentional — your domain entity should be testable with zero framework setup.

domain/user.repository.ts

export interface UserRepository {
  findById(id: string): Promise<User | null>;
  findByEmail(email: string): Promise<User | null>;
  save(user: User): Promise<void>;
}

export const USER_REPOSITORY = Symbol('USER_REPOSITORY');
Enter fullscreen mode Exit fullscreen mode

This is your port. The domain defines what it needs; infrastructure provides it.


2. Application layer

Use cases live here. Each handler does one thing: it receives a command or query, calls domain logic, and persists or returns the result.

application/commands/create-user.command.ts

export class CreateUserCommand {
  constructor(
    public readonly email: string,
    public readonly name: string,
  ) {}
}
Enter fullscreen mode Exit fullscreen mode

application/commands/create-user.handler.ts

import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { Inject } from '@nestjs/common';
import { CreateUserCommand } from './create-user.command';
import { User } from '../../domain/user.entity';
import { UserRepository, USER_REPOSITORY } from '../../domain/user.repository';

@CommandHandler(CreateUserCommand)
export class CreateUserHandler implements ICommandHandler<CreateUserCommand> {
  constructor(
    @Inject(USER_REPOSITORY)
    private readonly userRepository: UserRepository,
  ) {}

  async execute(command: CreateUserCommand): Promise<string> {
    const existing = await this.userRepository.findByEmail(command.email);
    if (existing) {
      throw new Error(`User with email ${command.email} already exists`);
    }

    const user = User.create(command.email, command.name);
    await this.userRepository.save(user);

    return user.id;
  }
}
Enter fullscreen mode Exit fullscreen mode

Notice what's missing: no HTTP-specific concepts, no TypeORM, no NestJS response objects. This handler works identically whether triggered via REST, a CLI command, or a message queue consumer.


3. Infrastructure layer

Here's where NestJS, TypeORM, and the outside world come in.

infrastructure/persistence/user.orm-entity.ts

import { Entity, Column, PrimaryColumn, CreateDateColumn } from 'typeorm';

@Entity('users')
export class UserOrmEntity {
  @PrimaryColumn('uuid')
  id: string;

  @Column({ unique: true })
  email: string;

  @Column()
  name: string;

  @CreateDateColumn()
  createdAt: Date;
}
Enter fullscreen mode Exit fullscreen mode

infrastructure/persistence/typeorm-user.repository.ts

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { UserRepository } from '../../domain/user.repository';
import { User } from '../../domain/user.entity';
import { UserOrmEntity } from './user.orm-entity';

@Injectable()
export class TypeOrmUserRepository implements UserRepository {
  constructor(
    @InjectRepository(UserOrmEntity)
    private readonly ormRepo: Repository<UserOrmEntity>,
  ) {}

  async findById(id: string): Promise<User | null> {
    const record = await this.ormRepo.findOneBy({ id });
    return record ? this.toDomain(record) : null;
  }

  async findByEmail(email: string): Promise<User | null> {
    const record = await this.ormRepo.findOneBy({ email });
    return record ? this.toDomain(record) : null;
  }

  async save(user: User): Promise<void> {
    await this.ormRepo.save(this.toOrm(user));
  }

  private toDomain(record: UserOrmEntity): User {
    return new User(record.id, record.email, record.name, record.createdAt);
  }

  private toOrm(user: User): UserOrmEntity {
    const entity = new UserOrmEntity();
    entity.id = user.id;
    entity.email = user.email;
    entity.name = user.name;
    return entity;
  }
}
Enter fullscreen mode Exit fullscreen mode

The mapper (toDomain / toOrm) is the key. It keeps your ORM schema and your domain model independent — you can change one without touching the other.

infrastructure/http/users.controller.ts

import { Controller, Post, Body, HttpCode } from '@nestjs/common';
import { CommandBus } from '@nestjs/cqrs';
import { CreateUserDto } from './dto/create-user.dto';
import { CreateUserCommand } from '../../application/commands/create-user.command';

@Controller('users')
export class UsersController {
  constructor(private readonly commandBus: CommandBus) {}

  @Post()
  @HttpCode(201)
  async create(@Body() dto: CreateUserDto): Promise<{ id: string }> {
    const id = await this.commandBus.execute(
      new CreateUserCommand(dto.email, dto.name),
    );
    return { id };
  }
}
Enter fullscreen mode Exit fullscreen mode

The controller is intentionally thin. It validates the request (via DTOs + class-validator), dispatches a command, and returns a response. No business logic here.


4. Wiring it all together

users.module.ts

import { Module } from '@nestjs/common';
import { CqrsModule } from '@nestjs/cqrs';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserOrmEntity } from './infrastructure/persistence/user.orm-entity';
import { TypeOrmUserRepository } from './infrastructure/persistence/typeorm-user.repository';
import { USER_REPOSITORY } from './domain/user.repository';
import { CreateUserHandler } from './application/commands/create-user.handler';
import { UsersController } from './infrastructure/http/users.controller';

const CommandHandlers = [CreateUserHandler];

@Module({
  imports: [CqrsModule, TypeOrmModule.forFeature([UserOrmEntity])],
  controllers: [UsersController],
  providers: [
    ...CommandHandlers,
    {
      provide: USER_REPOSITORY,
      useClass: TypeOrmUserRepository,
    },
  ],
})
export class UsersModule {}
Enter fullscreen mode Exit fullscreen mode

The provide/useClass pair is where Dependency Inversion happens. The application layer depends on the USER_REPOSITORY token (a symbol), not on TypeORM directly. Swap useClass to an in-memory repository for tests — zero changes to handlers.


Testing the application layer in isolation

describe('CreateUserHandler', () => {
  let handler: CreateUserHandler;
  let repo: InMemoryUserRepository;

  beforeEach(() => {
    repo = new InMemoryUserRepository();
    handler = new CreateUserHandler(repo);
  });

  it('creates a user and returns its id', async () => {
    const id = await handler.execute(
      new CreateUserCommand('kuba@example.com', 'Kuba'),
    );
    expect(id).toBeDefined();
    const user = await repo.findByEmail('kuba@example.com');
    expect(user?.name).toBe('Kuba');
  });

  it('throws when email already taken', async () => {
    await handler.execute(new CreateUserCommand('kuba@example.com', 'Kuba'));
    await expect(
      handler.execute(new CreateUserCommand('kuba@example.com', 'Other')),
    ).rejects.toThrow('already exists');
  });
});
Enter fullscreen mode Exit fullscreen mode

No NestJS test harness, no database, no HTTP — just fast, focused unit tests.


Common tradeoffs to be aware of

"This is a lot of boilerplate for CRUD." Yes. For a simple 3-field entity, the overhead is real. Clean Architecture pays off when your domain has actual rules — validation, state machines, business constraints. If it's just a thin wrapper over a DB table, CQRS + ports-and-adapters might be overkill.

"Do I need separate ORM and domain entities?" Only if they diverge. If your TypeORM entity maps 1:1 to your domain object with no behavior, merge them for now and split later when needed. The important thing is that your application layer never imports typeorm directly.

"What about aggregates and domain events?" Out of scope for this guide, but a natural next step. Once your User.create() starts needing to publish an event (e.g., to send a welcome email), domain events give you a clean way to do it without coupling to side effects.


Summary

Layer Contains Can import
Domain Entities, value objects, repository interfaces Nothing external
Application Command/query handlers, use cases Domain only
Infrastructure Controllers, ORM repos, external clients Application + Domain + NestJS

Start with the domain. Define your ports. Let infrastructure implement them. Keep your handlers framework-free. Test the application layer without spinning up a full NestJS app.

The payoff isn't apparent on day one — it becomes obvious when you need to refactor and the blast radius is small.


If you're building something and applying these patterns, I'd love to see what you're working on — drop a link in the comments.

Top comments (0)