Email copiado — support@tuurt.com
Cargando experiencia
nestjs · May 05, 2026 · 9 min

Structure of a Scalable NestJS Project: Modules, Layers, and Boundaries

The architecture decisions you make in the first few weeks determine how much the project hurts at month 6. How to organize domain modules, separate layers, and keep clean boundaries in NestJS.

By Tuurt Team

Structure of a Scalable NestJS Project: Modules, Layers, and Boundaries

NestJS provides structure from day one, but that initial structure doesn't scale on its own. A project with 5 modules and 20 endpoints has different problems than one with 30 modules, parallel teams, and business logic that grows week after week.

This post is about the decisions you make (or don't make) in the first few weeks that determine how much the project hurts at month 6.


The most common mistake: modules by type, not by domain

The structure that appears in every tutorial:

src/
  controllers/
  services/
  repositories/
  dto/

Works for a tutorial. Doesn't work for a real project. When you have 15 controllers, 15 services, and 15 repos, finding everything related to "Payments" means navigating 4 different folders.

The alternative is organizing by domain:

src/
  users/
    users.module.ts
    users.controller.ts
    users.service.ts
    users.repository.ts
    dto/
  orders/
    orders.module.ts
    ...
  payments/
    ...

Each domain is a cohesive unit. Everything related to Users lives in users/. When someone touches payments, they know exactly where to look.


Layers inside each module

Within each domain module, layer separation is what keeps logic manageable:

Entry layer (controllers): only receives the HTTP request, validates the DTO, calls the service, returns the response. Zero business logic. If your controller does more than 3 things, something is wrong.

Application layer (services): orchestrates use cases. Calls repositories, emits events, coordinates with other services. Business logic lives here, but not persistence details.

Domain layer (entities, value objects): business rules that don't depend on any infrastructure. An Order knows whether it can be cancelled; it knows nothing about HTTP or SQL.

Infrastructure layer (repositories, adapters): persistence details, external calls, queues. Implements interfaces defined in the application layer.

In small projects, the separation between application and domain can be excessive. In projects that will grow, it's the difference between being able to test business logic without a database or not.


Boundaries: the rule that gets violated the most

A boundary is the limit of what a module can see from the rest of the system. In NestJS, it's defined by what you export from each module.

The typical mistake: exporting everything and letting any module import whatever it wants from any other. It looks like this:

// orders.module.ts — exports everything
@Module({
  exports: [OrdersService, OrdersRepository, OrdersMapper],
})

And it ends with PaymentsService directly using OrdersRepository because "it was easier." Now Payments knows the persistence details of Orders. That's a broken boundary.

The rule: export only what other modules need to know. Almost always, that's just the Service.

// orders.module.ts — clean boundary
@Module({
  exports: [OrdersService], // only the public interface
})

If another module needs to do something with Orders, it does it through the service. The repository and internal details are private to the module.


SharedModule: useful and dangerous at the same time

The SharedModule is where things everyone needs go: utilities, guards, global pipes, helpers. It's convenient, which is why it gets abused.

The symptom of an unhealthy SharedModule: it grows uncontrolled and ends up being a junk drawer with 40 providers. When everything is in Shared, nothing has a clear owner.

Rules to keep it healthy:

  • Only truly cross-cutting concerns go in: auth guards, validation pipes, date/string utilities, global interceptors.
  • If something is only used by 2 or 3 modules, it doesn't go in Shared — it goes in the module that makes the most sense as owner and gets selectively exported.
  • Review it monthly. If it's growing, it's a sign that something that should have its own module is hiding in there.

Circular dependencies: how to avoid them before they appear

Circular dependencies in NestJS are a runtime problem, not a compile-time one. They appear late, are hard to debug, and are almost always a symptom of a design that mixes responsibilities.

The most common cause: UsersService needs OrdersService and OrdersService needs UsersService.

Solutions before reaching for forwardRef():

Option 1 — Events: instead of A calling B directly, A emits an event and B listens. NestJS's EventEmitter or an internal message bus breaks the direct dependency.

Option 2 — Coordination module: if A and B need each other mutually, there's probably a third responsibility that should be in a module C. Extracting it resolves the circularity.

Option 3 — Move shared logic: if the logic causing the circularity is small, moving it to SharedModule or a common/ module can solve the problem without complicating the architecture.

forwardRef() is the last resort, not the first solution.


Hexagonal vs feature-folder: when to use each

Feature-folder (what we've described so far) is the pragmatic option. Domain modules, internal layers, clean boundaries. Works well for most product projects.

Hexagonal (ports & adapters) makes sense when:

  • The domain has complex logic that needs to be tested in isolation.
  • There are multiple input adapters (HTTP, gRPC, events) or output adapters (different databases, interchangeable external services).
  • The team has experience with the pattern and the project has a long lifespan.

In practice: start with a well-structured feature-folder. Migrate specific parts to hexagonal when the domain justifies it. Don't start with pure hexagonal from day one unless you have concrete reasons.


The structure that scales

src/
  common/
    guards/
    pipes/
    interceptors/
    decorators/
  config/
  database/
  users/
    dto/
    entities/
    users.controller.ts
    users.service.ts
    users.repository.ts
    users.module.ts
  orders/
  payments/
  app.module.ts
  main.ts

No controllers/, services/, repositories/ folders at the root level. No bloated SharedModule. Each domain with its own directory and module.


Conclusion

NestJS gives you the pieces. You provide the architecture. The decisions about how to organize modules, where to put boundaries, and how to separate layers aren't cosmetic — they determine how much the project can grow before the cost of each change starts rising.

Investing a day thinking about structure at the start is worth more than two weeks of refactoring at month 8.

nestjs architecture node typescript backend best-practices
← Back to blog