12k
All articles

Structuring a Large Node.js App with NestJS

Structure a large NestJS app by feature with modules, controllers, services, DTO validation, guards, config and tests. See how explicit imports and exports keep dependencies clear.

OpenReplay Team
OpenReplay Team
Structuring a Large Node.js App with NestJS

A large NestJS app should be organized by feature: give each domain (users, orders, billing) its own module folder holding its controller, service, DTOs and tests, and let modules share code only through explicit exports and imports.

If you’re inheriting a growing Express API, the symptoms are familiar. A single route file does auth, validation and database writes, and process.env is read in a dozen places. Nobody is sure which helper is safe to change.

This article builds one small users-and-orders API and puts each piece where it belongs: modules, controllers, providers, validation, guards, config, a shared module and a unit test. If you’re still choosing a framework, Is There a Rails for JavaScript? compares the alternatives.

Key Takeaways

  • A NestJS module keeps its providers private by default. Another module can inject one only if the owning module exports it and the consuming module imports that module.
  • Organizing by feature keeps each domain’s module, controller, service, DTOs and tests in one folder, so a change to orders stays inside src/orders/.
  • With whitelist enabled, ValidationPipe strips properties that have no validation decorator. Adding forbidNonWhitelisted makes those requests fail instead.
  • A guard that returns false makes Nest throw a ForbiddenException, so auth checks live in one class instead of in every handler.
  • An unresolved circular import between modules stops Nest at bootstrap. Extracting the shared concept is a better fix than forwardRef().

The Problem: An Express App With No Rules

Express sets no rules about where code goes, so a growing app ends up arranged however its first few handlers happened to be written. Most projects start the way a guide on setting up Node.js with TypeScript and Express describes: one app.ts, a routes folder and some middleware. Within a few months, a handler looks like this:

app.post('/orders', (req, res) => {
  if (req.headers['x-api-key'] !== process.env.API_KEY) return res.sendStatus(403);
  const { userId, items } = req.body;
  if (typeof userId !== 'number' || !Array.isArray(items)) return res.sendStatus(400);
  const user = users.find((u) => u.id === userId);
  if (!user) return res.sendStatus(404);
  orders.push({ id: orders.length + 1, userId, items });
  res.status(201).json(orders.at(-1));
});

That handler mixes five concerns. NestJS gives each one a fixed home:

Express habitNestJS construct
Route fileController
Helper module with business logicProvider (service)
Auth check in middleware or handlerGuard
process.env readsConfigService
Manual req.body checksDTO + validation pipe

All of this comes at a cost. For an API with a handful of routes, NestJS’s decorators, modules and DI container are ceremony you don’t need. They start to pay off once several people change one feature at a time without reading the rest of the codebase.

How Should You Structure a Large NestJS Project?

The core of a maintainable NestJS project structure is one folder per feature, each with its own module. With that layout, changing how orders work means opening one directory, not hunting through separate controllers/, services/ and dto/ trees.

src/
├── users/
│   ├── dto/create-user.dto.ts
│   ├── users.controller.ts
│   ├── users.service.ts
│   ├── users.service.spec.ts
│   └── users.module.ts
├── orders/
│   ├── dto/create-order.dto.ts
│   ├── orders.controller.ts
│   ├── orders.service.ts
│   ├── orders.service.spec.ts
│   └── orders.module.ts
├── shared/
│   ├── api-key.guard.ts
│   ├── audit-log.service.ts
│   └── shared.module.ts
├── app.module.ts
└── main.ts

NestJS modules encapsulate their providers by default. OrdersService needs UsersService, so UsersModule has to export it:

// users/users.module.ts
import { Module } from '@nestjs/common';
import { UsersController } from './users.controller.js';
import { UsersService } from './users.service.js';
import { SharedModule } from '../shared/shared.module.js';

@Module({
  imports: [SharedModule],
  controllers: [UsersController],
  providers: [UsersService],
  exports: [UsersService],
})
export class UsersModule {}
// orders/orders.module.ts
import { Module } from '@nestjs/common';
import { OrdersController } from './orders.controller.js';
import { OrdersService } from './orders.service.js';
import { UsersModule } from '../users/users.module.js';
import { SharedModule } from '../shared/shared.module.js';

@Module({
  imports: [UsersModule, SharedModule],
  controllers: [OrdersController],
  providers: [OrdersService],
})
export class OrdersModule {}

The dependency graph runs one way: OrdersModule → UsersModule → SharedModule. Anything UsersModule doesn’t export stays internal, and that is what lets you refactor a feature safely.

What Are Controllers, Providers and Dependency Injection in NestJS?

A controller maps HTTP routes to methods. A provider is any class marked @Injectable() that Nest can create and hand to other classes. Dependency injection means a class lists what it needs as constructor parameters and the framework supplies the instances. That same mechanism lets a test pass in a fake instead of the real dependency.

// users/users.service.ts
import { Injectable } from '@nestjs/common';

export interface User { id: number; email: string }

@Injectable()
export class UsersService {
  private readonly users: User[] = [{ id: 1, email: 'ada@example.com' }];

  findOne(id: number): User | undefined {
    return this.users.find((u) => u.id === id);
  }
}
// orders/orders.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { UsersService } from '../users/users.service.js';
import { AuditLogService } from '../shared/audit-log.service.js';
import { CreateOrderDto } from './dto/create-order.dto.js';

@Injectable()
export class OrdersService {
  private readonly orders: Array<CreateOrderDto & { id: number }> = [];

  constructor(
    private readonly users: UsersService,
    private readonly audit: AuditLogService,
  ) {}

  create(dto: CreateOrderDto) {
    if (!this.users.findOne(dto.userId)) {
      throw new NotFoundException(`User ${dto.userId} not found`);
    }
    const order = { id: this.orders.length + 1, ...dto };
    this.orders.push(order);
    this.audit.record('order.created', order.id);
    return order;
  }
}
// orders/orders.controller.ts
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
import { ApiKeyGuard } from '../shared/api-key.guard.js';
import { CreateOrderDto } from './dto/create-order.dto.js';
import { OrdersService } from './orders.service.js';

@Controller('orders')
@UseGuards(ApiKeyGuard)
export class OrdersController {
  constructor(private readonly orders: OrdersService) {}

  @Post()
  create(@Body() dto: CreateOrderDto) {
    return this.orders.create(dto);
  }
}

The controller turns a request into a single service call. The rule “the user must exist” lives in the service, where any other caller can reuse it and a test can reach it. Built-in HTTP exceptions such as NotFoundException become the matching error response.

How Do DTOs and Validation Pipes Work in NestJS?

A DTO (data transfer object) is a class that describes the shape of a request body. Paired with a validation pipe, it rejects bad input before the controller runs. If you have a sample payload, the JSON to TypeScript converter generates the starting type, and you then add class-validator decorators:

// orders/dto/create-order.dto.ts
import { ArrayNotEmpty, IsArray, IsInt, IsString } from 'class-validator';

export class CreateOrderDto {
  @IsInt()
  userId!: number;

  @IsArray()
  @ArrayNotEmpty()
  @IsString({ each: true })
  items!: string[];
}

Register the pipe once in main.ts:

import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module.js';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalPipes(
    new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }),
  );
  await app.listen(3000);
}
bootstrap();

The validation docs define what each option does. whitelist strips properties that have no decorator. forbidNonWhitelisted turns that silent stripping into an error response. transform converts the payload into an instance of the DTO class. ValidationPipe depends on class-validator and class-transformer.

NestJS 12, released in August 2026, added a second approach: StandardSchemaValidationPipe, which validates Zod, Valibot or ArkType schemas passed as @Body({ schema }). On its own, the schema option only records the schema on the parameter. Validation happens once the pipe is registered, for example with app.useGlobalPipes(new StandardSchemaValidationPipe()).

If no pipe is registered, the frontend tells you. Session replays of API-backed UIs show the user’s input, the malformed request and the generic error toast side by side, which makes it clear the fix belongs at the DTO boundary.

How Do Guards and Config Handle Auth and Environment Variables?

A guard is an @Injectable() class implementing CanActivate. It runs before the route handler and decides whether the request continues. If it returns false, Nest throws a ForbiddenException (403). Combined with @nestjs/config, the guard reads its secret from ConfigService instead of process.env:

// shared/api-key.guard.ts
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';

@Injectable()
export class ApiKeyGuard implements CanActivate {
  constructor(private readonly config: ConfigService) {}

  canActivate(context: ExecutionContext): boolean {
    const request = context.switchToHttp().getRequest();
    return request.headers['x-api-key'] === this.config.get<string>('API_KEY');
  }
}

Reading request.headers works on both the Express and Fastify adapters. In AppModule, validate the environment at startup so a missing key fails at boot, not on the first request. Since NestJS 12, validationSchema accepts any Standard Schema object, such as a Zod schema. Earlier versions expected a Joi schema:

// app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { z } from 'zod';
import { UsersModule } from './users/users.module.js';
import { OrdersModule } from './orders/orders.module.js';

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      validationSchema: z.object({ API_KEY: z.string().min(1) }),
    }),
    UsersModule,
    OrdersModule,
  ],
})
export class AppModule {}

Config is the one place where a global flag earns its keep, because almost every module reads it.

A Shared Module and Testing With Swapped Dependencies

Code used across features goes in a shared module that exports it. Each feature then imports that module, so every dependency shows up in the imports array. Marking modules @Global() hides that wiring:

// shared/shared.module.ts
import { Module } from '@nestjs/common';
import { AuditLogService } from './audit-log.service.js';
import { ApiKeyGuard } from './api-key.guard.js';

@Module({
  providers: [AuditLogService, ApiKeyGuard],
  exports: [AuditLogService, ApiKeyGuard],
})
export class SharedModule {}

DI pays off in tests. Test.createTestingModule builds a small module in which you replace real providers with plain objects:

// orders/orders.service.spec.ts
import { Test } from '@nestjs/testing';
import { NotFoundException } from '@nestjs/common';
import { OrdersService } from './orders.service.js';
import { UsersService } from '../users/users.service.js';
import { AuditLogService } from '../shared/audit-log.service.js';

describe('OrdersService', () => {
  it('throws when the user does not exist', async () => {
    const moduleRef = await Test.createTestingModule({
      providers: [
        OrdersService,
        { provide: UsersService, useValue: { findOne: () => undefined } },
        { provide: AuditLogService, useValue: { record: () => {} } },
      ],
    }).compile();

    const service = moduleRef.get(OrdersService);
    expect(() => service.create({ userId: 99, items: ['book'] })).toThrow(NotFoundException);
  });
});

Since NestJS 12, nest new sets up Vitest for ESM projects and Jest for CommonJS ones. With Vitest, import describe, it and expect from vitest unless globals are enabled. For integration tests against a real database, running NestJS with Docker Compose covers the setup.

What Are the Common Mistakes in Large NestJS Codebases?

Most structural problems in large NestJS apps come from three patterns:

  • Circular module dependencies. If UsersModule and OrdersModule import each other, Nest fails at bootstrap because it cannot create the module instance. forwardRef() resolves the cycle, but moving the shared concept into its own module removes it.
  • An overgrown AppModule. The root module should import feature modules and global config, not declare dozens of controllers and providers itself.
  • Logic in controllers. Once a controller makes decisions, those rules can’t be reused or unit-tested without HTTP. Keep each controller method to a single service call.

Conclusion

A large NestJS app stays manageable when each feature lives in its own module, the only links between modules are explicit exports, and controllers stay thin. Pick one route in your current API, such as POST /orders. Move its rules into a service, its body checks into a DTO and its auth into a guard, then repeat that pattern one feature at a time.

FAQs

Should NestJS auth logic go in middleware or in a guard?

Put route-specific auth in a guard. Middleware runs before Nest picks the route handler, so it sees only the raw request and response. It cannot see the controller, the handler or decorator metadata. Guards run once all middleware has finished and ahead of any interceptor or pipe, and they receive an ExecutionContext that exposes the handler and its class. Middleware still suits work that applies to every request, such as logging.

What causes the Nest can't resolve dependencies error?

The error means Nest cannot find a provider for one of a constructor's parameters in the module named in the message. Usually that provider is missing from the module's providers array and is not exported by any module in its imports array. To fix it, export the provider from the module that owns it and import that module. Also check that no class marked @Injectable() sits in an imports array.

Why does a numeric string in a request body fail @IsInt even with transform enabled?

With transform set to true, ValidationPipe turns the JSON body into an instance of the DTO class, but it does not change the type of primitive values. A userId sent as '30' stays a string and fails @IsInt. You can send a real number from the client, or set enableImplicitConversion to true inside transformOptions. Implicit conversion has its own catch: any non-empty string, including 'false', becomes true for a boolean field.

How do I validate nested objects or arrays of objects in a NestJS DTO?

Add @ValidateNested() from class-validator and @Type(() => ItemDto) from class-transformer to the property. Without @Type, the nested value stays a plain object, so the validation decorators on ItemDto never run. For an array of objects, use @ValidateNested({ each: true }) together with the same @Type decorator. The nested DTO class needs its own class-validator decorators, and whitelist then applies to its properties as well.

DevTools for the frontend

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.