Dia 1 — Onde o 2º trimestre parou

Informatica · Conteudo · publicado em 30/09/2026
Dia 1 de 15

Onde o 2º trimestre parou

Aula 1

Revisão da API do 2º trimestre

O que a API do 2º trimestre faz

O trimestre passado terminou com um CRUD completo: quatro rotas, um arquivo, um MySQL. As quatro operações funcionam, e é por isso que elas servem de ponto de partida — o código do exemplo abaixo é o mesmo que estava rodando.

RotaO que fazStatus
GET /produtoslista todos200
GET /produtos/1busca por id200 ou 404
POST /produtoscria201 com o id novo
DELETE /produtos/1remove200 com removidos

Consultas parametrizadas, await, try/catch, servidor que sobe e desce: nada disso faltou. O que faltou são seis coisas, e o exemplo do fim mede duas delas rodando de verdade contra o servidor no ar.

Esta é a revisão de abertura: a API do trimestre passado é medida antes de ser mudada, e o que fica é a lista do que ela faz ao lado do que ela não decide. Cada rota por operação aparece com a consulta que a serve, e o código que cresceu aparece inteiro — quatro rotas, quatro blocos de SQL e um try/catch único que trata erro de negócio e erro técnico no mesmo lugar. Revisar o próprio código com o exemplo rodando é o que separa este dia de uma mudança às cegas: o defeito não é o que o código faz, é o que ele não tem decidido. O que ficou do 2º trimestre são exatamente essas duas coisas, e cada bloco de três dias adiante pega uma delas.

Onde a dívida técnica aparece

Dívida técnica é o custo que se paga por uma decisão tomada com pressa. No CRUD ela não aparece como código quebrado — aparece como decisões que impedem a próxima mudança.

A camada sumiu com a rota. Rota, consulta e resposta estão no mesmo bloco. Isso funcionou com quatro rotas. Na quarta alteração, quem precisar mudar a consulta vai abrir o mesmo arquivo onde está a resposta HTTP, e o try/catch do servidor vai engolir o erro técnico como se fosse erro de negócio.

Não existe dono da requisição. O DELETE abaixo vai sem token, sem header, sem nada — e remove. Não é um bug de implementação: é a ausência de uma decisão. Enquanto ninguém decidir quem pode remover, qualquer pessoa na internet remove.

A validação não existe. O POST com nm_item vazio e qtd zero responde 201 e grava. A tabela aceitou o que a API não conferiu, e a contagem do exemplo sai da consulta — não de um palpite.

O esquema não tem dono. As tabelas nascem de CREATE TABLE dentro do arquivo que por acaso roda primeiro. Trocar de arquivo pode mudar o esquema sem querer, e o erro aparece na consulta que roda depois.

Não há teste. A pasta não existe. Uma correção no dia 10 pode desfazer a do dia 3, e ninguém percebe sem uma requisição na mão.

Não há registro do que rodou. O NODE_ENV está vazio: a API não sabe se está em máquina de quem estuda ou em produção, e a porta é escolhida em tempo de execução por um listen(0).

O que a medição mostra

O bloco do fim do exemplo não comenta os defeitos — ele provoca dois deles e conta.

O DELETE vai sem credencial nenhuma e devolve 200 com removidos: 1. O número sai do affectedRows que o driver devolveu. Ninguém perguntou quem fez a requisição.

O POST manda nm_item: '' com qtd: 0 e recebe 201. Em seguida vem um SELECT COUNT(*) com os mesmos filtros, e a contagem sai 1: a linha inválida está no banco.

Duas medidas, dois defeitos de arquitetura confirmados no mesmo processo que serviu as requisições. O TRUNCATE no começo e a limpeza no fim existem para o exemplo rodar duas vezes dando o mesmo resultado.

O que o trimestre resolve

Cada bloco de três dias do trimestre pega uma promessa que ficou no 2º e que a API não cumpriu:

BlocoPromessa pendente
dias 1 e 2organizar o código em camadas
dias 3 e 4autenticação e rota protegida
dias 5 e 6teste e validação de entrada
dias 7 e 8migração de banco
dias 9 e 10segurança e segredo fora do código
dias 11 e 12medir a consulta antes de otimizar
dias 13 e 14concorrência e transação
dias 15 e 16regra de negócio e estado
dias 17 e 18trabalho que demora
dias 19 e 20log e rastreio
dias 21 e 22processo e ambiente
dias 23 e 24documentação e entrega
dias 25 e 26fechamento do ano

O assunto não muda: continua sendo Node.js e MySQL. O que muda é a exigência — o código precisa sobreviver a outra pessoa, a uma mudança de esquema e a um restart.

O status: 200 do DELETE sem token é a medida mais importante do exemplo. Um código que "funciona" na tela não prova que está protegido: prova que responde. O que protege é a decisão de quem pode chamar, e decisão se escreve antes de escrever a rota.

O exemplo declara a própria conexão com require('mysql2/promise') e fecha com end(). Uma conexão compartilhada que se fecha sozinha quando não há consulta em voo não sobrevive a um servidor HTTP mantendo requisições entre uma consulta e outra — e o aluno que copiar o exemplo para um projeto real precisa ver o require e o end().

Exemplo

'use strict';

// Exemplo da aula 1 do dia 1: a API do 2º trimestre funcionando de verdade,
// e a medicao do que ela deixou de fazer.
//
// A API e a do trimestre passado — o CRUD inteiro em um arquivo so. Ela
// funciona: as quatro rotas respondem e o dado vai para o MySQL. E e
// exatamente por funcionar que ela serve aqui: o trimestre comeca olhando
// codigo que roda, nao codigo imaginado.
//
// O `diagnostico` do fim nao e opiniao. Cada linha sai de uma medicao real:
// uma requisicao feita sem credencial, um INSERT que aceitou dado invalido,
// uma pasta procurada no disco.
//
// POR QUE O EXEMPLO ABRE A CONEXAO PROPRIA
// O `conexao` que o material entrega e do harness, e ele existe para exemplos
// curtos. Um exemplo que segura a conexao enquanto um servidor HTTP atende
// requisicoes nao pode usar a compartilhada: entre uma consulta e outra passa
// tempo sem nenhuma em voo, e a conexao e fechada por baixo. Por isso o
// exemplo declara o proprio `require('mysql2')` e fecha com `end()` no
// `finally` — que e o que o codigo de producao faz tambem.

const http = require('node:http');
const fs = require('node:fs');
const { createConnection } = require('mysql2/promise');

async function main() {
  const c = await createConnection({
    host: process.env.DB_HOST,
    port: Number(process.env.DB_PORT),
    user: process.env.DB_USER,
    password: process.env.DB_PASS,
    database: process.env.DB_NAME,
    multipleStatements: true,
  });

  // -------------------------------------------------------------- esquema
  await c.query(`
    CREATE TABLE IF NOT EXISTS tb_revisao (
      id     INT AUTO_INCREMENT PRIMARY KEY,
      nm_item VARCHAR(40) NOT NULL,
      qtd    INT NOT NULL
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
  `);

  // `TRUNCATE` e nao `DELETE` porque ele tambem zera o `AUTO_INCREMENT`. Com
  // `DELETE` o id da proxima insercao continuaria crescendo a cada execucao,
  // e a saida embutida na pagina mudaria entre uma rodada e outra. Aqui o
  // exemplo comeca sempre do mesmo estado.
  await c.query('TRUNCATE TABLE tb_revisao');
  await c.query('INSERT INTO tb_revisao (id, nm_item, qtd) VALUES (?, ?, ?)',
    [1, 'teclado', 2]);

  // --------------------------------------------------------------- a API
  // Rota, consulta e resposta no mesmo lugar: e o estado em que o 2º
  // trimestre terminou.
  async function json(res, status, corpo) {
    res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
    res.end(JSON.stringify(corpo));
  }

  async function lerCorpo(req) {
    const partes = [];
    for await (const p of req) partes.push(p);
    const bruto = Buffer.concat(partes).toString('utf8');
    return bruto ? JSON.parse(bruto) : {};
  }

  const rotas = {
    'GET /produtos': async (_req, res) => {
      const [linhas] = await c.query('SELECT * FROM tb_revisao ORDER BY id');
      await json(res, 200, { dados: linhas });
    },
    'GET /produtos/1': async (_req, res) => {
      const [linhas] = await c.query('SELECT * FROM tb_revisao WHERE id = ?', [1]);
      await json(res, linhas.length ? 200 : 404,
        linhas.length ? linhas[0] : { erro: 'item nao encontrado' });
    },
    'POST /produtos': async (req, res) => {
      const novo = await lerCorpo(req);
      const [r] = await c.execute(
        'INSERT INTO tb_revisao (nm_item, qtd) VALUES (?, ?)',
        [novo.nm_item, novo.qtd]);
      await json(res, 201, { id: r.insertId });
    },
    'DELETE /produtos/1': async (_req, res) => {
      const [r] = await c.execute('DELETE FROM tb_revisao WHERE id = ?', [1]);
      await json(res, 200, { removidos: r.affectedRows });
    },
  };

  const servidor = http.createServer(async (req, res) => {
    const rota = rotas[`${req.method} ${req.url}`];
    if (!rota) {
      await json(res, 404, { erro: 'rota nao encontrada' });
      return;
    }
    await rota(req, res);
  });

  // `listen(0)`: a porta 3000 pode estar ocupada na maquina de quem roda, e o
  // exemplo passaria a falhar por motivo aleatorio. Zero pede uma livre ao
  // sistema, e por isso o numero muda a cada execucao.
  await new Promise((r) => servidor.listen(0, '127.0.0.1', r));
  const base = 'http://127.0.0.1:' + servidor.address().port;
  console.log('API do 2º trimestre no ar em ' + base);
  console.log('a porta muda a cada execucao: e o listen(0) pedindo uma livre');

  const pedir = (metodo, caminho, corpo) => fetch(base + caminho, {
    method: metodo,
    headers: corpo ? { 'Content-Type': 'application/json' } : undefined,
    body: corpo ? JSON.stringify(corpo) : undefined,
  });

  try {
    console.log('\n--- as quatro rotas do CRUD ---');

    let r = await pedir('GET', '/produtos');
    console.log('GET    /produtos      -> ' + r.status + '  ' + await r.text());

    r = await pedir('GET', '/produtos/1');
    console.log('GET    /produtos/1    -> ' + r.status + '  ' + await r.text());

    r = await pedir('POST', '/produtos', { nm_item: 'mouse', qtd: 5 });
    console.log('POST   /produtos      -> ' + r.status + '  ' + await r.text());

    // -------------------------------------------------------------- medico
    // As duas medicoes que o trimestre resolve. Nenhuma delas epalpite: sao
    // requisicoes de verdade contra o servidor que esta no ar agora.

    console.log('\n--- o que a API aceita hoje ---');

    // Medida 1: nenhuma credencial e pedida. O DELETE abaixo vai sem token,
    // sem header de autenticacao, sem nada. E mesmo assim ele remove.
    r = await pedir('DELETE', '/produtos/1');
    console.log('DELETE sem token algum  -> ' + r.status + '  ' + await r.text());

    // Medida 2: nenhum campo e conferido. O item abaixo esta vazio e com
    // quantidade zero, e mesmo assim a rota responde 201.
    r = await pedir('POST', '/produtos', { nm_item: '', qtd: 0 });
    console.log('POST com dado invalido  -> ' + r.status + '  ' + await r.text());

    // `query` do mysql2 devolve `[linhas, campos]`: o array de linhas vem no
    // primeiro elemento. E por isso que o desmonte e `[conta]`, e nao `conta`
    // — que seria o array inteiro, e nao a linha.
    const [conta] = await c.query(
      'SELECT COUNT(*) AS n FROM tb_revisao WHERE nm_item = ? OR qtd = ?',
      ['', 0]);
    console.log('linhas invalidas gravadas no banco: ' + conta[0].n
      + ' — a tabela aceitou o que a API nao conferiu');
    // Limpa o dado invalido para o exemplo nao deixar estado para tras.
    await c.query('DELETE FROM tb_revisao WHERE nm_item = ? OR qtd = ?', ['', 0]);
  } finally {
    await new Promise((r) => servidor.close(r));
    console.log('\nservidor encerrado com close().');
  }

  // ------------------------------------------------------------- estrutura
  // O que o repositorio tem e o que nao tem. `existsSync` procura de verdade.
  console.log('--- estrutura do projeto ---');
  const perto = (nome) => fs.existsSync(__dirname + '/../' + nome);
  for (const [nome, papel] of [
    ['teste', 'pasta de teste'],
    ['migracoes', 'pasta de migracoes'],
    ['src', 'pasta src por camada'],
  ]) {
    console.log(papel + ': ' + (perto(nome) ? 'existe' : 'nao existe'));
  }

  const [tabelas] = await c.query('SHOW TABLES LIKE ?', ['%migrac%']);
  console.log('tabela de migracoes no banco: '
    + (tabelas.length ? tabelas.length + ' encontrada(s)' : 'nenhuma'));

  const [versao] = await c.query('SELECT VERSION() AS v');
  console.log('esquema criado por este arquivo, rodando em ' + versao[0].v);
  console.log('NODE_ENV: ' + (process.env.NODE_ENV || '(vazio — a API nao sabe '
    + 'em que ambiente esta)'));

  await c.end();
}

main().catch((erro) => {
  console.error('falhou:', erro.code || erro.name, '-', erro.message);
  process.exit(1);
});

Saída real

API do 2º trimestre no ar em http://127.0.0.1:36239
a porta muda a cada execucao: e o listen(0) pedindo uma livre

--- as quatro rotas do CRUD ---
GET    /produtos      -> 200  {"dados":[{"id":1,"nm_item":"teclado","qtd":2}]}
GET    /produtos/1    -> 200  {"id":1,"nm_item":"teclado","qtd":2}
POST   /produtos      -> 201  {"id":2}

--- o que a API aceita hoje ---
DELETE sem token algum  -> 200  {"removidos":1}
POST com dado invalido  -> 201  {"id":3}
linhas invalidas gravadas no banco: 1 — a tabela aceitou o que a API nao conferiu

servidor encerrado com close().
--- estrutura do projeto ---
pasta de teste: nao existe
pasta de migracoes: nao existe
pasta src por camada: nao existe
tabela de migracoes no banco: 1 encontrada(s)
esquema criado por este arquivo, rodando em 10.11.14-MariaDB-0ubuntu0.24.04.1
NODE_ENV: (vazio — a API nao sabe em que ambiente esta)
Aula 2

Separar em camadas: rota, serviço, repositório

Por que separar em camadas

O CRUD do 2º trimestre tinha rota, consulta e resposta no mesmo bloco. Isso funcionou com quatro rotas e quebra na quinta mudança. Separar responsabilidades não é arrumação de arquivos: é fazer com que cada decisão tenha um dono, e só um.

O critério de uma boa separação é o que o exemplo do dia mede: trocar uma camada sem tocar nas outras. No fim do exemplo o repositório é substituído por um que não fala com banco nenhum, e a mesma rota e o mesmo serviço continuam funcionando.

As três camadas

CamadaSabe deNão sabe de
rotaHTTP, status, JSONSQL, regra
serviçoregra de negócioSQL, HTTP
repositórioSQL, tabela, colunaHTTP, regra

O fluxo é sempre o mesmo, e ele é o que dá nome às camadas:

requisição → rota → serviço → repositório → MySQL
             ↑                      ↓
             └──── status ← erro ───┘

Cada camada tem nome em inglês no código de mercado, e o mesmo papel aparece com nomes diferentes: a que traduz HTTP é o controller, ou route handler quando a função só faz o roteamento; a que decide é o service; a que fala com o banco é o repository. Os três termos aparecem em código de framework, e a arquitetura em camadas é a mesma em qualquer um deles.

A rota traduz. Lê req.method e req.url, chama um método do serviço, e converte o retorno em res.writeHead com JSON. Ela não sabe por que qtd negativa é erro — sabe apenas que o serviço lançou algo com status.

const responder = (status, corpo) => {
  res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
  res.end(JSON.stringify(corpo));
};

O serviço decide. Não sabe que existe tabela, coluna ou SELECT. Recebe objeto, devolve objeto. A regra inteira mora aqui: o que é obrigatório, o que é repetido, o que é conflito.

if (!nmItem) throw new ErroDeNegocio('nm_item e obrigatorio', 400);
if (!Number.isInteger(qtd) || qtd <= 0) {
  throw new ErroDeNegocio('qtd deve ser inteiro maior que zero', 400);
}

O repositório executa. É a única camada com SQL. Também é onde mora o filtro por nome: o serviço pergunta "existe um item com esse nome?" e não sabe que a resposta sai de WHERE nm_item = ?.

Erro de negócio e erro técnico

A divisão que a aula do dia 11 detalha nasce aqui, e é a razão de o serviço lançar um tipo próprio em vez de throw new Error.

class ErroDeNegocio extends Error {
  constructor(mensagem, status) {
    super(mensagem);
    this.name = 'ErroDeNegocio';
    this.status = status;
  }
}

A rota distingue os dois com uma única pergunta:

if (erro instanceof ErroDeNegocio) {
  return responder(erro.status, { erro: erro.message });
}
responder(500, { erro: 'falha interna' });

O message do erro de negócio vai para o cliente, porque ele foi escrito para ser lido por quem está usando a API. O detalhe do erro técnico não vai: o console.error guarda, a resposta devolve cinco palavras.

No exemplo, os quatro status saem da consulta ao banco e da decisão do serviço, um por um: 201 no item válido, 409 no nome repetido, 400 no nome vazio, 400 na quantidade negativa, 404 no id que não existe.

Onde a lógica entra

A pergunta que a divisão responde é sempre a mesma: onde a lógica entra. A resposta tem três critérios, e a ordem importa — o que decide desce para a camada de baixo pela injeção, e o que só é tradução fica na de cima. Uma regra escrita no repositório vira SQL; uma regra escrita na rota vira if que ninguém acha; a regra escrita no serviço é a única que os dois lados conseguem ler.

No disco isso aparece como uma pasta src com um módulo por camada, e o nome do arquivo diz a camada:

  • src/rota/produto.js — http.createServer, status, JSON
  • src/servico/produto.js — regra de negócio, ErroDeNegocio
  • src/repositorio/produto.js — SQL, tabela tb_item

O import entre camadas respeita a seta e só a seta: a rota importa o serviço, e o serviço recebe o repositório por parâmetro sem importá-lo. O caminho de arquivo é o que a busca usa, e ele é escrito com barra: src/repositorio/produto.js.

Quando a pasta src não existe, o sinal é o próprio arquivo: se a rota e o SELECT estão no mesmo .js, ainda não há camadas — há um arquivo que sabe tudo.

Injeção de dependência

As camadas se ligam por parâmetro, não por require de dentro. criarServico(repo) recebe o repositório pronto; criarRota(servico) recebe o serviço pronto. Nenhuma das duas funções importa a camada de baixo.

const repo = criarRepositorio(c);
const servico = criarServico(repo);
const tratar = criarRota(servico);

É essa assinatura que torna a prova do fim do exemplo possível. criarServico(repoFalso) recebe um objeto que devolve dados de memória, e todo o resto da pilha continua igual — inclusive a mensagem de 409, que saiu da regra e não do banco.

O c do repositório vem de createConnection e fecha com end(). Uma conexão compartilhada não sobrevive a requisições que passam tempo sem consulta em voo, e o aluno que copiar o exemplo precisa ver o require e o end() na tela.

O que a troca de repositório prova

O segundo bloco do exemplo sobe outro servidor, com a mesma criarRota e o mesmo criarServico, e um repositório que devolve { id: 1, nm_item: 'memoria', qtd: 99 } sem tocar em banco. O GET /itens responde 200 com esse objeto, e o POST com nome repetido responde 409 com a mesma mensagem do banco real.

Duas respostas iguais vindas de lugares diferentes, no mesmo processo. É isso que a camada compra: a regra de negócio passa a ser testável sem banco, e o próximo bloco do trimestre vai usar exatamente isso.

O erro mais comum ao começar a separar é empurrar a regra para o repositório porque é "mais fácil" — o serviço vira um pass-through que só repassa parâmetro, e a divisão não compra nada. O sinal é o serviço não ter nenhum if próprio.

Separar em três camadas não é obrigatória em toda API. É obrigatória quando a regra existe, é testada por outra pessoa e o dado já tem dono. Uma API de leitura sem regra nenhuma fica mais clara em duas.

Exemplo

'use strict';

// Exemplo da aula 2 do dia 1: o mesmo CRUD em tres camadas.
//
// As tres camadas existem por uma razao que da para medir: cada uma pode ser
// trocada sem tocar nas outras. O exemplo monta as tres e depois faz a prova —
// troca o banco de lugar sem tocar no SQL, e troca a regra sem tocar na rota.

const http = require('node:http');
const { createConnection } = require('mysql2/promise');

// ============================================================ CAMADA 3: REPOSITORIO
// A unica camada que conhece SQL. Nao sabe o que e HTTP, nao sabe o que e
// regra de negocio, nao sabe o que e JSON. Recebe nome e devolve linha.
function criarRepositorio(c) {
  return {
    async listar() {
      const [linhas] = await c.query('SELECT * FROM tb_camadas ORDER BY id');
      return linhas;
    },

    async buscarPorId(id) {
      const [linhas] = await c.query('SELECT * FROM tb_camadas WHERE id = ?', [id]);
      return linhas[0] || null;
    },

    async criar(nmItem, qtd) {
      const [r] = await c.execute(
        'INSERT INTO tb_camadas (nm_item, qtd) VALUES (?, ?)', [nmItem, qtd]);
      return r.insertId;
    },

    // O filtro por nome mora AQUI, e nao no servico: o servico diz "existe um
    // item com esse nome?" sem saber que existe uma coluna `nm_item`, e sem
    // saber que a busca e por igualdade.
    async buscarPorNome(nome) {
      const [linhas] = await c.query(
        'SELECT * FROM tb_camadas WHERE nm_item = ?', [nome]);
      return linhas[0] || null;
    },

    async remover(id) {
      const [r] = await c.execute('DELETE FROM tb_camadas WHERE id = ?', [id]);
      return r.affectedRows;
    },
  };
}

// ============================================================ CAMADA 2: SERVICO
// A unica camada que conhece regra de negocio. Nao sabe SQL e nao sabe HTTP.
// Recebe objeto, devolve objeto — ou lanca ErroDeNegocio.
class ErroDeNegocio extends Error {
  constructor(mensagem, status) {
    super(mensagem);
    this.name = 'ErroDeNegocio';
    this.status = status;
  }
}

function criarServico(repo) {
  return {
    async listar() {
      return repo.listar();
    },

    async buscarPorId(id) {
      const item = await repo.buscarPorId(id);
      if (!item) {
        // Regra que depende do dado: o id existe? Nao existe e um erro de
        // negocio (404), nao um erro tecnico (500).
        throw new ErroDeNegocio('item nao encontrado', 404);
      }
      return item;
    },

    async criar(dados) {
      // Valida e normaliza ANTES de tocar o banco.
      const nmItem = String(dados.nm_item ?? '').trim();
      const qtd = Number(dados.qtd);

      if (!nmItem) throw new ErroDeNegocio('nm_item e obrigatorio', 400);
      if (!Number.isInteger(qtd) || qtd <= 0) {
        throw new ErroDeNegocio('qtd deve ser inteiro maior que zero', 400);
      }

      const repetido = await repo.buscarPorNome(nmItem);
      if (repetido) {
        throw new ErroDeNegocio('ja existe item com esse nome', 409);
      }

      return repo.criar(nmItem, qtd);
    },

    async remover(id) {
      const removidos = await repo.remover(id);
      if (removidos === 0) {
        throw new ErroDeNegocio('item nao encontrado', 404);
      }
      return removidos;
    },
  };
}

// ============================================================ CAMADA 1: ROTA
// A unica camada que conhece HTTP. Nao sabe SQL e nao sabe a regra: so
// traduz requisição em chamada de serviço e erro em status.
function criarRota(servico) {
  return async function tratar(req, res) {
    const responder = (status, corpo) => {
      res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
      res.end(JSON.stringify(corpo));
    };

    const partes = [];
    for await (const p of req) partes.push(p);
    const bruto = Buffer.concat(partes).toString('utf8');
    const dados = bruto ? JSON.parse(bruto) : {};

    const id = Number(req.url.split('/').pop());

    try {
      if (req.method === 'GET' && req.url === '/itens') {
        return responder(200, { dados: await servico.listar() });
      }
      if (req.method === 'GET' && Number.isInteger(id)) {
        return responder(200, await servico.buscarPorId(id));
      }
      if (req.method === 'POST' && req.url === '/itens') {
        const idNovo = await servico.criar(dados);
        return responder(201, { id: idNovo });
      }
      if (req.method === 'DELETE' && Number.isInteger(id)) {
        return responder(200, { removidos: await servico.remover(id) });
      }
      return responder(404, { erro: 'rota nao encontrada' });
    } catch (erro) {
      // A rota distingue os dois tipos de erro pelo que o servico lancou.
      if (erro instanceof ErroDeNegocio) {
        return responder(erro.status, { erro: erro.message });
      }
      // Erro tecnico: a rota nao devolve o detalhe para o cliente.
      responder(500, { erro: 'falha interna' });
      console.error('erro tecnico:', erro.code || erro.name, erro.message);
    }
  };
}

// ============================================================ LIGACAO
async function main() {
  const c = await createConnection({
    host: process.env.DB_HOST,
    port: Number(process.env.DB_PORT),
    user: process.env.DB_USER,
    password: process.env.DB_PASS,
    database: process.env.DB_NAME,
    multipleStatements: true,
  });

  await c.query(`
    CREATE TABLE IF NOT EXISTS tb_camadas (
      id     INT AUTO_INCREMENT PRIMARY KEY,
      nm_item VARCHAR(40) NOT NULL,
      qtd    INT NOT NULL
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
  `);
  await c.query('TRUNCATE TABLE tb_camadas');

  // O repositorio real, com SQL de verdade.
  const repo = criarRepositorio(c);

  const servico = criarServico(repo);
  const tratar = criarRota(servico);

  const servidor = http.createServer(tratar);
  await new Promise((r) => servidor.listen(0, '127.0.0.1', r));
  const base = 'http://127.0.0.1:' + servidor.address().port;
  console.log('mesmo CRUD, agora em tres camadas, em ' + base);
  console.log('a porta muda a cada execucao: e o listen(0) pedindo uma livre');

  const pedir = (metodo, caminho, corpo) => fetch(base + caminho, {
    method: metodo,
    headers: corpo ? { 'Content-Type': 'application/json' } : undefined,
    body: corpo ? JSON.stringify(corpo) : undefined,
  });

  try {
    console.log('\n--- rota servindo, servico decidindo, repositorio consultando ---');

    let r = await pedir('POST', '/itens', { nm_item: 'teclado', qtd: 2 });
    console.log('POST valido       -> ' + r.status + '  ' + await r.text());

    r = await pedir('POST', '/itens', { nm_item: 'teclado', qtd: 3 });
    console.log('POST nome repetido-> ' + r.status + '  ' + await r.text());

    r = await pedir('POST', '/itens', { nm_item: '  ', qtd: 1 });
    console.log('POST nome vazio   -> ' + r.status + '  ' + await r.text());

    r = await pedir('POST', '/itens', { nm_item: 'mouse', qtd: -5 });
    console.log('POST qtd negativa -> ' + r.status + '  ' + await r.text());

    r = await pedir('GET', '/itens');
    console.log('GET lista         -> ' + r.status + '  ' + await r.text());

    r = await pedir('GET', '/99');
    console.log('GET id inexistente-> ' + r.status + '  ' + await r.text());
  } finally {
    await new Promise((r) => servidor.close(r));
    console.log('\nservidor encerrado com close().');
  }

  // ------------------------------------------------------- a prova das camadas
  // Um repositorio que nao e o MySQL: o mesmo servico e a mesma rota, sem
  // tocar em uma linha do SQL acima. E isso que "camada" significa.
  const [tabelas] = await c.query('SHOW TABLES LIKE ?', ['%falso%']);
  console.log('tabela no banco para o repositorio falso: '
    + (tabelas.length ? 'existe' : 'nenhuma — ele nem fala com o banco'));

  const repoFalso = {
    async listar() { return [{ id: 1, nm_item: 'memoria', qtd: 99 }]; },
    async buscarPorId(id) { return id === 1 ? { id: 1, nm_item: 'memoria', qtd: 99 } : null; },
    async buscarPorNome(nome) { return nome === 'teclado' ? { id: 1, nm_item: 'teclado', qtd: 2 } : null; },
    async criar() { return 500; },
    async remover() { return 0; },
  };

  const servicoFalso = criarServico(repoFalso);
  const tratarFalso = criarRota(servicoFalso);

  const servidor2 = http.createServer(tratarFalso);
  await new Promise((r) => servidor2.listen(0, '127.0.0.1', r));
  const base2 = 'http://127.0.0.1:' + servidor2.address().port;
  console.log('\nmesma rota e mesmo servico, repositorio trocado (em ' + base2 + ')');

  try {
    let r2 = await fetch(base2 + '/itens');
    console.log('GET lista no falso-> ' + r2.status + '  ' + await r2.text());

    r2 = await fetch(base2 + '/itens', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ nm_item: 'teclado', qtd: 1 }),
    });
    console.log('POST repetido     -> ' + r2.status + '  ' + await r2.text()
      + '  (regra do servico, sem tocar no banco)');
  } finally {
    await new Promise((r) => servidor2.close(r));
    console.log('segundo servidor encerrado com close().');
  }

  console.log('\na regra do servico rodou duas vezes, em dois bancos diferentes');

  await c.end();
}

main().catch((erro) => {
  console.error('falhou:', erro.code || erro.name, '-', erro.message);
  process.exit(1);
});

Saída real

mesmo CRUD, agora em tres camadas, em http://127.0.0.1:33919
a porta muda a cada execucao: e o listen(0) pedindo uma livre

--- rota servindo, servico decidindo, repositorio consultando ---
POST valido       -> 201  {"id":1}
POST nome repetido-> 409  {"erro":"ja existe item com esse nome"}
POST nome vazio   -> 400  {"erro":"nm_item e obrigatorio"}
POST qtd negativa -> 400  {"erro":"qtd deve ser inteiro maior que zero"}
GET lista         -> 200  {"dados":[{"id":1,"nm_item":"teclado","qtd":2}]}
GET id inexistente-> 404  {"erro":"item nao encontrado"}

servidor encerrado com close().
tabela no banco para o repositorio falso: nenhuma — ele nem fala com o banco

mesma rota e mesmo servico, repositorio trocado (em http://127.0.0.1:34875)
GET lista no falso-> 200  {"dados":[{"id":1,"nm_item":"memoria","qtd":99}]}
POST repetido     -> 409  {"erro":"ja existe item com esse nome"}  (regra do servico, sem tocar no banco)
segundo servidor encerrado com close().

a regra do servico rodou duas vezes, em dois bancos diferentes