NestJS's biggest strength is that it hands you an architecture. Its biggest weakness is that the architecture is open-ended: you still have to decide how modules relate, where shared code lives, and how to keep things consistent as the app grows. A 5-controller hello-world is fine. A 50-controller production backend with three teams contributing? Structure starts to matter a lot.
This guide covers the structural decisions that actually affect maintainability, the patterns that work, the ones that become regretful technical debt, and when it makes sense to step back and reconsider whether NestJS's structure is still the right fit.
nest new gives you this:
src/
├── app.controller.ts
├── app.module.ts
├── app.service.ts
└── main.ts
That's fine for a demo. You'll outgrow it at 5-10 endpoints. What replaces it is the first decision worth getting right.
Group by feature, not by technical layer. Each feature is a module with everything it needs (controllers, services, DTOs, entities, 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
├── orders/
│ ├── dto/
│ ├── entities/
│ ├── orders.controller.ts
│ ├── orders.service.ts
│ └── orders.module.ts
├── shared/
│ ├── decorators/
│ ├── filters/
│ ├── guards/
│ └── interceptors/
├── app.module.ts
└── main.ts
Why this works:
src/
├── controllers/
├── services/
├── dtos/
├── entities/
└── modules/
This mirrors an MVC frame, but breaks at scale. A change to "users" touches four folders. Pull requests span unrelated files. Dependencies across layers become invisible.
Use feature-based unless you're writing a tutorial. Every NestJS codebase that grew past 20 controllers with a layered structure ends up regretting it.
NestJS modules are both an organizational and a runtime concept: they define what's injectable where. Getting this right matters more than file layout.
@Module({
imports: [TypeOrmModule.forFeature([User])],
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService], // so other modules can inject it
})
export class UsersModule {}
Feature modules should export only what other modules need to inject. Don't export everything by default.
Create a SharedModule for filters, interceptors, pipes, and utility providers that many features use:
@Global()
@Module({
providers: [LoggerService, HttpExceptionFilter],
exports: [LoggerService, HttpExceptionFilter],
})
export class SharedModule {}
@Global() makes these available everywhere without re-importing. Use it sparingly, only for genuinely cross-cutting concerns. Overusing @Global() recreates the "everything is available everywhere" problem that modules were meant to solve.
Some concerns (database connection, logger configuration, global interceptors) should only initialize once. Put those in a CoreModule, imported only in AppModule:
@Module({
imports: [TypeOrmModule.forRoot({ /* ... */ })],
exports: [TypeOrmModule],
})
export class CoreModule {}
For large apps, consider loading some modules lazily:
@Module({ /* ... */ })
export class AdminModule {}
// In app.module.ts, use dynamic import in a route or feature flag
Lazy loading matters for serverless cold starts and very large apps. For most deployments, eager loading is fine and simpler.
Keep DTOs (shape of data in/out of the API) and entities (database models) separate. They look similar at first; they diverge over time.
<feature>/dto/: validated with class-validator, used by controllers.<feature>/entities/: annotated with TypeORM/Prisma/Drizzle decorators, used by services.Never expose an entity directly from a controller. It leaks internal fields, locks you into your DB schema, and makes API changes expensive.
// users/dto/user-response.dto.ts
export class UserResponseDto {
id: number;
email: string;
name: string;
// no password, no internal flags
}
// users/users.service.ts
async findOne(id: number): Promise<UserResponseDto> {
const user = await this.repo.findOne(id);
return { id: user.id, email: user.email, name: user.name };
}
A library like class-transformer with @Exclude() decorators can automate this, but be explicit about which fields leave the service.
Use @nestjs/config with Joi or Zod schema validation:
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
validationSchema: Joi.object({
DATABASE_URL: Joi.string().required(),
JWT_SECRET: Joi.string().required(),
PORT: Joi.number().default(3000),
}),
}),
],
})
export class AppModule {}
Fail fast if config is invalid. A NestJS app that starts with missing env vars and crashes an hour later under load is worse than one that won't start at all.
Co-locate unit tests with the code they test:
users/
├── users.service.ts
└── users.service.spec.ts
Put e2e tests in a separate top-level folder:
test/
├── users.e2e-spec.ts
└── orders.e2e-spec.ts
NestJS's testing module lets you mock any provider in a single line, which is the real DX win of the DI architecture:
const module = await Test.createTestingModule({
providers: [
UsersService,
{ provide: UsersRepository, useValue: mockRepository },
],
}).compile();
Use this for unit tests. For e2e, spin up the real AppModule with a test database and use supertest.
Centralize error handling with a filter:
// shared/filters/http-exception.filter.ts
@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() : 500;
const message =
exception instanceof HttpException
? exception.getResponse()
: "Internal server error";
response.status(status).json({
statusCode: status,
message,
timestamp: new Date().toISOString(),
});
}
}
Register globally in main.ts:
app.useGlobalFilters(new GlobalExceptionFilter());
This keeps error format consistent across every endpoint. Don't let individual controllers format errors their own way.
Use a structured logger (Pino, Winston) wrapped as a NestJS provider. Don't use console.log: you'll regret it when you add observability.
@Injectable()
export class AppLogger extends Logger {
log(message: string, context?: string) {
// structured JSON output
}
}
Register in main.ts before anything else:
const app = await NestFactory.create(AppModule, {
logger: new AppLogger(),
});
If you end up splitting into microservices, use nest new --monorepo (or set up a Turborepo / Nx workspace). Shared DTOs go in a libs/ package imported by every service:
apps/
├── users-service/
├── orders-service/
└── gateway/
libs/
├── shared-dto/
├── shared-auth/
└── shared-logging/
This gets painful fast, versioning shared libs across services, keeping them in sync, managing deploys, but there aren't great alternatives if you're committed to NestJS microservices. See our NestJS Microservices Guide for the full picture.
forwardRef() warning. Fix the structure, don't paper over it.new UsersService() instead of injecting it, you lose testability.AppModule. If app.module.ts imports 40 feature modules, it's telling you something. Break it up with domain modules.This is worth asking honestly: once you've set up modules, providers, DTOs, entities, guards, interceptors, pipes, filters, and decorators, have you built much that delivers business value?
NestJS's structure is a solution to a problem: keeping a large TypeScript backend coherent across many developers. If you're a team of 2-5, the structure often costs more than it earns. You're writing framework wiring instead of features.
Encore takes a different position: the framework handles the structure decisions so you don't have to. An Encore service is just a folder with an encore.service.ts file. An endpoint is a function with typed parameters. There's no DI container, no module system, no providers.
src/
├── users/
│ ├── encore.service.ts
│ ├── users.ts // endpoints + business logic
│ └── migrations/ // database schema
├── orders/
│ ├── encore.service.ts
│ ├── orders.ts
│ └── migrations/
└── encore.app
Compare that to the NestJS equivalent. Same functionality, 80% less scaffolding. No modules to register, no providers to inject, no DTOs to duplicate against entities.
// users/users.ts
import { api } from "encore.dev/api";
import { SQLDatabase } from "encore.dev/storage/sqldb";
// Provisions managed Postgres, Docker locally, RDS or Cloud SQL in production.
const db = new SQLDatabase("users", { migrations: "./migrations" });
interface User {
id: number;
email: string;
name: string;
}
export const get = api(
{ method: "GET", path: "/users/:id", expose: true },
async ({ id }: { id: number }): Promise<User> => {
return await db.queryRow`SELECT * FROM users WHERE id = ${id}`;
},
);
What you don't write:
What you get automatically:
For a new backend, this is usually a better starting point than NestJS. For an existing NestJS codebase, sticking with what you have is normally right, the structural decisions are already made.
Want to jump straight to a running app? Clone this starter and deploy it to your own cloud.
For a new NestJS project with good structure from day one:
npm install -g @nestjs/cli
nest new my-app
cd my-app
nest generate module users
nest generate controller users
nest generate service users
For a new backend that skips most of the structure decisions:
brew install encoredev/tap/encore
encore app create my-app --example=ts/empty
cd my-app && encore run
Want to jump straight to a running app? Clone this starter and deploy it to your own cloud.