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.tsroutes/: 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:
routes→controllers→services→repositoriesschemasesharedpodem ser usados por qualquer camada, mas evite queshareddependa de camadas acima.repositoriesnão deve importar nada decontrollers,routesoumiddlewares.
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.
- Ouça o áudio com a tela desligada
- Ganhe Certificado após a conclusão
- + de 5000 cursos para você explorar!
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 emcontrollers/. 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: umshared/index.tsque 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(ouconfig/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/utilsdevem 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) oushared/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-casecom 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.tsreexportando 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.tsou em umsrc/modules/index.tsque apenas registra routers (sem puxar services/repositories diretamente).