Criar REST API com Express no Node.js

1. Visão geral

Este guia o orienta a construir uma REST API de gerenciamento de Order com Express — desde a inicialização do projeto, definição de rotas, middleware, validação de dados, até tratamento centralizado de erros. Após ler, você será capaz de construir uma API CRUD completa, com testes automatizados usando Jest e Supertest, e conhecerá os erros comuns ao trabalhar com Express.

Exemplo ao longo do guia: uma API de gerenciamento de pedidos (Order) de um Customer, com suporte para criar, visualizar, editar e deletar.

2. Preparar o projeto

Crie um novo projeto Node.js e instale Express:

mkdir order-api && cd order-api
npm init -y
npm install express
npm install --save-dev jest supertest

Adicione type: module para usar a sintaxe import/export (ES Module) e declare o script de teste em package.json:

{
  "name": "order-api",
  "type": "module",
  "scripts": {
    "start": "node src/server.js",
    "test": "node --experimental-vm-modules node_modules/.bin/jest"
  }
}

Nota: Jest com ES Module requer a flag --experimental-vm-modules. Se quiser evitar complicações, você pode remover "type": "module" e usar require/module.exports (CommonJS) — ambas são válidas, depende da preferência do time.

3. Criar servidor Express básico

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

const app = express();
app.use(express.json()); // faz parse do body JSON em req.body

app.get('/health', (req, res) => {
  res.json({ status: 'UP' });
});

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

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Order API está rodando na porta ${PORT}`);
});
  • express.json() é um middleware integrado que faz o parse automático de Content-Type: application/json em um objeto req.body. Sem essa linha, req.body será undefined.
  • Separar app.js de server.js para que os testes não precisem abrir uma porta real — essa é uma prática importante para REST API Node.js.

Teste assim:

node src/server.js
curl http://localhost:3000/health
{"status":"UP"}

4. Definir o resource Order

Use um “banco de dados” em memória usando um array para focar na API, sem depender de um banco de dados real.

// src/orders/orderRepository.js
let orders = [];
let nextId = 1;

export function findAll() {
  return orders;
}

export function findById(id) {
  return orders.find((o) => o.id === id);
}

export function create(data) {
  const order = { id: nextId++, ...data, status: 'PENDING' };
  orders.push(order);
  return order;
}

export function update(id, data) {
  const index = orders.findIndex((o) => o.id === id);
  if (index === -1) return null;
  orders[index] = { ...orders[index], ...data };
  return orders[index];
}

export function remove(id) {
  const index = orders.findIndex((o) => o.id === id);
  if (index === -1) return false;
  orders.splice(index, 1);
  return true;
}

Este repositório simula as operações CRUD que você verá em qualquer ORM (Sequelize, Prisma…), com a diferença de que armazena em RAM em vez de um banco de dados.

5. Roteamento com Express Router

Em vez de anexar todas as rotas ao app, use express.Router() para modularizar por recurso:

// src/orders/orderRouter.js
import { Router } from 'express';
import * as repo from './orderRepository.js';

const router = Router();

router.get('/', (req, res) => {
  res.json(repo.findAll());
});

router.get('/:id', (req, res, next) => {
  const order = repo.findById(Number(req.params.id));
  if (!order) return next({ status: 404, message: 'Order não existe' });
  res.json(order);
});

router.post('/', (req, res) => {
  const order = repo.create(req.body);
  res.status(201).json(order);
});

router.put('/:id', (req, res, next) => {
  const order = repo.update(Number(req.params.id), req.body);
  if (!order) return next({ status: 404, message: 'Order não existe' });
  res.json(order);
});

router.delete('/:id', (req, res, next) => {
  const deleted = repo.remove(Number(req.params.id));
  if (!deleted) return next({ status: 404, message: 'Order não existe' });
  res.status(204).end();
});

export default router;

Anexe o router ao app com o prefixo /orders:

// src/app.js (atualizado)
import orderRouter from './orders/orderRouter.js';

app.use('/orders', orderRouter);
  • router.get('/:id', ...) — :id é um parâmetro de rota, acessado via req.params.id (sempre é string, precisa Number() para comparação).
  • Chamar next({ status: 404, message: ... }) passa o erro para o middleware de tratamento de erros (seção 7), em vez de repetir res.status(404)... em cada rota.
  • 204 No Content para DELETE bem-sucedido — sem retornar body.

6. Validar dados de entrada

Validar manualmente com if/else é fácil de pular casos. Use express-validator para ser conciso e claro:

npm install express-validator
// src/orders/orderValidator.js
import { body, validationResult } from 'express-validator';

export const validateCreateOrder = [
  body('customerName').isString().notEmpty().withMessage('customerName é obrigatório'),
  body('amount').isFloat({ gt: 0 }).withMessage('amount deve ser maior que 0'),
  (req, res, next) => {
    const errors = validationResult(req);
    if (!errors.isEmpty()) {
      return res.status(400).json({ errors: errors.array() });
    }
    next();
  },
];

Anexe o validador à rota POST:

// src/orders/orderRouter.js (atualizado)
import { validateCreateOrder } from './orderValidator.js';

router.post('/', validateCreateOrder, (req, res) => {
  const order = repo.create(req.body);
  res.status(201).json(order);
});
  • Cada body(...) é uma regra que valida um campo; se falhar, erros são coletados em validationResult(req).
  • O middleware final no array verifica errors.isEmpty() — se houver erros, retorna 400 com detalhes e não chama next() para bloquear a requisição de chegar ao handler real.

Envie uma requisição sem amount:

curl -X POST http://localhost:3000/orders \
  -H "Content-Type: application/json" \
  -d '{"customerName": "Nam"}'
{"errors":[{"type":"field","msg":"amount deve ser maior que 0","path":"amount",...}]}

7. Tratamento centralizado de erros

Express permite definir um middleware de tratamento de erro — é um middleware com 4 parâmetros (err, req, res, next), colocado após todas as rotas:

// src/errorHandler.js
export function errorHandler(err, req, res, next) {
  const status = err.status || 500;
  const message = err.message || 'Erro no sistema';

  if (status === 500) {
    console.error(err); // registre o erro real para debug, não retorne stack trace ao cliente
  }

  res.status(status).json({ message });
}
// src/app.js (atualizado, adicione no FINAL do arquivo)
import { errorHandler } from './errorHandler.js';

app.use(errorHandler);
  • Express reconhece esse middleware como error handler pela quantidade de parâmetros (4), não pelo nome da função.
  • Deve-se colocar app.use(errorHandler) por último, após todos os app.use(router) — Express processa middleware na ordem em que são declarados.
  • Qualquer next(err) ou erro throw em uma rota (se usar express-async-errors ou try/catch) cairá aqui.

Erro comum: esquecer de chamar next(err) em código async (por exemplo await db.query(...) throw) faz Express não capturar o erro, causando crash do servidor ou requisição pendente. Com Express 5 (versão mais recente), async handler com throw de erro é automaticamente capturado — mas Express 4 requer que você envolva em try/catch e chame next(err) manualmente.

8. Adicionar camada Service

Quando a lógica fica mais complexa (por exemplo: não permitir criar order se amount ultrapassar o limite do cliente), não coloque tudo no router. Extraia para orderService:

// src/orders/orderService.js
import * as repo from './orderRepository.js';

const MAX_AMOUNT = 100_000_000;

export function createOrder(data) {
  if (data.amount > MAX_AMOUNT) {
    const err = new Error('Ultrapassou o limite permitido');
    err.status = 422;
    throw err;
  }
  return repo.create(data);
}
// src/orders/orderRouter.js (atualizado rota POST)
import * as orderService from './orderService.js';

router.post('/', validateCreateOrder, (req, res, next) => {
  try {
    const order = orderService.createOrder(req.body);
    res.status(201).json(order);
  } catch (err) {
    next(err);
  }
});

Agora o router cuida apenas de HTTP (status code, parse param), enquanto orderService cuida da lógica de negócio. Esse é um padrão similar à camada Controller–Service em outros frameworks, facilitando testar lógica de negócio independentemente de HTTP.

9. Escrever testes com Jest e Supertest

Supertest permite chamar a API diretamente no objeto app (instância Express) sem precisar abrir uma porta real — essa é a razão pela qual a seção 3 separa app.js de server.js.

// tests/order.test.js
import request from 'supertest';
import app from '../src/app.js';

describe('Order API', () => {
  it('criar order com sucesso retorna 201 e id', async () => {
    const response = await request(app)
      .post('/orders')
      .send({ customerName: 'Nam', amount: 500000 });

    expect(response.status).toBe(201);
    expect(response.body).toMatchObject({
      customerName: 'Nam',
      amount: 500000,
      status: 'PENDING',
    });
    expect(response.body.id).toBeDefined();
  });

  it('falta amount retorna 400', async () => {
    const response = await request(app)
      .post('/orders')
      .send({ customerName: 'Nam' });

    expect(response.status).toBe(400);
  });

  it('buscar order inexistente retorna 404', async () => {
    const response = await request(app).get('/orders/9999');

    expect(response.status).toBe(404);
    expect(response.body.message).toBe('Order não existe');
  });
});

Execute os testes:

npm test
PASS  tests/order.test.js
  Order API
    ✓ criar order com sucesso retorna 201 e id
    ✓ falta amount retorna 400
    ✓ buscar order inexistente retorna 404
  • request(app) inicializa um cliente HTTP virtual anexado diretamente ao app Express — mais rápido que testar pela rede real.
  • toMatchObject verifica apenas os campos especificados, ignorando os demais (como id gerado automaticamente) — útil para testar responses com valores dinâmicos.

Nota: porque orders é um array em memória compartilhado entre os testes, testes executados em ordem podem se afetar (por exemplo, a order criada no teste 1 ainda existe quando o teste 2 roda). Com uma API real conectada a um banco de dados, resete os dados em beforeEach ou use transaction rollback.

10. Quando não usar Express puro

  • API grande, múltiplos domínios complexos: Express não tem convenções obrigatórias sobre estrutura, facilitando “spaghetti code” se o time não estabelecer regras próprias. Considere NestJS (com DI, módulos, decoradores como Spring) se o time está acostumado com arquitetura layered clara.
  • Precisa de type-safety forte: Express + JavaScript puro não detecta erros de tipo em tempo de compilação. Se o projeto é grande, use TypeScript desde o início, não espere “crescer depois converter” — o custo de conversão posterior é muito maior.
  • Alto throughput, precisa de streaming/WebSocket complexo: Express ainda funciona, mas Fastify ou frameworks com mais otimizações de desempenho (benchmark serialização JSON mais rápida) podem ser mais adequados para sistemas com tráfego extremamente grande.

Para API pequena–média com CRUD claro como no exemplo acima, Express continua sendo uma escolha rápida e concisa, com comunidade grande e muitos middlewares prontos (auth, rate-limit, CORS…).

11. Conclusão

Neste guia, você construiu uma REST API Order completa com Express: roteamento com Router, validação de entrada com express-validator, tratamento centralizado de erros com middleware de 4 parâmetros, e separação da lógica de negócio em camada service para facilitar testes. A seção de testes com Jest + Supertest o deixa confiante para refatorar sem medo de quebrar o comportamento da API. Dessa base, você pode adicionar conexão com banco de dados real (Prisma, Mongoose), autenticação (JWT), ou migrar para TypeScript conforme o projeto cresce.

nodejsexpressrest-apijavascripttesting