Building a NestJS backend is straightforward. Building one that stays maintainable when your codebase grows from 5 controllers to 50, and your team grows from 2 engineers to 10, is a different challenge entirely.
NestJS gives you a strong starting point with its Angular-inspired architecture. Modules, controllers, services, dependency injection, decorators — the framework hands you the vocabulary. What it does not hand you is the structural discipline to use that vocabulary well as the application scales.
This guide covers the NestJS project structure and modular architecture decisions that actually matter in production: the folder conventions that survive team growth, the module patterns that prevent coupling nightmares, the DTO and entity separation that keeps your API layer clean, and the common mistakes that cause experienced teams to spend weeks refactoring codebases they built in a hurry.
Whether you are starting a new NestJS backend from scratch or improving the structure of an existing one, this is the practical reference you need.
What Is NestJS Modular Architecture and Why Does It Matter?
NestJS modularity is the framework’s core organising principle. Every NestJS application is composed of modules — TypeScript classes decorated with @Module() that group related controllers, services, and providers into self-contained units.
The module system is not just an organisational convention. It is a runtime concept. NestJS’s dependency injection container uses module boundaries to determine what is injectable where. This means structural decisions about modules directly affect what your application can do, not just how your files are arranged.
A well-designed NestJS backend architecture produces a codebase where:
- Adding a new feature means adding a new module, not touching existing ones
- Deleting a feature means deleting one folder, not hunting through four layers
- A new developer can find all the logic for a given domain in one place
- Circular dependencies surface immediately rather than silently causing runtime failures
A poorly designed one produces a codebase where a change to the users feature touches controllers, services, DTOs, entities, and modules spread across unrelated folders, and every pull request becomes an archaeology project.
The structural decisions covered below are the ones that determine which outcome you get.
NestJS Project Structure: Feature-Based vs Layered
The first and most consequential decision in any NestJS project is how to organise your folders.
The Layered Structure (Avoid for Anything Non-Trivial)
The default instinct for many developers coming from MVC frameworks is to organise by technical layer:
src/
├── controllers/
│ ├── users.controller.ts
│ └── products.controller.ts
├── services/
│ ├── users.service.ts
│ └── products.service.ts
├── entities/
│ ├── user.entity.ts
│ └── product.entity.ts
└── modules/
\ ├── users.module.ts
\ └── products.module.ts
This looks tidy at 10 files. At 100 files, a change to the users domain touches four separate folders. Pull requests span unrelated code. Dependencies between features become invisible. New developers cannot locate the full context of any given feature without searching across the entire codebase.
This structure is fine for tutorials. It becomes technical debt in production.
The Feature-Based Structure (Recommended)
Group by domain feature, not by technical layer. Every feature owns its controllers, services, DTOs, entities, and tests in one folder:
src/
├── users/
│ ├── dto/
│ │ ├── create-user.dto.ts
│ │ └── update-user.dto.ts
│ ├── entities/
│ │ └── user.entity.ts
│ ├── users.controller.ts
│ ├── users.service.ts
│ ├── users.module.ts
│ └── users.service.spec.ts
├── products/
│ ├── dto/
│ ├── entities/
│ ├── products.controller.ts
│ ├── products.service.ts
│ └── products.module.ts
├── orders/
│ ├── dto/
│ ├── entities/
│ ├── orders.controller.ts
│ ├── orders.service.ts
│ └── orders.module.ts
├── shared/
│ ├── decorators/
│ ├── filters/
│ ├── guards/
│ └── interceptors/
├── core/
│ └── core.module.ts
├── app.module.ts
└── main.ts
This is the NestJS project structure best practice that scales. Moving a feature is moving one folder. Deleting a feature is deleting one folder. Onboarding a new developer means pointing them at one directory per domain rather than explaining how four layers fit together.
NestJS Module Design: The Patterns That Work
Understanding NestJS modularity at the code level is what separates backends that scale cleanly from ones that accumulate hidden coupling.
One Module Per Feature
Each feature should have its own dedicated module. The module declares what it provides and explicitly exports only what other modules need to inject:
typescript// users/users.module.ts
import { Module } from ‘@nestjs/common’;
import { TypeOrmModule } from ‘@nestjs/typeorm’;
import { UsersController } from ’./users.controller’;
import { UsersService } from ’./users.service’;
import { User } from ’./entities/user.entity’;
@Module({
imports: [TypeOrmModule.forFeature([User])],
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService], // only export what other modules genuinely need
})
export class UsersModule {}
Do not export everything by default. Exporting everything recreates the global scope problem that modules were designed to solve.
Shared Module for Cross-Cutting Concerns
Filters, interceptors, pipes, decorators, and utility providers that are used across many features belong in a SharedModule:
typescript// shared/shared.module.ts
import { Global, Module } from ‘@nestjs/common’;
import { LoggerService } from ’./logger/logger.service’;
import { HttpExceptionFilter } from ’./filters/http-exception.filter’;
@Global()
@Module({
providers: [LoggerService, HttpExceptionFilter],
exports: [LoggerService, HttpExceptionFilter],
})
export class SharedModule {}
The @Global() decorator makes these providers available throughout the application without re-importing the SharedModule in every feature module. Use @Global() sparingly — only for genuinely application-wide concerns. If you find yourself making every service global, that is a signal that your module boundaries are not right.
Core Module for One-Time Setup
Database connections, global interceptor configuration, and other one-time initialisation concerns belong in a CoreModule, imported only in AppModule:
typescript// core/core.module.ts
import { Module } from ‘@nestjs/common’;
import { TypeOrmModule } from ‘@nestjs/typeorm’;
import { ConfigModule, ConfigService } from ‘@nestjs/config’;
@Module({
imports: [
\ TypeOrmModule.forRootAsync({
\ imports: [ConfigModule],
\ useFactory: (config: ConfigService) => ({
\ type: ‘postgres’,
\ url: config.get(‘DATABASE_URL’),
\ autoLoadEntities: true,
\ }),
\ inject: [ConfigService],
\ }),
],
exports: [TypeOrmModule],
})
export class CoreModule {}
AppModule then stays clean — it imports CoreModule and feature modules, and nothing else:
typescript// app.module.ts
import { Module } from ‘@nestjs/common’;
import { CoreModule } from ’./core/core.module’;
import { SharedModule } from ’./shared/shared.module’;
import { UsersModule } from ’./users/users.module’;
import { ProductsModule } from ’./products/products.module’;
import { OrdersModule } from ’./orders/orders.module’;
@Module({
imports: [CoreModule, SharedModule, UsersModule, ProductsModule, OrdersModule],
})
export class AppModule {}
DTOs vs Entities: Keep Them Separate
One of the most consistent sources of technical debt in NestJS backends is conflating DTOs with entities. They look similar at first. They diverge significantly over time.
Entities are database models. They carry ORM decorators and reflect your persistence layer schema.
DTOs (Data Transfer Objects) define the shape of data entering and leaving your API. They carry validation decorators and reflect your API contract.
// users/entities/user.entity.ts
import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn } from ‘typeorm’;
@Entity(‘users’)
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column({ unique: true })
email: string;
@Column()
passwordHash: string; // internal field, never exposed
@CreateDateColumn()
createdAt: Date;
}
// users/dto/create-user.dto.ts
import { IsEmail, MinLength, IsString } from ‘class-validator’;
export class CreateUserDto {
@IsEmail()
email: string;
@IsString()
@MinLength(8)
password: string; // raw password for hashing, not stored as-is
}
// users/dto/user-response.dto.ts
export class UserResponseDto {
id: number;
email: string;
createdAt: Date;
// passwordHash is deliberately absent
}
Never return an entity directly from a controller. It exposes internal fields, locks your API to your database schema, and makes schema changes break your API contract. Map entities to response DTOs explicitly in your service layer.
Configuration with Validation
A NestJS application that starts with missing or invalid environment variables and fails an hour into production load is worse than one that refuses to start. Use @nestjs/config with schema validation:
// app.module.ts
import { ConfigModule } from ‘@nestjs/config’;
import * as Joi from ‘joi’;
@Module({
imports: [
\ ConfigModule.forRoot({
\ isGlobal: true,
\ validationSchema: Joi.object({
\ DATABASE_URL: Joi.string().required(),
\ JWT_SECRET: Joi.string().min(32).required(),
\ PORT: Joi.number().default(3000),
\ NODE_ENV: Joi.string()
\ .valid(‘development’, ‘production’, ‘test’)
\ .default(‘development’),
\ }),
\ }),
\ // other imports
],
})
export class AppModule {}
Fail fast. If configuration is invalid, the application should not start. This surfaces environment variable problems in deployment pipelines before they reach production traffic.
Use Dependency Injection Correctly
NestJS’s dependency injection system is what makes the modular architecture testable and maintainable. Using it correctly means more than just using constructor(private readonly service: SomeService).
Inject services and repositories rather than instantiating them manually. If you write new UsersService() anywhere in application code, you bypass the DI container and lose testability.
Keep constructor injection lists short. A service with seven injected dependencies is telling you it has too many responsibilities. Split it.
Avoid circular dependencies. If Module A depends on Module B and Module B depends on Module A, you have a structural problem that forwardRef() papers over rather than fixes. Restructure your module boundaries so the dependency flows in one direction.
Error Handling: Centralise It
Inconsistent error response shapes across endpoints are one of the fastest ways to frustrate API consumers and complicate client-side error handling. Centralise error handling with a global exception filter:
// shared/filters/http-exception.filter.ts
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
} from ‘@nestjs/common’;
@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
\ const ctx = host.switchToHttp();
\ const response = ctx.getResponse();
\ const status =
\ exception instanceof HttpException
\ ? exception.getStatus()
\ : HttpStatus.INTERNAL_SERVER_ERROR;
\ const message =
\ exception instanceof HttpException
\ ? exception.getResponse()
\ : ‘Internal server error’;
\ response.status(status).json({
\ statusCode: status,
\ message,
\ timestamp: new Date().toISOString(),
\ path: ctx.getRequest().url,
\ });
}
}
Register globally in main.ts:
typescriptapp.useGlobalFilters(new GlobalExceptionFilter());
Every endpoint now returns errors in the same shape. Client-side error handling becomes predictable. Monitoring tools can parse your error responses reliably.
Testing Layout in a NestJS Backend
Co-locate unit tests with the files they test. This is the NestJS project structure best practice that most teams get right in principle and wrong in execution by separating test files into a parallel directory:
users/
├── users.service.ts
├── users.service.spec.ts // unit test lives here, not in a /test folder
├── users.controller.ts
└── users.controller.spec.ts
Put end-to-end tests in a top-level test/ directory:
test/
├── users.e2e-spec.ts
└── orders.e2e-spec.ts
NestJS’s testing module makes mocking straightforward:
// users/users.service.spec.ts
const module = await Test.createTestingModule({
providers: [
\ UsersService,
\ {
\ provide: getRepositoryToken(User),
\ useValue: {
\ findOne: jest.fn(),
\ save: jest.fn(),
\ },
\ },
],
}).compile();
Write unit tests for services and utility functions. Write end-to-end tests for full API behaviour using supertest against a real AppModule with a test database. Do not aim for 100% coverage — aim for tests that catch real regressions in your core business logic.
Common Mistakes in NestJS Backend Architecture
These are the patterns that produce the most expensive refactoring work in production NestJS codebases.
Using the layered folder structure past 20 controllers. Every experienced NestJS team that has maintained a layered structure at scale has regretted it. Make the feature-based switch before you need to, not after.
Putting business logic in controllers. Controllers handle HTTP routing and response formatting. Services handle business logic. Repositories handle data access. If your controller is doing conditional logic, calculations, or data transformation, it has too much responsibility.
Exporting everything from every module. This recreates global scope within a module system that exists to prevent global scope. Export only what other modules explicitly need to inject.
Returning entities directly from controllers. This is the pattern that causes security incidents (exposed internal fields), tight coupling (API changes require database schema changes), and maintenance debt (schema evolution breaks API consumers).
One giant AppModule. If app.module.ts is importing 30 feature modules directly, it should be importing domain modules that group related features. An AppModule with 30 direct imports is a flat list masquerading as architecture.
Ignoring circular dependency warnings. NestJS will warn you with a forwardRef() requirement. The right response is to fix the module structure, not to use forwardRef() as a permanent solution.
Skipping configuration validation. An app that starts successfully with a malformed or missing DATABASE_URL and then crashes under load is a production incident waiting to happen. Validate configuration at startup.
NestJS Backend Architecture for Microservices
If your NestJS backend is growing toward a microservices architecture, use nest new —monorepo or set up a Turborepo or Nx workspace from the start. Shared DTOs and contracts between services belong in a libs/ package:
apps/
├── users-service/
├── orders-service/
└── api-gateway/
libs/
├── shared-dto/
├── shared-auth/
└── shared-logging/
The key rule for microservices: if Service A imports Service B’s entity directly, you do not have microservices. You have a distributed monolith. Shared contracts between services should live in shared libraries, not by importing each other’s internal types.
Building NestJS Backends That Last
The decisions that determine whether a NestJS backend is maintainable at scale are made in the first weeks of the project. Feature-based folder structure, clean module boundaries with explicit exports, separated DTOs and entities, validated configuration, centralised error handling, and co-located tests are not optimisations you add later. They are the foundation that makes later optimisation possible.
A backend built with this structure can absorb new features without touching existing ones, onboard new developers without extensive explanation, and survive the growth from a small team to a large one without a structural rewrite.
If you are building a new NestJS backend from scratch and want an engineering partner who gets these decisions right from day one, or if you have an existing backend that has grown in ways that now make it difficult to extend, our team at Capital Compute brings the kind of production-level NestJS experience that makes these structural decisions confidently rather than by trial and error. Explore our NestJS developer services or get in touch for an architecture review to see how we can help.


