04/20/26

NestJS Project Structure: Best Practices for 2026

How to organize modules, services, and shared code so the codebase scales

8 Min Read

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.

The Starting Point

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.

Folder Structures That Work

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:

  • Moving a feature is moving one folder.
  • Deleting a feature is deleting one folder.
  • Finding code for a given domain is one place.
  • New devs find their way around faster.

Layered (avoid for anything non-trivial)

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.

Module Design

NestJS modules are both an organizational and a runtime concept: they define what's injectable where. Getting this right matters more than file layout.

One module per feature

@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.

Shared module for cross-cutting concerns

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.

Core module for one-time setup

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 {}

Lazy modules

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.

DTOs and Entities

Keep DTOs (shape of data in/out of the API) and entities (database models) separate. They look similar at first; they diverge over time.

  • DTOs live in <feature>/dto/: validated with class-validator, used by controllers.
  • Entities live in <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.

Configuration

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.

Testing Layout

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.

Error Handling

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.

Logging

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(),
});

Monorepo for Microservices

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.

Common Mistakes

  • Circular module dependencies. NestJS will tell you with a forwardRef() warning. Fix the structure, don't paper over it.
  • Putting business logic in controllers. Controllers handle HTTP, services handle business logic. Keep them thin.
  • Shared entity imports across microservices. If service A imports service B's entity, you don't have microservices, you have a distributed monolith.
  • Manual dependency wiring bypassing DI. If you new UsersService() instead of injecting it, you lose testability.
  • One giant AppModule. If app.module.ts imports 40 feature modules, it's telling you something. Break it up with domain modules.

When the Structure Itself Is the Problem

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.

A lighter approach

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:

  • A module declaration.
  • A separate DTO class.
  • A TypeORM entity with decorators.
  • Controller → Service → Repository indirection.
  • A testing module override.

What you get automatically:

  • Validation from the TypeScript types.
  • An OpenAPI spec.
  • A typed client SDK.
  • Distributed tracing.
  • Infrastructure provisioned on AWS or GCP.

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.

Deploy with Encore

Want to jump straight to a running app? Clone this starter and deploy it to your own cloud.

Deploy

Getting Started

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
Deploy with Encore

Want to jump straight to a running app? Clone this starter and deploy it to your own cloud.

Deploy

Ready to build your next backend?

Encore is the Open Source framework for building robust type-safe distributed systems with declarative infrastructure.