Arquitetura de pastas e camadas no Node.js com Express e TypeScript

Capítulo 13

Tempo estimado de leitura: 10 minutos

+ Exercício

Estrutura de pastas proposta

Uma arquitetura de pastas consistente ajuda a separar responsabilidades, reduzir acoplamento e facilitar testes. A ideia central é organizar o código por camadas (responsabilidades técnicas) e, quando o projeto crescer, modularizar por domínio (responsabilidades de negócio) sem perder os limites entre camadas.

Uma estrutura inicial coerente para Express + TypeScript:

src/  app.ts  server.ts  routes/    index.ts    users.routes.ts  controllers/    users.controller.ts  services/    users.service.ts  repositories/    users.repository.ts  schemas/    users.schema.ts  middlewares/    validate.middleware.ts    error.middleware.ts    async-handler.middleware.ts  config/    env.ts    http.ts  shared/    errors/      app-error.ts      error-codes.ts    http/      response.ts    types/      express.d.ts    utils/      pick.ts
  • routes/: define endpoints, aplica middlewares de rota e delega para controllers. Não contém regra de negócio.
  • controllers/: traduz HTTP para casos de uso (extrai params/body, chama service, devolve resposta). Não acessa banco diretamente.
  • services/: regras de negócio e orquestração. Depende de interfaces (contratos) de repositórios, não de implementações concretas.
  • repositories/: acesso a dados (persistência). Implementa contratos usados pelos services. Não conhece Express.
  • schemas/: validação e tipagem do payload (ex.: Zod). Não executa lógica de negócio.
  • middlewares/: cross-cutting concerns (validação, autenticação, tratamento de erro, etc.).
  • config/: leitura de variáveis de ambiente, configuração de servidor e dependências.
  • shared/: componentes reutilizáveis (erros padronizados, helpers HTTP, tipos globais).

Responsabilidades e limites entre camadas

Regra prática de dependências

Para manter o acoplamento baixo, aplique a direção de dependência:

  • routescontrollersservicesrepositories
  • schemas e shared podem ser usados por qualquer camada, mas evite que shared dependa de camadas acima.
  • repositories não deve importar nada de controllers, routes ou middlewares.

O que não colocar em cada camada

  • Routes: não validar manualmente campos (delegue ao middleware), não montar respostas complexas.
  • Controllers: não conter regra de negócio (ex.: “se usuário já existe, então…”), não conhecer detalhes de persistência.
  • Services: não acessar req/res, não retornar objetos “HTTP-like” (status, headers).
  • Repositories: não lançar erros HTTP; lance erros de domínio/persistência que serão traduzidos acima.

Feature completa: criar usuário (router → controller → service → repository)

A seguir, um fluxo completo com validação, tratamento de erro padronizado e inversão de dependência. O exemplo usa Zod para validação e um repositório em memória para focar na arquitetura (a implementação real de banco entra no repositório concreto).

1) Schema de validação (schemas)

// src/schemas/users.schema.tsimport { z } from "zod";export const createUserSchema = z.object({  body: z.object({    name: z.string().min(2),    email: z.string().email(),  }),});export type CreateUserInput = z.infer<typeof createUserSchema>["body"]; 

Note que o schema valida o formato esperado e também gera tipo para o restante do fluxo.

Continue em nosso aplicativo e ...
  • Ouça o áudio com a tela desligada
  • Ganhe Certificado após a conclusão
  • + de 5000 cursos para você explorar!
ou continue lendo abaixo...
Download App

Baixar o aplicativo

2) Erros padronizados (shared/errors)

// src/shared/errors/error-codes.tsexport const ErrorCodes = {  VALIDATION: "VALIDATION",  CONFLICT: "CONFLICT",  NOT_FOUND: "NOT_FOUND",  INTERNAL: "INTERNAL",} as const;export type ErrorCode = typeof ErrorCodes[keyof typeof ErrorCodes];
// src/shared/errors/app-error.tstype AppErrorOptions = {  code: import("./error-codes").ErrorCode;  statusCode: number;  details?: unknown;};export class AppError extends Error {  public readonly code: AppErrorOptions["code"];  public readonly statusCode: number;  public readonly details?: unknown;  constructor(message: string, options: AppErrorOptions) {    super(message);    this.code = options.code;    this.statusCode = options.statusCode;    this.details = options.details;  }}

3) Middleware de validação (middlewares)

// src/middlewares/validate.middleware.tsimport type { Request, Response, NextFunction } from "express";import type { ZodSchema } from "zod";import { AppError } from "../shared/errors/app-error";import { ErrorCodes } from "../shared/errors/error-codes";export const validate = (schema: ZodSchema) =>  (req: Request, _res: Response, next: NextFunction) => {    const result = schema.safeParse({      body: req.body,      params: req.params,      query: req.query,    });    if (!result.success) {      return next(        new AppError("Validation error", {          code: ErrorCodes.VALIDATION,          statusCode: 400,          details: result.error.flatten(),        })      );    }    // opcional: sobrescrever com dados parseados para garantir tipos/normalização    req.body = result.data.body;    req.params = result.data.params ?? req.params;    req.query = result.data.query ?? req.query;    return next();  };

4) Async handler para evitar try/catch repetitivo (middlewares)

// src/middlewares/async-handler.middleware.tsimport type { Request, Response, NextFunction, RequestHandler } from "express";export const asyncHandler = (fn: RequestHandler) =>  (req: Request, res: Response, next: NextFunction) => {    Promise.resolve(fn(req, res, next)).catch(next);  };

5) Contrato do repositório (inversão de dependência)

O service deve depender de um contrato (interface), não de uma classe concreta. Isso reduz acoplamento e facilita testes.

// src/repositories/users.repository.tsexport type User = {  id: string;  name: string;  email: string;};export type CreateUserDTO = {  name: string;  email: string;};export interface UsersRepository {  findByEmail(email: string): Promise<User | null>;  create(data: CreateUserDTO): Promise<User>;}

6) Implementação concreta do repositório (ex.: memória)

// src/repositories/users.memory.repository.tsimport { randomUUID } from "crypto";import type { UsersRepository, User, CreateUserDTO } from "./users.repository";export class UsersMemoryRepository implements UsersRepository {  private items: User[] = [];  async findByEmail(email: string): Promise<User | null> {    return this.items.find(u => u.email === email) ?? null;  }  async create(data: CreateUserDTO): Promise<User> {    const user: User = { id: randomUUID(), ...data };    this.items.push(user);    return user;  }}

Em produção, você teria outra implementação (ex.: UsersDbRepository) que também implementa UsersRepository. O service não muda.

7) Service com regra de negócio (services)

// src/services/users.service.tsimport { AppError } from "../shared/errors/app-error";import { ErrorCodes } from "../shared/errors/error-codes";import type { UsersRepository } from "../repositories/users.repository";import type { CreateUserInput } from "../schemas/users.schema";export class UsersService {  constructor(private readonly usersRepo: UsersRepository) {}  async createUser(input: CreateUserInput) {    const existing = await this.usersRepo.findByEmail(input.email);    if (existing) {      throw new AppError("Email already in use", {        code: ErrorCodes.CONFLICT,        statusCode: 409,        details: { email: input.email },      });    }    const user = await this.usersRepo.create(input);    return user;  }}

Repare que o service lança AppError com código e status, mas não conhece Express. Ele apenas expressa a regra e o tipo de falha.

8) Controller (controllers)

// src/controllers/users.controller.tsimport type { Request, Response } from "express";import type { UsersService } from "../services/users.service";import { created } from "../shared/http/response";export class UsersController {  constructor(private readonly usersService: UsersService) {}  create = async (req: Request, res: Response) => {    const user = await this.usersService.createUser(req.body);    return created(res, { user });  };}

9) Helper de resposta HTTP (shared/http)

// src/shared/http/response.tsimport type { Response } from "express";export const created = (res: Response, data: unknown) => res.status(201).json(data);export const ok = (res: Response, data: unknown) => res.status(200).json(data);

10) Routes (routes)

// src/routes/users.routes.tsimport { Router } from "express";import { validate } from "../middlewares/validate.middleware";import { asyncHandler } from "../middlewares/async-handler.middleware";import { createUserSchema } from "../schemas/users.schema";import { makeUsersController } from "../shared/factories/users.factory";const router = Router();const controller = makeUsersController();router.post(  "/users",  validate(createUserSchema),  asyncHandler(controller.create));export default router;

Note o uso de uma factory para montar dependências e evitar imports cruzados entre camadas.

11) Factory de composição (shared/factories)

// src/shared/factories/users.factory.tsimport { UsersController } from "../../controllers/users.controller";import { UsersService } from "../../services/users.service";import { UsersMemoryRepository } from "../../repositories/users.memory.repository";export const makeUsersController = () => {  const repo = new UsersMemoryRepository();  const service = new UsersService(repo);  return new UsersController(service);};

Esse ponto é crítico para inversão de dependência: a composição acontece na borda (factory), e as camadas internas ficam desacopladas.

12) Middleware de erro padronizado (middlewares)

// src/middlewares/error.middleware.tsimport type { Request, Response, NextFunction } from "express";import { AppError } from "../shared/errors/app-error";import { ErrorCodes } from "../shared/errors/error-codes";export const errorMiddleware = (err: unknown, _req: Request, res: Response, _next: NextFunction) => {  if (err instanceof AppError) {    return res.status(err.statusCode).json({      error: {        message: err.message,        code: err.code,        details: err.details ?? null,      },    });  }  return res.status(500).json({    error: {      message: "Internal server error",      code: ErrorCodes.INTERNAL,      details: null,    },  });};

13) Plugando rotas e middleware no app

// src/routes/index.tsimport { Router } from "express";import usersRoutes from "./users.routes";const router = Router();router.use(usersRoutes);export default router;
// src/app.tsimport express from "express";import routes from "./routes";import { errorMiddleware } from "./middlewares/error.middleware";export const app = express();app.use(express.json());app.use(routes);app.use(errorMiddleware);

Como evitar dependências circulares

Dependência circular em Node.js/TypeScript costuma aparecer quando dois módulos se importam direta ou indiretamente, causando undefined em runtime, inicialização parcial ou comportamento estranho em testes.

Padrões que geram ciclo (e como corrigir)

  • Controller importando Router: mantenha router apenas em routes/ e controller apenas em controllers/. O router importa o controller, nunca o contrário.
  • Service importando Controller: service não deve conhecer HTTP. Se precisar de algo “do request”, extraia antes no controller e passe como argumento.
  • Repository importando Service: repositório é infraestrutura. Ele expõe métodos e não orquestra regras.
  • Barrels (index.ts) mal usados: um shared/index.ts que reexporta “tudo” pode criar ciclos invisíveis. Use barrels com parcimônia e por pasta, não um barrel global.

Regras práticas

  • Composição nas bordas: factories em shared/factories (ou config/container) montam o grafo de dependências. Camadas internas não instanciam dependências concretas.
  • Importe tipos com import type: reduz risco de ciclos em runtime quando você só precisa de tipos.
  • Evite “helpers” que importam camadas: utilitários em shared/utils devem ser puros e não depender de Express, services ou repositories.

Inversão de dependência em pontos críticos

Os pontos críticos são aqueles que mudam com frequência ou variam por ambiente (persistência, mensageria, provedores externos). A inversão de dependência (DIP) ajuda a manter o núcleo de negócio estável.

Aplicando DIP com contratos

  • Defina interfaces no nível onde elas são consumidas (geralmente próximo ao service), mas mantenha-as em uma pasta neutra como repositories/ (contratos de persistência) ou shared/ports (contratos genéricos).
  • Implemente as interfaces em classes concretas (ex.: UsersMemoryRepository, UsersDbRepository).
  • Faça a injeção via construtor no service.

Quando usar factory vs. container

  • Factory: suficiente para projetos pequenos/médios; explícito e fácil de debugar.
  • Container: útil quando há muitas dependências e composição repetida; cuidado para não esconder o grafo e dificultar rastreio de ciclos.

Convenções: nomenclatura, barrels e modularização por domínio

Nomenclatura recomendada

  • Arquivos: kebab-case com sufixo por responsabilidade: users.controller.ts, users.service.ts, users.routes.ts, validate.middleware.ts.
  • Classes: PascalCase: UsersController, UsersService.
  • Interfaces/contratos: nome do papel: UsersRepository.
  • DTO/Input: CreateUserDTO, CreateUserInput.

Index barrels: quando faz sentido

Barrels podem melhorar imports, mas devem ser locais e previsíveis.

  • Bom uso: reexportar itens de uma pasta sem depender de outras camadas.
  • Evite: src/index.ts reexportando tudo do projeto; isso facilita ciclos e aumenta acoplamento.

Exemplo seguro (barrel local de schemas):

// src/schemas/index.tsexport * from "./users.schema";

Critérios para modularização por domínio

Quando o número de features crescer, a estrutura por camadas pode virar “pastas gigantes”. Um caminho é modularizar por domínio mantendo as camadas dentro do módulo.

src/  modules/    users/      users.routes.ts      users.controller.ts      users.service.ts      users.repository.ts      users.schema.ts    orders/      ...  middlewares/  shared/  config/  app.ts
  • Quando modularizar: quando um domínio tiver múltiplos endpoints, regras e modelos, e começar a exigir mudanças frequentes sem relação com outros domínios.
  • Regra de ouro: um módulo não deve importar internals de outro módulo. Se precisar compartilhar algo, mova para shared/ (ou crie contratos explícitos).
  • Rotas: agregue módulos em src/routes/index.ts ou em um src/modules/index.ts que apenas registra routers (sem puxar services/repositories diretamente).

Agora responda o exercício sobre o conteúdo:

Qual prática melhor reduz acoplamento e ajuda a evitar dependências circulares ao organizar camadas em um projeto Express + TypeScript?

Você acertou! Parabéns, agora siga para a próxima página

Você errou! Tente novamente.

Ao montar o grafo de dependências em factories, as camadas internas não instanciam implementações concretas. Services dependem de contratos e recebem repositórios via injeção, reduzindo acoplamento e diminuindo o risco de ciclos.

Próximo capitúlo

Qualidade de código no Node.js: ESLint, Prettier, padrões e revisão automatizada

Arrow Right Icon
Capa do Ebook gratuito Node.js Essencial: Construindo um Back-end com Express e TypeScript
81%

Node.js Essencial: Construindo um Back-end com Express e TypeScript

Novo curso

16 páginas

Baixe o app para ganhar Certificação grátis e ouvir os cursos em background, mesmo com a tela desligada.