Estrutura de Projeto Node.js com TypeScript: Como Organizar do Zero sem Virar Bagunça
← Voltar para Codeshort

Estrutura de Projeto Node.js com TypeScript: Como Organizar do Zero sem Virar Bagunça

Aprenda a estruturar projetos Node.js com TypeScript usando arquitetura por domínio, separação em camadas reais e configuração de ambiente segura — do zero até um projeto que escala de verdade.

DC
Dev Code Software
15 de abril de 2026·11 min de leitura

Todo projeto Node.js começa do mesmo jeito. Você abre o terminal, roda npm init, cria um index.js e vai colocando coisas lá dentro. Funciona. Por um tempo.

Três semanas depois, você tem 800 linhas num único arquivo, quatro funções chamadas helper, um utils.js que valida dados, formata datas, acessa o banco e ainda manda e-mail — e ninguém toca no código com medo de quebrar algo sem querer.

Não é falta de habilidade. É ausência de estrutura.

O que você vai aprender aqui é como montar a base de um projeto Node.js com TypeScript que aguenta crescimento real: separação de responsabilidades por camadas, organização por domínio, configuração de ambiente à prova de falhas e tudo que reduz o tempo de manutenção antes que o projeto fique caro demais pra manter.


Índice


Por que a estrutura importa — e quando ela atrapalha

Estrutura de pastas resolve um problema concreto: onde procurar quando algo quebra. Com 10 arquivos, qualquer convenção funciona. Com 50, a falta de padrão começa a custar tempo. Com 200 arquivos e múltiplos devs, vira dívida técnica real.

O problema oposto existe com a mesma frequência: over-engineering desde o início. Criar uma hierarquia de 8 níveis pra um CRUD com três rotas. Isso também paralisa o projeto, só que de outro jeito.

A lógica correta é: estruture no nível que o problema atual exige, com espaço pra crescer sem reescrever tudo. A estrutura que você vai ver aqui não é a mais simples possível, nem a mais sofisticada — é a que aguenta crescimento real sem virar caos no caminho.


A estrutura de pastas que funciona de verdade

meu-projeto/
├── src/
│   ├── config/
│   │   ├── database.ts
│   │   ├── env.ts
│   │   └── logger.ts
│   ├── modules/
│   │   └── usuarios/
│   │       ├── usuario.controller.ts
│   │       ├── usuario.service.ts
│   │       ├── usuario.repository.ts
│   │       ├── usuario.routes.ts
│   │       ├── usuario.schema.ts
│   │       └── usuario.types.ts
│   ├── shared/
│   │   ├── middlewares/
│   │   │   ├── autenticar.ts
│   │   │   ├── tratarErros.ts
│   │   │   └── validar.ts
│   │   ├── errors/
│   │   │   └── AppError.ts
│   │   └── utils/
│   │       └── formatarData.ts
│   ├── app.ts
│   └── server.ts
├── tests/
│   └── usuarios/
│       ├── usuario.service.spec.ts
│       └── usuario.controller.spec.ts
├── .env.example
├── .gitignore
├── package.json
├── tsconfig.json
└── README.md

app.ts vs server.ts: a separação que todo mundo ignora

Esses dois arquivos vivem misturados em 90% dos projetos que já analisei. Eles têm responsabilidades distintas, e isso importa especialmente para os testes:

// src/app.ts — configura o Express, sem subir o servidor
import express from 'express';
import { usuarioRoutes } from './modules/usuarios/usuario.routes';
import { tratarErros } from './shared/middlewares/tratarErros';

const app = express();

app.use(express.json());
app.use('/api/usuarios', usuarioRoutes);
app.use(tratarErros);

export { app };
// src/server.ts — sobe o servidor, importa o app
import { app } from './app';
import { env } from './config/env';

app.listen(env.PORT, () => {
  console.log(`Servidor rodando na porta ${env.PORT}`);
});

Com essa separação, seus testes importam o app diretamente e usam supertest sem precisar subir uma porta real. Parece detalhe — até você perder uma hora tentando entender por que os testes travam na CI.


Separando responsabilidades: routes, controllers, services e repositories

Essa é a parte que mais gera dúvida em projetos novos: onde exatamente vai cada coisa?

A divisão em quatro camadas tem uma lógica interna que, uma vez internalizada, você aplica sem pensar:

Routes — apenas define endpoints

Nada de lógica aqui. A única responsabilidade é mapear um endpoint HTTP para o controller correto e aplicar middlewares na ordem certa.

// src/modules/usuarios/usuario.routes.ts
import { Router } from 'express';
import { UsuarioController } from './usuario.controller';
import { autenticar } from '../../shared/middlewares/autenticar';
import { validar } from '../../shared/middlewares/validar';
import { criarUsuarioSchema } from './usuario.schema';

const router = Router();
const controller = new UsuarioController();

router.post('/', validar(criarUsuarioSchema), controller.criar);
router.get('/:id', autenticar, controller.buscarPorId);
router.delete('/:id', autenticar, controller.deletar);

export { router as usuarioRoutes };

Controllers — recebe, delega e responde

O controller recebe a requisição HTTP, extrai o que precisa (body, params, query), chama o service correspondente e devolve a resposta. Não faz query no banco. Não tem regra de negócio.

// src/modules/usuarios/usuario.controller.ts
import { Request, Response, NextFunction } from 'express';
import { UsuarioService } from './usuario.service';

export class UsuarioController {
  private service = new UsuarioService();

  criar = async (req: Request, res: Response, next: NextFunction) => {
    try {
      const usuario = await this.service.criar(req.body);
      res.status(201).json(usuario);
    } catch (err) {
      next(err);
    }
  };

  buscarPorId = async (req: Request, res: Response, next: NextFunction) => {
    try {
      const usuario = await this.service.buscarPorId(req.params.id);
      res.json(usuario);
    } catch (err) {
      next(err);
    }
  };

  deletar = async (req: Request, res: Response, next: NextFunction) => {
    try {
      await this.service.deletar(req.params.id);
      res.status(204).send();
    } catch (err) {
      next(err);
    }
  };
}

Services — aqui mora a lógica de negócio

Validações de domínio, regras, cálculos, orquestração entre múltiplos repositories. O service não sabe que HTTP existe — ele só recebe dados e devolve resultados ou lança erros.

// src/modules/usuarios/usuario.service.ts
import { UsuarioRepository } from './usuario.repository';
import { AppError } from '../../shared/errors/AppError';
import bcrypt from 'bcrypt';

interface CriarUsuarioDTO {
  nome: string;
  email: string;
  senha: string;
}

export class UsuarioService {
  private repository = new UsuarioRepository();

  async criar(dados: CriarUsuarioDTO) {
    const emailEmUso = await this.repository.buscarPorEmail(dados.email);

    if (emailEmUso) {
      throw new AppError('Email já está em uso', 409);
    }

    const senhaHash = await bcrypt.hash(dados.senha, 12);

    return this.repository.criar({
      ...dados,
      senha: senhaHash,
    });
  }

  async buscarPorId(id: string) {
    const usuario = await this.repository.buscarPorId(id);

    if (!usuario) {
      throw new AppError('Usuário não encontrado', 404);
    }

    return usuario;
  }

  async deletar(id: string) {
    await this.buscarPorId(id); // valida existência antes de deletar
    return this.repository.deletar(id);
  }
}

Repositories — toda interação com o banco passa aqui

Se você trocar o Prisma pelo Drizzle amanhã, só os repositories mudam. O resto do projeto não sabe que a troca aconteceu.

// src/modules/usuarios/usuario.repository.ts
import { prisma } from '../../config/database';

export class UsuarioRepository {
  buscarPorEmail(email: string) {
    return prisma.user.findUnique({ where: { email } });
  }

  buscarPorId(id: string) {
    return prisma.user.findUnique({
      where: { id },
      select: { id: true, nome: true, email: true, criadoEm: true },
    });
  }

  criar(dados: { nome: string; email: string; senha: string }) {
    return prisma.user.create({ data: dados });
  }

  deletar(id: string) {
    return prisma.user.delete({ where: { id } });
  }
}

Atenção ao select no repository: definir quais campos saem do banco nessa camada evita que passwordHash apareça acidentalmente numa resposta de API. Isso não é opcional — é parte do contrato de dados da aplicação.


Configuração e variáveis de ambiente do jeito certo

O padrão mais comum — e mais frágil — é process.env.QUALQUER_COISA espalhado pelo código. Quando uma variável muda de nome ou está faltando, você descobre em produção, durante um incidente.

A solução é validar as variáveis na inicialização do processo com um schema tipado:

// src/config/env.ts
import { z } from 'zod';

const envSchema = z.object({
  NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
  PORT: z.coerce.number().default(3000),
  DATABASE_URL: z.string().url(),
  JWT_SECRET: z.string().min(32),
  JWT_REFRESH_SECRET: z.string().min(32),
});

const parsed = envSchema.safeParse(process.env);

if (!parsed.success) {
  console.error('Variáveis de ambiente inválidas:');
  console.error(parsed.error.flatten().fieldErrors);
  process.exit(1);
}

export const env = parsed.data;

Com isso, se JWT_SECRET estiver ausente ou curto demais, o processo falha na inicialização com uma mensagem clara — não numa requisição de autenticação às 2h da manhã.

O .env.example no repositório é obrigatório. Documenta o que o projeto precisa sem expor valores reais:

# .env.example
NODE_ENV=development
PORT=3000
DATABASE_URL=postgresql://usuario:senha@localhost:5432/meu_banco
JWT_SECRET=gere-com-crypto-randombytes-64-tostring-hex
JWT_REFRESH_SECRET=outro-secret-diferente-do-anterior

Middlewares, validações e tratamento de erros centralizado

Onde ficam os middlewares

Em src/shared/middlewares/. São reutilizados por qualquer módulo, então faz sentido estarem fora da pasta de módulos individuais.

O middleware de tratamento de erros centralizado é o que permite que todos os controllers usem next(err) sem se preocupar com a formatação da resposta:

// src/shared/middlewares/tratarErros.ts
import { Request, Response, NextFunction } from 'express';
import { AppError } from '../errors/AppError';
import { ZodError } from 'zod';

export function tratarErros(
  err: unknown,
  req: Request,
  res: Response,
  _next: NextFunction
) {
  if (err instanceof AppError) {
    return res.status(err.statusCode).json({
      error: err.message,
      code: err.code,
    });
  }

  if (err instanceof ZodError) {
    return res.status(400).json({
      error: 'Dados inválidos',
      detalhes: err.flatten().fieldErrors,
    });
  }

  console.error('[ERRO NÃO TRATADO]', err);

  return res.status(500).json({
    error: 'Erro interno do servidor',
  });
}
// src/shared/errors/AppError.ts
export class AppError extends Error {
  constructor(
    message: string,
    public readonly statusCode: number = 400,
    public readonly code?: string
  ) {
    super(message);
    this.name = 'AppError';
  }
}

Onde ficam os schemas de validação

Junto ao módulo que os usa. usuario.schema.ts vive dentro de modules/usuarios/ — não num diretório global de schemas, porque cada schema é específico de um módulo.

// src/modules/usuarios/usuario.schema.ts
import { z } from 'zod';

export const criarUsuarioSchema = z.object({
  nome: z.string().min(2).max(100),
  email: z.string().email(),
  senha: z.string().min(8),
});

export const atualizarUsuarioSchema = criarUsuarioSchema.partial().omit({ senha: true });

export type CriarUsuarioDTO = z.infer<typeof criarUsuarioSchema>;

O erro clássico: organizar por tipo em vez de por domínio

Esse é o padrão que parece lógico no início e vira pesadelo conforme o projeto cresce:

❌ Organização por tipo de arquivo

src/
├── controllers/
│   ├── usuario.controller.ts
│   ├── pedido.controller.ts
│   └── produto.controller.ts
├── services/
│   ├── usuario.service.ts
│   ├── pedido.service.ts
│   └── produto.service.ts
└── repositories/
    ├── usuario.repository.ts
    └── pedido.repository.ts

O problema prático: quando você trabalha na feature de pedidos, você abre arquivos em quatro pastas diferentes. Cada mudança de contexto é um custo real de navegação. Quando você precisa entender tudo sobre pedidos, o código está espalhado pelo projeto inteiro.

✓ Organização por domínio

src/
└── modules/
    ├── usuarios/
    │   ├── usuario.controller.ts
    │   ├── usuario.service.ts
    │   ├── usuario.repository.ts
    │   └── usuario.routes.ts
    ├── pedidos/
    │   └── ...
    └── produtos/
        └── ...

Tudo relacionado a usuários está em um lugar só. Você adiciona um campo, escreve o teste, abre o PR — sem navegar por quatro diretórios.

Sobre dependências entre módulos: organização por domínio não impede que módulos importem uns aos outros. Um PedidoService pode — e vai — importar de UsuarioRepository. O que você evita é a dependência circular: PedidoServiceUsuarioServicePedidoRepository. Se isso acontecer, é sinal de que a separação de domínios precisa ser revisitada.


Scripts, tooling e o que vai no package.json

{
  "scripts": {
    "dev": "tsx watch src/server.ts",
    "build": "tsc",
    "start": "node dist/server.js",
    "test": "vitest",
    "test:coverage": "vitest --coverage",
    "lint": "eslint src --ext .ts",
    "lint:fix": "eslint src --ext .ts --fix",
    "typecheck": "tsc --noEmit",
    "db:migrate": "prisma migrate dev",
    "db:generate": "prisma generate",
    "db:studio": "prisma studio"
  }
}

Alguns pontos que fazem diferença real:

tsx watch no lugar de ts-node-dev — é mais rápido e tem melhor suporte a ESM. Em produção, tsc compila para JS puro e você executa com Node diretamente, sem overhead de transpilação em runtime.

typecheck separado de buildtsc --noEmit verifica tipos sem gerar arquivos. Útil no CI para validar tipagem sem fazer o build completo.

O tsconfig.json que vale usar

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "lib": ["ES2022"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "paths": {
      "@modules/*": ["./src/modules/*"],
      "@shared/*": ["./src/shared/*"],
      "@config/*": ["./src/config/*"]
    }
  },
  "include": ["src"],
  "exclude": ["node_modules", "dist", "tests"]
}

Os paths com alias (@modules/, @shared/) eliminam imports relativos como ../../../../shared/errors/AppError. Você vai precisar configurar o mesmo alias no bundler ou usar tsconfig-paths em runtime — mas o ganho de legibilidade compensa.


FAQ

Preciso dessa estrutura para um projeto pequeno?

Não. Se você está fazendo um script interno, uma API com duas rotas ou um projeto solo de aprendizado, essa estrutura é overkill. Comece com app.ts e um arquivo por recurso. Migre para módulos quando tiver mais de 3-4 entidades ou quando a mesma funcionalidade precisar ser reutilizada em mais de um lugar. O que você viu aqui é o ponto de chegada, não o ponto de partida.

Repository pattern vale a pena se eu uso Prisma?

Sim, por uma razão prática: Prisma é fácil de mockar nos testes quando está isolado no repository. Se você usa prisma.user.findUnique direto no service, os testes unitários do service precisam mockar o módulo do Prisma inteiro — o que é mais frágil. Com repository, você mocka uma classe com métodos claros. A troca de ORM também fica trivial: muda o repository, o service não sabe que aconteceu.

Devo usar injeção de dependência?

Para a maioria dos projetos Node.js, instanciar as classes manualmente (como nos exemplos acima) é suficiente. Frameworks de DI como tsyringe ou inversify adicionam complexidade que raramente se paga em projetos com menos de 5 devs. Se você perceber que está passando 4-5 dependências no construtor de uma classe, aí vale avaliar.

Onde ficam os testes de integração?

Em tests/, seguindo a mesma estrutura de módulos do src/. Testes unitários ficam junto ao código que testam ou em tests/ — o importante é a consistência. Para testes de integração que sobem o servidor e fazem requisições reais, tests/integration/ é uma boa convenção.

Como lidar com funcionalidade compartilhada entre módulos?

Em src/shared/. A regra é simples: se uma função ou classe é usada por dois ou mais módulos, vai para shared/. Se é usada só por um módulo, fica dentro desse módulo. O fluxo de dependência é sempre módulo → shared, nunca shared → módulo.


Próximos passos

Com a estrutura no lugar, o próximo passo prático é configurar o pipeline de CI que valide três pontos antes de qualquer merge: npm run typecheck, npm run lint e npm run test. Estrutura sem validação automática é convenção — não garantia.

Se o projeto vai crescer em equipe, documente as decisões de estrutura no README.md ou num ARCHITECTURE.md na raiz. Não precisa ser longo — uma lista de "onde colocar X" já reduz o tempo de onboarding pela metade.