Pular para o conteúdo
Cursos

DevClub

LógicaFront-endBack-endMobile

IA Club

IA na prática
Estudar programaçãoEstudar IA
LiçãoIntermediáriocódigo testado

express.Router: dividir a API em arquivos por recurso

Como quebrar um index.js de 400 linhas em rotas, controllers e serviços com express.Router, sem perder o prefixo nem os middlewares do grupo.

Rodolfo Mori11 min de leitura

express.Router() cria um mini-aplicativo que você monta dentro do principal. Cada recurso da API — livros, leitores, empréstimos — ganha o próprio arquivo de rotas, e o index.js volta a caber na tela. O contrato não muda: mesmas URLs, mesmos status, mesmas respostas.

Os exemplos desta lição são a API da Biblioteca Vila Maria, uma biblioteca de bairro com acervo, leitores cadastrados e empréstimo com prazo. Ela já existe e funciona; o que vamos fazer aqui é reorganizar o mesmo código em arquivos, e depois provar, com diff, que nenhuma resposta mudou.

Pense no balcão de uma biblioteca

Imagine uma biblioteca pequena com uma única pessoa atendendo empréstimo, cadastro, devolução, multa e reserva no mesmo balcão. No começo funciona. Quando a fila cresce, cada pedido obriga essa pessoa a procurar a regra certa no meio de um fichário enorme. Separar o código com express.Router() é abrir um balcão para cada assunto: livros de um lado, leitores de outro, empréstimos em outro.

O nome técnico continua sendo roteador. Ele recebe uma requisição, compara método e caminho e escolhe qual função vai atendê-la. A analogia termina aí: o router não cria outro servidor nem muda a URL pública. Ele só organiza a mesma porta de entrada por dentro. Durante a lição, confira essa ideia em cada etapa: se o cliente continua recebendo a mesma resposta, a biblioteca foi organizada sem mudar o serviço.

O index.js que cresceu: os sintomas antes da refatoração

A versão original é um arquivo só. Todas as rotas, os dados em memória e as regras moram nele:

bash
wc -l antes/index.js
150 antes/index.js

Cento e cinquenta linhas é pequeno perto do index.js de quatrocentas que você vai encontrar num projeto de verdade — e já é grande demais para trabalhar confortavelmente. Repare no que se repete:

js
app.post('/livros', (req, res) => {
  if (req.get('x-cracha') !== 'bibliotecario') {
    return res.status(403).json({ erro: 'Só bibliotecário altera o acervo' });
  }
  // ...
});

app.patch('/livros/:id', (req, res) => {
  if (req.get('x-cracha') !== 'bibliotecario') {
    return res.status(403).json({ erro: 'Só bibliotecário altera o acervo' });
  }
  // ...
});

app.delete('/livros/:id', (req, res) => {
  if (req.get('x-cracha') !== 'bibliotecario') {
    return res.status(403).json({ erro: 'Só bibliotecário altera o acervo' });
  }
  // ...
});

Três sintomas, e você provavelmente reconhece os três:

  • a mesma verificação de crachá copiada em três handlers;
  • Ctrl+F virou o jeito de navegar no arquivo;
  • duas pessoas mexendo em recursos diferentes dão conflito no mesmo arquivo.

Nenhum é problema de sintaxe. São problemas de onde o código mora.

express.Router(): um mini-app por recurso

Router() devolve um objeto com a mesma cara do app: tem .get(), .post(), .patch(), .delete() e .use(). A diferença é que ele não escuta porta nenhuma. Ele é uma peça, não um servidor.

js
// src/routes/livros.routes.js
import { Router } from 'express';

import * as controller from '../controllers/livros.controller.js';
import exigirCracha from '../middlewares/exigirCracha.js';

const router = Router();

router.get('/', controller.listar);
router.get('/:id', controller.mostrar);

router.use(exigirCracha('altera o acervo'));

router.post('/', controller.criar);
router.patch('/:id', controller.atualizar);
router.delete('/:id', controller.remover);

export default router;

Duas coisas para reparar já. Primeiro: os caminhos são '/' e '/:id', sem a palavra livros em lugar nenhum. Segundo: router.use(...) no meio do arquivo, depois dos GET — a ordem ali é intencional, e a gente volta nela.

app.use(‘/livros’, router): onde o prefixo realmente entra

A palavra livros entra uma vez só, na hora de montar:

js
app.use('/livros', livrosRotas);

O que o Express faz com esse prefixo não é concatenar string. Ele corta o trecho combinado da URL antes de entregar a requisição ao router, e guarda o que cortou em req.baseUrl. Dá para ver isso:

js
import express, { Router } from 'express';

const livros = Router();

livros.get('/:id', (req, res) => {
  res.json({
    originalUrl: req.originalUrl,
    baseUrl: req.baseUrl,
    url: req.url,
    path: req.path,
    params: req.params,
  });
});

const app = express();
app.use('/livros', livros);
app.listen(3030);
bash
curl -s "http://localhost:3030/livros/2?formato=curto"
{"originalUrl":"/livros/2?formato=curto","baseUrl":"/livros","url":"/2?formato=curto","path":"/2","params":{"id":"2"}}

Leia a saída com calma, porque ela explica quase tudo:

propriedade valor quem enxerga
req.originalUrl /livros/2?formato=curto a URL que o cliente pediu, intacta
req.baseUrl /livros o prefixo consumido pelo app.use
req.url /2?formato=curto o que sobrou para o router resolver
req.path /2 o mesmo, sem a query string

Por isso o arquivo de rotas escreve '/:id': quando a requisição chega nele, o /livros já foi embora. É também por isso que o seu middleware de log deve usar req.originalUrl — dentro de um router montado, req.url mostra o caminho mutilado.

O 404 que aparece quando você repete o prefixo

Esse é o tropeço número um de quem acabou de mudar para router. Você mantém o caminho completo dentro do arquivo:

js
import express, { Router } from 'express';

const livros = Router();

livros.get('/livros/:id', (req, res) => {   // errado: prefixo repetido
  res.json({ id: req.params.id });
});

const app = express();
app.use('/livros', livros);

app.use((req, res) => {
  res.status(404).json({ erro: `Rota ${req.method} ${req.path} não existe` });
});

app.listen(3030);
bash
curl -s -w '   [%{http_code}]\n' http://localhost:3030/livros/2
curl -s -w '   [%{http_code}]\n' http://localhost:3030/livros/livros/2
{"erro":"Rota GET /livros/2 não existe"} [404] {"id":"2"} [200]

A rota não sumiu: ela mudou de endereço para /livros/livros/2. Quando um 404 aparece logo depois de você extrair um router, é quase sempre isto.

O erro mais comum: o router que nunca foi exportado

O segundo tropeço não dá 404 — ele nem deixa o servidor subir. Você escreve o arquivo inteiro e esquece a última linha:

js
// src/routes/livros.routes.js
import { Router } from 'express';

const router = Router();

router.get('/', (req, res) => res.json([]));

// (faltou a última linha: export default router)
bash
node provas/sem-export/app.js
file:///.../provas/sem-export/app.js:2 import livrosRotas from './livros.routes.js'; ^^^^^^^^^^^ SyntaxError: The requested module './livros.routes.js' does not provide an export named 'default' at #asyncInstantiate (node:internal/modules/esm/module_job:327:21) at async ModuleJob.run (node:internal/modules/esm/module_job:431:5) at async node:internal/modules/esm/loader:633:26 at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:101:5) Node.js v24.16.0

O erro é bom: ele aponta o arquivo, a linha e o nome do export que faltou. Em projeto CommonJS, o mesmo esquecimento produz uma mensagem bem menos generosa, porque o require devolve um objeto vazio em vez de falhar:

js
// livros.routes.js — versão CommonJS do mesmo erro
const { Router } = require('express');

const router = Router();
router.get('/', (req, res) => res.json([]));

// (faltou a última linha: module.exports = router)

O app.js que importa esse arquivo continua idêntico. A diferença é que agora ele recebe {}, e só descobre isso na linha do app.use:

bash
node provas/cjs/app.js
/.../node_modules/router/index.js:392 throw new TypeError('argument handler must be a function') ^

TypeError: argument handler must be a function at router.use (/…/node_modules/router/index.js:392:13) at app.<anonymous> (/…/node_modules/express/lib/application.js:222:21) at Array.forEach (<anonymous>) at app.use (/…/node_modules/express/lib/application.js:219:7) at Object.<anonymous> (/…/provas/cjs/app.js:5:5) Node.js v24.16.0

argument handler must be a function quer dizer “você passou para o use uma coisa que não é middleware”. Na prática, quase sempre é um router que não foi exportado, ou um module.exports = { router } importado como se fosse o router direto. Se você ainda não decidiu entre os dois sistemas de módulo, a lição ESM ou CommonJS no Node compara os dois no mesmo projeto.

Separando rota, controller e serviço sem inventar camada

Com o recurso já isolado, o arquivo de rotas fica sendo o índice do recurso: quem lê ele vê a API inteira de livros em seis linhas de rota. O que cada rota faz mora em outro lugar.

O controller conhece HTTP. Ele lê req, escolhe o status e escreve res:

js
// src/controllers/livros.controller.js
import * as servico from '../services/livros.service.js';

export function mostrar(req, res) {
  const livro = servico.porId(req.params.id);
  if (!livro) return res.status(404).json({ erro: 'Livro não encontrado' });
  res.json(livro);
}

export function criar(req, res) {
  const { titulo, autor, exemplares } = req.body;
  if (!titulo || !autor) {
    return res.status(400).json({ erro: 'titulo e autor são obrigatórios' });
  }
  res.status(201).json(servico.criar({ titulo, autor, exemplares }));
}

O serviço não conhece HTTP. Ele não recebe req, não devolve res, e por isso você consegue testá-lo sem subir servidor nenhum:

js
// src/services/livros.service.js
import { acervo, proximoId } from '../acervo.js';

export function porId(id) {
  return acervo.livros.find((l) => l.id === Number(id));
}

export function criar({ titulo, autor, exemplares }) {
  const livro = { id: proximoId('livros'), titulo, autor, exemplares: exemplares ?? 1 };
  acervo.livros.push(livro);
  return livro;
}

export function remover(id) {
  const indice = acervo.livros.findIndex((l) => l.id === Number(id));
  if (indice === -1) return false;
  acervo.livros.splice(indice, 1);
  return true;
}

Repare que remover devolve true ou false, não 204 nem 404. Traduzir isso para status é trabalho do controller. Essa fronteira é o critério inteiro:

camada pode tocar em nunca toca em
rotas caminho, método, middleware do grupo req.body, banco, regra
controller req, res, status, formato da resposta consulta ao banco, cálculo de regra
serviço dados, regra de negócio, outros serviços req, res, status HTTP

E agora a parte que quase nenhum tutorial diz: não crie camada onde não há regra. Na Biblioteca Vila Maria, leitores só guarda e devolve. O controller fala direto com os dados, e não existe leitores.service.js nenhum:

js
// src/controllers/leitores.controller.js
import { acervo, proximoId } from '../acervo.js';

export function criar(req, res) {
  const { nome, email } = req.body;
  if (!nome || !email) {
    return res.status(400).json({ erro: 'nome e email são obrigatórios' });
  }
  const leitor = { id: proximoId('leitores'), nome, email };
  acervo.leitores.push(leitor);
  res.status(201).json(leitor);
}

Um service que só repassa a chamada não organiza nada — ele só acrescenta um arquivo que você vai abrir por engano. Empréstimo tem regra (baixa de exemplar, devolução repetida, disponibilidade), então empréstimo tem service. Leitor não tem, então não tem.

Middleware que vale só para um grupo de rotas

Lembra da verificação de crachá copiada três vezes? Ela vira um middleware, e o router decide o alcance dela. Como o crachá protege ações com mensagens diferentes, o middleware é uma função que devolve um middleware:

js
// src/middlewares/exigirCracha.js
export default function exigirCracha(acao) {
  return (req, res, next) => {
    if (req.get('x-cracha') !== 'bibliotecario') {
      return res.status(403).json({ erro: `Só bibliotecário ${acao}` });
    }
    next();
  };
}

Existem duas formas de aplicar, e elas resolvem casos diferentes. A primeira é router.use(), que vale para tudo que vier depois dele no arquivo:

js
router.get('/', controller.listar);      // público
router.get('/:id', controller.mostrar);  // público

router.use(exigirCracha('altera o acervo'));

router.post('/', controller.criar);      // exige crachá
router.patch('/:id', controller.atualizar);
router.delete('/:id', controller.remover);

A segunda é passar o middleware direto na rota, quando só uma precisa dele:

js
router.post('/', exigirCracha('cadastra leitor'), controller.criar);

O erro aqui é de posição, não de sintaxe. Subindo aquele router.use() três linhas, para antes dos GET, a leitura pública do acervo morre:

bash
curl -s -w '   [%{http_code}]\n' http://localhost:3030/livros
curl -s -w '   [%{http_code}]\n' http://localhost:3030/leitores
{"erro":"Só bibliotecário altera o acervo"} [403] [{"id":1,"nome":"Ana Prado","email":"ana@vilamaria.org"},{"id":2,"nome":"Bruno Sales","email":"bruno@vilamaria.org"}] [200]

Duas lições numa saída só. /livros quebrou porque o middleware passou a rodar antes das rotas públicas. E /leitores continuou aberto — prova de que o router.use() não vaza para fora do router em que foi escrito. Esse escopo é exatamente o que você não tinha no index.js gigante, onde app.use() pegava o aplicativo inteiro. Se a mecânica de next() e ordem ainda não estiver clara, vale reler middleware no Express antes de seguir.

mergeParams: quando a rota filha precisa do :id do pai

Empréstimo não existe sozinho: ele é sempre de um leitor. A URL diz isso, e o router também pode dizer:

js
// src/routes/leitores.routes.js
import { Router } from 'express';

import * as controller from '../controllers/leitores.controller.js';
import emprestimosRotas from './emprestimos.routes.js';
import exigirCracha from '../middlewares/exigirCracha.js';

const router = Router();

router.use('/:leitorId/emprestimos', emprestimosRotas);

router.get('/', controller.listar);
router.get('/:id', controller.mostrar);
router.post('/', exigirCracha('cadastra leitor'), controller.criar);

export default router;

O router de empréstimos, então, só cuida do que vem depois:

js
// src/routes/emprestimos.routes.js
import { Router } from 'express';

import * as controller from '../controllers/emprestimos.controller.js';

const router = Router({ mergeParams: true });

router.get('/', controller.listarDoLeitor);
router.post('/', controller.criar);
router.post('/:id/devolucao', controller.devolver);

export default router;

Aquele { mergeParams: true } não é enfeite. Por padrão, cada router só enxerga os parâmetros dos próprios caminhos — os do prefixo em que ele foi montado ficam de fora. Montei os dois lado a lado para medir a diferença:

js
import express, { Router } from 'express';

const semMerge = Router();
semMerge.get('/', (req, res) => res.json({ params: req.params }));

const comMerge = Router({ mergeParams: true });
comMerge.get('/', (req, res) => res.json({ params: req.params }));

const app = express();
app.use('/leitores/:leitorId/sem-merge', semMerge);
app.use('/leitores/:leitorId/com-merge', comMerge);
app.listen(3030);
bash
curl -s http://localhost:3030/leitores/1/sem-merge
curl -s http://localhost:3030/leitores/1/com-merge
{"params":{}} {"params":{"leitorId":"1"}}

Objeto vazio contra objeto com o leitorId dentro. E o estrago no aplicativo real é silencioso: trocando Router({ mergeParams: true }) por Router() em emprestimos.routes.js, req.params.leitorId vira undefined, Number(undefined) vira NaN, e a busca pelo leitor não acha ninguém:

bash
curl -s -w '   [%{http_code}]\n' http://localhost:3030/leitores/1/emprestimos
{"erro":"Leitor não encontrado"} [404]

Um 404 que mente. O leitor 1 existe; quem sumiu foi o parâmetro. Quando uma rota aninhada devolve “não encontrado” para um id que você acabou de listar, mergeParams é o primeiro lugar para olhar — antes disso, confira também se o parâmetro está mesmo onde você acha que está, em req.params, req.query e req.body.

Um arquivo de rotas que carrega todos os outros

Falta juntar tudo. Em vez de o app.js importar quatro routers, existe um routes/index.js que só faz a montagem:

js
// src/routes/index.js
import { Router } from 'express';

import livrosRotas from './livros.routes.js';
import leitoresRotas from './leitores.routes.js';

const rotas = Router();

rotas.use('/livros', livrosRotas);
rotas.use('/leitores', leitoresRotas);

export default rotas;

E o app.js monta esse único router, cercado pelos middlewares globais e pelo 404 do fim:

js
// src/app.js
import express from 'express';

import rotas from './routes/index.js';
import registrarAcesso from './middlewares/registrarAcesso.js';

const app = express();

app.use(express.json());
app.use(registrarAcesso);
app.use(rotas);

app.use((req, res) => {
  res.status(404).json({ erro: `Rota ${req.method} ${req.path} não existe` });
});

export default app;

Repare que o app.js não chama listen. Quem sobe o servidor é o server.js, com sete linhas:

js
// src/server.js
import app from './app.js';

const PORTA = process.env.PORT ?? 3030;

app.listen(PORTA, () => {
  console.log(`Biblioteca Vila Maria no ar em http://localhost:${PORTA}`);
});

Essa separação parece cerimônia até o dia do primeiro teste automatizado: o teste importa app.js e nunca ocupa uma porta.

O ganho aparece quando chega um recurso novo. A biblioteca decidiu reservar salas de estudo. Criei routes/salas.routes.js e controllers/salas.controller.js, e o routes/index.js mudou assim:

diff
 import livrosRotas from './livros.routes.js';
 import leitoresRotas from './leitores.routes.js';
+import salasRotas from './salas.routes.js';
 
 const rotas = Router();
 
 rotas.use('/livros', livrosRotas);
 rotas.use('/leitores', leitoresRotas);
+rotas.use('/salas', salasRotas);

Duas linhas em arquivo existente, e nenhuma linha tocada em livros, leitores ou empréstimos:

bash
curl -s -X POST -H 'x-cracha: bibliotecario' http://localhost:3030/salas/1/reserva
curl -s http://localhost:3030/livros/2
{"id":1,"nome":"Sala Rachel de Queiroz","lugares":6,"reservada":true} {"id":2,"titulo":"Vidas Secas","autor":"Graciliano Ramos","exemplares":2}

Antes e depois: o mesmo projeto em duas árvores de arquivo

O arquivo único virou isto:

bash
npx tree-cli -l 4
depois └── src ├── acervo.js ├── app.js ├── controllers | ├── emprestimos.controller.js | ├── leitores.controller.js | └── livros.controller.js ├── middlewares | ├── exigirCracha.js | └── registrarAcesso.js ├── routes | ├── emprestimos.routes.js | ├── index.js | ├── leitores.routes.js | └── livros.routes.js ├── server.js └── services ├── emprestimos.service.js └── livros.service.js directory: 5 file: 14

E aqui vem o número que ninguém mostra, porque ele não é o que a gente espera:

bash
find src -type f -name '*.js' | sort | xargs wc -l
20 src/acervo.js 16 src/app.js 35 src/controllers/emprestimos.controller.js 21 src/controllers/leitores.controller.js 32 src/controllers/livros.controller.js 8 src/middlewares/exigirCracha.js 4 src/middlewares/registrarAcesso.js 11 src/routes/emprestimos.routes.js 11 src/routes/index.js 15 src/routes/leitores.routes.js 17 src/routes/livros.routes.js 7 src/server.js 36 src/services/emprestimos.service.js 31 src/services/livros.service.js 264 total

Modularizar aumentou o total de linhas: 150 viraram 264. Os import, os export e as assinaturas de função custam. Quem prometer que separar em arquivos “reduz o código” está vendendo outra coisa.

O que caiu foi o tamanho do arquivo que você precisa abrir. O maior arquivo do projeto tem 36 linhas; a mediana fica em 16,5. Para mexer no cálculo de devolução, você abre um arquivo de 36 linhas em vez de caçar a linha 130 de um de 150 — e daqui a um ano, de um de 600.

A prova que fecha a refatoração: 20 rotas, zero diferença

Refatoração sem verificação é reescrita torcendo. Antes de mexer no código, escrevi um script que bate em 20 rotas — sucesso, erro de validação, 403 sem crachá, 404 de recurso inexistente, 409 de devolução repetida — e grava status e corpo de cada uma:

bash
B=http://localhost:3030

bater() {
  local metodo=$1 caminho=$2
  shift 2
  echo "--- $metodo $caminho"
  curl -s -o /tmp/corpo.txt -w '%{http_code}\n' -X "$metodo" "$B$caminho" "$@"
  cat /tmp/corpo.txt
  echo
}

bater GET /livros
bater POST /livros -H 'content-type: application/json' -d '{"titulo":"Angústia","autor":"Graciliano Ramos"}'
bater POST /leitores/1/emprestimos/2/devolucao
# ... 17 outras

Rodei contra o index.js de 150 linhas, guardei em antes.txt. Rodei contra os 14 arquivos, guardei em depois.txt. Aí:

bash
diff antes.txt depois.txt
echo "diff -> $?"
diff -> 0

Nenhuma linha de diferença em 20 requisições. É isso que separa refatoração de reescrita: o cliente da API não tem como perceber que você mudou alguma coisa.

Catorze arquivos sobem mais devagar?

Essa pergunta aparece toda vez, e ela merece número em vez de opinião. Medi o tempo entre o início do processo e o callback do listen, com performance.now() dentro dele, em 11 execuções de cada versão, descartando a primeira (aquecimento):

versão mediana mínimo máximo
1 arquivo, 150 linhas 45,0 ms 39,8 ms 61,1 ms
14 arquivos, 264 linhas 47,5 ms 43,6 ms 53,5 ms

Medido num MacBook, Node 24.16.0 com Express 5.2.1, 10 execuções válidas por versão. A diferença de mediana é de 2,5 ms — e os intervalos se sobrepõem quase inteiros, o que quer dizer que boa parte disso é ruído da máquina, não custo dos arquivos. Resolver 13 módulos a mais custa menos que uma consulta ao banco. Organize por legibilidade; o boot aguenta.

O que vem depois

Você tem a API dividida por recurso, com prefixo, middleware de grupo e rota aninhada funcionando. Ainda sobrou repetição: cada controller escreve o próprio res.status(404).json(...). O passo seguinte é jogar tudo isso num handler de erro centralizado, onde o controller só precisa avisar que deu errado.

Se alguma dessas rotas está quebrando antes de você chegar lá, volte para Express do zero e confira a base. Para ver onde esta lição entra no caminho inteiro, o guia de Node.js mostra a ordem, e a trilha de Node lista as lições na sequência.

Prefere aprender em vídeo?

Tem uma aula sobre este assunto no nosso canal.

Ver todos os vídeos do canal
  • express
  • router
  • organizacao
  • controller
  • node

Perguntas frequentes

Preciso mesmo de controller e service, ou só o arquivo de rotas basta?
Comece só com o arquivo de rotas. O controller vira arquivo separado quando o handler passa de umas quinze linhas, e o service só nasce quando existe regra de negócio para guardar — no exemplo desta lição, leitores não tem service nenhum e isso é de propósito.
Posso ter mais de um Router no mesmo arquivo?
Pode, e às vezes compensa: um Router público e um Router protegido, montados no mesmo prefixo, deixam explícito o que exige autenticação. O que não vale é criar arquivo por rota — o corte útil é por recurso.
O nome do arquivo precisa ser livros.routes.js?
Não. O sufixo .routes.js é convenção, não regra do Express. O que importa é o nome dizer o recurso e o papel, para você achar o arquivo pelo nome sem abrir nenhum.
Dá para importar um Router dentro de outro Router mais de um nível?
Dá, e o Express aninha o prefixo a cada nível. Só lembre que cada nível novo precisa de mergeParams true se quiser enxergar os parâmetros dos níveis de cima — a herança não é automática.

Dúvidas e comentários

Travou em algum passo? Pergunte aqui — a equipe e outros alunos respondem.

Todo o código deste artigo foi executado em Node 24.16.0 com Express 5.2.1, e as saídas exibidas são as reais — como produzimos este conteúdo.

Fontes consultadas

  1. Express 5 — API Reference: Router — expressjs.com
  2. Express — Routing guide — expressjs.com

Continue por aqui