Estruturando uma aplicação Node.js de grande porte com NestJS
Estruture uma aplicação NestJS grande por funcionalidade com módulos, controllers, serviços, validação de DTO, guards, configuração e testes. Organize dependências com imports e exports explícitos.
Uma aplicação NestJS de grande porte deve ser organizada por funcionalidade: cada domínio (users, orders, billing) recebe sua própria pasta de módulo, contendo controller, service, DTOs e testes, e os módulos compartilham código apenas por meio de exports e imports explícitos.
Se você está herdando uma API Express em crescimento, os sintomas são conhecidos. Um único arquivo de rotas cuida de autenticação, validação e gravações no banco de dados, e process.env é lido em uma dúzia de lugares. Ninguém sabe ao certo qual helper pode ser alterado com segurança.
Este artigo constrói uma pequena API de usuários e pedidos e coloca cada peça no lugar certo: módulos, controllers, providers, validação, guards, configuração, um módulo compartilhado e um teste unitário. Se você ainda está escolhendo um framework, o artigo Is There a Rails for JavaScript? compara as alternativas.
Principais pontos
- Por padrão, um módulo NestJS mantém seus providers privados. Outro módulo só pode injetar um deles se o módulo proprietário o exportar e o módulo consumidor importar esse módulo.
- Organizar por funcionalidade mantém o módulo, o controller, o service, os DTOs e os testes de cada domínio em uma única pasta, de modo que uma mudança em orders fica restrita a
src/orders/. - Com
whitelisthabilitado, oValidationPiperemove as propriedades que não têm decorator de validação. AdicionarforbidNonWhitelistedfaz essas requisições falharem. - Um guard que retorna
falsefaz o Nest lançar umaForbiddenException, então as verificações de autenticação ficam em uma única classe em vez de espalhadas por todos os handlers. - Um import circular não resolvido entre módulos interrompe o Nest no bootstrap. Extrair o conceito compartilhado é uma solução melhor do que usar
forwardRef().
O problema: uma aplicação Express sem regras
O Express não define regras sobre onde o código deve ficar, então uma aplicação em crescimento acaba organizada do jeito que seus primeiros handlers foram escritos. A maioria dos projetos começa como descreve um guia de configuração do Node.js com TypeScript e Express: um app.ts, uma pasta de rotas e alguns middlewares. Em poucos meses, um handler fica assim:
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));
});
Esse handler mistura cinco responsabilidades. O NestJS dá a cada uma delas um lugar fixo:
| Hábito no Express | Construção no NestJS |
|---|---|
| Arquivo de rotas | Controller |
| Módulo helper com regras de negócio | Provider (service) |
| Verificação de autenticação em middleware ou handler | Guard |
Leituras de process.env | ConfigService |
Verificações manuais de req.body | DTO + validation pipe |
Tudo isso tem um custo. Para uma API com poucas rotas, os decorators, módulos e o container de DI do NestJS são cerimônia desnecessária. Eles começam a valer a pena quando várias pessoas alteram uma funcionalidade por vez sem precisar ler o restante da base de código.
Como estruturar um projeto NestJS de grande porte?
O núcleo de uma estrutura de projeto NestJS sustentável é uma pasta por funcionalidade, cada uma com seu próprio módulo. Com esse layout, mudar o funcionamento dos pedidos significa abrir um único diretório, e não sair caçando arquivos em árvores separadas de controllers/, services/ e dto/.
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
Os módulos do NestJS encapsulam seus providers por padrão. O OrdersService precisa do UsersService, então o UsersModule precisa exportá-lo:
// 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 {}
O grafo de dependências segue em uma única direção: OrdersModule → UsersModule → SharedModule. Tudo o que o UsersModule não exporta permanece interno, e é isso que permite refatorar uma funcionalidade com segurança.
O que são controllers, providers e injeção de dependência no NestJS?
Um controller mapeia rotas HTTP para métodos. Um provider é qualquer classe marcada com @Injectable() que o Nest consegue criar e entregar a outras classes. Injeção de dependência significa que uma classe declara o que precisa como parâmetros do construtor e o framework fornece as instâncias. Esse mesmo mecanismo permite que um teste passe um fake no lugar da dependência real.
// 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);
}
}
O controller transforma uma requisição em uma única chamada ao service. A regra “o usuário precisa existir” fica no service, onde qualquer outro chamador pode reutilizá-la e um teste consegue alcançá-la. Exceções HTTP nativas, como NotFoundException, se tornam a resposta de erro correspondente.
Como DTOs e validation pipes funcionam no NestJS?
Um DTO (data transfer object) é uma classe que descreve o formato do corpo de uma requisição. Combinado com um validation pipe, ele rejeita entradas inválidas antes de o controller ser executado. Se você tiver um payload de exemplo, o conversor de JSON para TypeScript gera o tipo inicial, e depois você adiciona os decorators do class-validator:
// 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[];
}
Registre o pipe uma única vez em 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();
A documentação de validação define o que cada opção faz. whitelist remove as propriedades que não têm decorator. forbidNonWhitelisted transforma essa remoção silenciosa em uma resposta de erro. transform converte o payload em uma instância da classe DTO. O ValidationPipe depende de class-validator e class-transformer.
O NestJS 12, lançado em agosto de 2026, adicionou uma segunda abordagem: o StandardSchemaValidationPipe, que valida schemas Zod, Valibot ou ArkType passados como @Body({ schema }). Sozinha, a opção schema apenas registra o schema no parâmetro. A validação acontece quando o pipe é registrado, por exemplo com app.useGlobalPipes(new StandardSchemaValidationPipe()).
Se nenhum pipe estiver registrado, o frontend denuncia. Session replays de interfaces que consomem a API mostram lado a lado a entrada do usuário, a requisição malformada e o toast de erro genérico, o que deixa claro que a correção pertence à fronteira do DTO.
Como guards e configuração lidam com autenticação e variáveis de ambiente?
Um guard é uma classe @Injectable() que implementa CanActivate. Ele é executado antes do handler da rota e decide se a requisição continua. Se retornar false, o Nest lança uma ForbiddenException (403). Em conjunto com o @nestjs/config, o guard lê seu segredo a partir do ConfigService em vez de 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');
}
}
Ler request.headers funciona tanto no adapter do Express quanto no do Fastify. No AppModule, valide o ambiente na inicialização para que uma chave ausente cause falha no boot, e não na primeira requisição. Desde o NestJS 12, validationSchema aceita qualquer objeto Standard Schema, como um schema Zod. Versões anteriores esperavam um schema Joi:
// 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 {}
A configuração é o único lugar em que uma flag global realmente se justifica, porque praticamente todos os módulos a leem.
Um módulo compartilhado e testes com dependências substituídas
O código usado por várias funcionalidades vai para um módulo compartilhado que o exporta. Cada funcionalidade então importa esse módulo, de modo que todas as dependências aparecem no array imports. Marcar módulos com @Global() esconde essas ligações:
// 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 {}
A DI mostra seu valor nos testes. O Test.createTestingModule cria um pequeno módulo no qual você substitui os providers reais por objetos simples:
// 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);
});
});
Desde o NestJS 12, o nest new configura Vitest para projetos ESM e Jest para projetos CommonJS. Com o Vitest, importe describe, it e expect de vitest, a menos que os globals estejam habilitados. Para testes de integração com um banco de dados real, o artigo sobre como executar o NestJS com Docker Compose cobre a configuração.
Quais são os erros mais comuns em bases de código NestJS de grande porte?
A maioria dos problemas estruturais em aplicações NestJS de grande porte vem de três padrões:
- Dependências circulares entre módulos. Se
UsersModuleeOrdersModuleimportam um ao outro, o Nest falha no bootstrap porque não consegue criar a instância do módulo. OforwardRef()resolve o ciclo, mas mover o conceito compartilhado para um módulo próprio o elimina. - Um
AppModuleinchado. O módulo raiz deve importar os módulos de funcionalidade e a configuração global, e não declarar ele mesmo dezenas de controllers e providers. - Lógica nos controllers. Quando um controller passa a tomar decisões, essas regras não podem ser reutilizadas nem testadas unitariamente sem HTTP. Mantenha cada método de controller restrito a uma única chamada ao service.
Conclusão
Uma aplicação NestJS de grande porte continua gerenciável quando cada funcionalidade vive em seu próprio módulo, as únicas ligações entre módulos são exports explícitos e os controllers permanecem enxutos. Escolha uma rota da sua API atual, como POST /orders. Mova as regras dela para um service, as verificações do corpo para um DTO e a autenticação para um guard; depois, repita esse padrão uma funcionalidade por vez.
Perguntas frequentes
A lógica de autenticação no NestJS deve ficar em um middleware ou em um guard?
Coloque a autenticação específica de rotas em um guard. O middleware é executado antes de o Nest escolher o handler da rota, então ele enxerga apenas a requisição e a resposta brutas. Ele não tem acesso ao controller, ao handler nem aos metadados dos decorators. Os guards são executados depois que todos os middlewares terminam e antes de qualquer interceptor ou pipe, e recebem um ExecutionContext que expõe o handler e sua classe. O middleware continua adequado para tarefas que se aplicam a todas as requisições, como logging.
O que causa o erro Nest can't resolve dependencies?
O erro significa que o Nest não consegue encontrar um provider para um dos parâmetros do construtor no módulo indicado na mensagem. Normalmente, esse provider está ausente do array providers do módulo e não é exportado por nenhum dos módulos no seu array imports. Para corrigir, exporte o provider a partir do módulo que o possui e importe esse módulo. Verifique também se nenhuma classe marcada com @Injectable() está em um array imports.
Por que uma string numérica no corpo da requisição falha em @IsInt mesmo com transform habilitado?
Com transform definido como true, o ValidationPipe transforma o corpo JSON em uma instância da classe DTO, mas não altera o tipo dos valores primitivos. Um userId enviado como '30' continua sendo uma string e falha em @IsInt. Você pode enviar um número de verdade a partir do cliente ou definir enableImplicitConversion como true dentro de transformOptions. A conversão implícita tem sua própria armadilha: qualquer string não vazia, incluindo 'false', vira true em um campo booleano.
Como validar objetos aninhados ou arrays de objetos em um DTO do NestJS?
Adicione @ValidateNested(), do class-validator, e @Type(() => ItemDto), do class-transformer, à propriedade. Sem o @Type, o valor aninhado continua sendo um objeto simples, e os decorators de validação de ItemDto nunca são executados. Para um array de objetos, use @ValidateNested({ each: true }) em conjunto com o mesmo decorator @Type. A classe DTO aninhada precisa ter seus próprios decorators do class-validator, e o whitelist passa a se aplicar também às propriedades dela.
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