← Back to list

Clean Code Architecture in NestJS: What I Learned After a Year of Building With It

I started my backend career with plain Node.js and Express. About a year ago I moved to NestJS, and it changed how I structure backend…

Dilshan Wickramasinghe · 2026-07-20 17:34 · 2 claps · 6.0 min read
#nestjs #clean-code-architecture #nodejs #layer-architecture #coding
Open on Medium ↗
Wiki topics: 💻 · Programming 🌐 · Web Development 📰 · Journalism & News 🏛️ · Architecture

Clean Code Architecture in NestJS: What I Learned After a Year of Building With It

I started my backend career with plain Node.js and Express. About a year ago I moved to NestJS, and it changed how I structure backend projects for the better. This is what I’ve learned since then about building NestJS projects the right way, using a clean, layered architecture instead of just piling logic into controllers.

I’ll walk through why NestJS is a better starting point than plain Node/Express, how Nest’s module-based architecture pushes you toward good structure from day one, why a layered (clean) architecture takes that even further, and how I actually apply it in my own projects, including a few public repos you can look through yourself.

Why NestJS Over Plain Node/Express

When I was using Express, every project ended up looking different. There’s no enforced structure, so every developer invents their own conventions, and six months later even you don’t remember why something was done a certain way.

A few things pushed me toward NestJS:

  • Structure is enforced, not optional. Modules, controllers, providers. Nest gives you a shape to work within from day one instead of deciding folder structure from scratch every time.
  • Dependency Injection out of the box. In Express I was manually wiring services together. Nest has DI built in, which makes testing and swapping implementations much easier.
  • It scales into microservices without a rewrite. At my current job I built a gaming platform where an API gateway sits in front of separate account and lobby services, talking over RabbitMQ. Doing that cleanly in raw Express would mean building a lot of the plumbing yourself. Nest’s microservices module, plus things like @nestjs/throttler for rate limiting and built-in support for message brokers, meant I could focus on domain logic instead of infrastructure glue.
  • TypeScript-first. Decorators, DTOs, and class-validator for request validation all fit together instead of being bolted on.

Express is still great for small, simple services. But once a project has more than one developer, or is expected to grow, the lack of opinions in Express starts costing more than it saves.

Nest’s Module-Based Architecture

Before getting into layered architecture, it’s worth understanding what Nest already gives you, because the layered approach builds directly on top of it.

Nest organizes everything around modules. Every distinct feature or domain (users, auth, tickets, payments) gets its own module, and each module bundles its own controllers, providers (services), and anything it exports for other modules to use. This is enforced through the @Module() decorator, which explicitly declares what a module owns and what it depends on:

@Module({
  imports: [TypeOrmModule.forFeature([User])],
  controllers: [UsersController],
  providers: [UsersService, UsersRepository],
  exports: [UsersService],
})
export class UsersModule {}

This matters more than it looks like at first. Because dependencies between modules are explicit, you can’t accidentally create tangled imports across the codebase the way you can in a plain Express app where any file can require() any other file. If AuthModule needs something from UsersModule, it has to import it properly and Nest's DI container resolves it.

Nest also gives you the CLI to scaffold this consistently instead of hand-rolling folders every time:

nest g module users
nest g controller users
nest g service users

These commands generate the boilerplate and automatically wire the pieces into the module, so every feature in the app ends up with the same shape without anyone having to remember the convention manually. That consistency alone removes a lot of the “where does this go” friction that plain Express projects run into once more than one person is working on them.

What Nest’s module system does not enforce on its own is what happens inside a module. It’s entirely possible to write a Nest app where every controller talks directly to the database, with business logic mixed into the same method that handles the HTTP request. That’s technically valid Nest code, but it brings back the same problems Express has, just wrapped in nicer decorators. That’s the gap a layered architecture closes.

Why Layered (Clean) Architecture

The idea is simple: separate what a request is, what it means, and how it’s stored.

  • Controller layer: only handles HTTP concerns such as routes, request/response shape, and status codes. No business logic here.
  • Service layer: this is where the actual business rules live. Can this user redeem this ticket? Is this bet allowed given the current round state? That kind of thing.
  • Repository / data layer: only knows how to talk to the database. Controllers and services never touch TypeORM or Prisma directly.
  • DTOs: define what data is allowed in and out, validated with class-validator.

This is what a layered architecture improves on top of Nest’s module structure:

  1. Testing gets much easier. If business logic sits in a service that only depends on an injected repository, that service can be unit tested without spinning up a real database.
  2. Bugs get isolated. When I built the scratch-card ticket engine at work, the piece that validates tickets against a canonical weight table, generates near-misses, and checks mod-97 checksums, having that logic isolated in its own service meant I could reason about correctness without HTTP or database noise mixed in.
  3. Swapping implementations doesn’t break everything. Changing the ORM, adding a caching layer with Redis, or putting a circuit breaker in front of a downstream service call shouldn’t force changes to the controllers.
  4. Onboarding is faster. A new developer sees “controller calls service, service calls repository” and immediately knows where to look for something.

This isn’t strict textbook Clean Architecture or full DDD with entities, value objects, and aggregates in every project. It’s a pragmatic, layered version of it that holds up under real deadlines and a small team, while still keeping the separation of concerns that matters most.

How to Structure It

Here’s roughly what the folder structure looks like in a well-organized Nest service:

src/
├── main.ts
├── app.module.ts
│
├── common/
│   ├── filters/            # exception filters
│   ├── guards/              # auth guards
│   ├── interceptors/        # logging, response transform
│   └── decorators/
│
├── config/
│   └── configuration.ts
│
└── modules/
    └── users/
        ├── users.module.ts
        ├── controllers/
        │   └── users.controller.ts
        ├── services/
        │   └── users.service.ts
        ├── repositories/
        │   └── users.repository.ts
        ├── entities/
        │   └── user.entity.ts
        └── dto/
            ├── create-user.dto.ts
            └── update-user.dto.ts

Each domain (users, auth, tickets, lobby) gets its own module folder with the same internal shape. That consistency is the whole point: anyone can open a module they’ve never touched before and already know where things are.

A quick example of how the layers talk to each other:

// dto/create-user.dto.ts
export class CreateUserDto {
  @IsEmail()
  email: string;  @IsString()
  @MinLength(8)
  password: string;
}

// controllers/users.controller.ts
@Controller('users')
export class UsersController {
  constructor(private readonly usersService: UsersService) {}
@Post()
  create(@Body() dto: CreateUserDto) {
    return this.usersService.createUser(dto);
  }
}

// services/users.service.ts
@Injectable()
export class UsersService {
  constructor(private readonly usersRepository: UsersRepository) {}
  async createUser(dto: CreateUserDto) {
    const existing = await this.usersRepository.findByEmail(dto.email);
    if (existing) {
      throw new ConflictException('Email already in use');
    }
    const hashed = await bcrypt.hash(dto.password, 10);
    return this.usersRepository.create({ ...dto, password: hashed });
  }
}

// repositories/users.repository.ts
@Injectable()
export class UsersRepository {
  constructor(
    @InjectRepository(User) private readonly repo: Repository<User>,
  ) {}
  findByEmail(email: string) {
    return this.repo.findOne({ where: { email } });
  }
  create(data: Partial<User>) {
    return this.repo.save(this.repo.create(data));
  }
}

Nothing fancy here. The controller doesn’t know how passwords are hashed, the service doesn’t know it’s TypeORM under the hood, and the repository doesn’t know or care that the data came from an HTTP request. That separation is the whole point of the pattern.

When this scales into microservices, the same layering repeats per service, with an API gateway acting as the entry point that routes to each one over RabbitMQ, with circuit breakers and retries in between so one flaky service doesn’t take down the whole platform.

Public Repos Using This Pattern

If you want to see this applied beyond a small example, I’ve broken a microservices setup into separate repos, plus a smaller full-stack project that uses the same layered pattern at monolith scale:

Feel free to clone any of these and look through the code.

Wrapping Up

Moving from Express to NestJS was never really about the framework being “better” in the abstract. It was about not having to reinvent structure on every project, and having a framework that scales toward microservices when a project actually needs it. Layered architecture on top of Nest’s module system is what keeps a codebase maintainable as it grows: clear boundaries between HTTP handling, business logic, and data access.

This is the approach I use across production systems now, from a gaming platform handling real-time multiplayer state to streaming services and smaller side projects. If you’re coming from Express and considering the move, start with the module and folder structure above, keep controllers thin, and the rest follows naturally.


메타데이터
post_id
1dd29fdc380b
slug
clean-code-architecture-in-nestjs-what-i-learned-after-a-year-of-building-with-it-1dd29fdc380b
url
https://medium.com/@dilshanmw717/clean-code-architecture-in-nestjs-what-i-learned-after-a-year-of-building-with-it-1dd29fdc380b
canonical_url
https://medium.com/@dilshanmw717/clean-code-architecture-in-nestjs-what-i-learned-after-a-year-of-building-with-it-1dd29fdc380b
author_url
https://medium.com/@dilshanmw717
status
ok
fetched_at
2026-08-08 16:47:33