Cloudflare Workers + D1: o padrão que adotei pra APIs no edge

Depois de quebrar a cabeça com roteamento e acesso a dados em Workers, fixei um padrão (router + db + handlers) que escala sem virar spaghetti.

O problema

Colocar a “fachada” de um site (o frontend, a parte visual que o usuário vê) para rodar de graça na Cloudflare é super fácil. A parte que costuma dar medo é a “engrenagem de trás” (o backend, onde ficam as regras do sistema e o banco de dados relacional que guarda as informações) sem precisar cuidar de um servidor inteiro.

Quando comecei a criar APIs (que são como pontes que ligam o visual às informações) usando o Cloudflare Workers com a ferramenta de banco de dados D1, eu copiei o jeito antigo de fazer no Express (um framework famoso do Node.js). Ou seja: criei um único arquivo chamado index.js, gigante, com toda a lógica misturada lá dentro.

Funcionou maravilhosamente para duas rotas (duas URLs). Na quinta, o código já estava impossível de ler. Na décima, eu já estava com medo até de mexer.

Este artigo mostra o padrão de organização que defini depois de refatorar (reorganizar e reescrever) meu código três vezes. Não é a única forma de fazer, mas foi a estrutura que achei que cresce sem virar uma bagunça — e que mantém a inicialização ultra-rápida que o modelo V8 Isolate do Workers exige.

A restrição que molda tudo

Antes de entender a estrutura, vale a pena entender por que ela é diferente do modelo do Express.

O Cloudflare Worker roda em algo chamado V8 Isolate — pense nisso não como um computador rodando 24 horas por dia (um processo Node persistente), mas como uma lâmpada inteligente que acende e apaga instantaneamente só quando alguém faz um pedido (request). Cada pedido do usuário pode rodar em uma instância novinha em folha. Por isso, o código precisa carregar em milissegundos, sem aquele processo pesado de “boot” (inicialização).

Isso descarta algumas práticas comuns no Express: não dá para criar um “pool” de conexões com o banco de dados fixo no topo do arquivo (já que não existe um “topo do arquivo” rodando o tempo todo para manter essa conexão viva), e não dá para carregar configurações pesadas do disco. A estrutura precisa ser leve e stateless (sem guardar memória de acessos passados no servidor).

O padrão: router + db + handlers

A estrutura que adotei é dividida em três camadas simples, onde cada uma tem uma única responsabilidade.

1. index.js — o recepcionista (só roteia). Ele prepara o roteador (usando bibliotecas como Hono ou itty-router), mapeia quais URLs vão para quais funções e exporta o evento fetch. Zero lógica de negócio aqui.

import { Router } from 'itty-router';
import { handlePessoa } from './pessoaHandler.js';

const router = Router();
router.get('/api/pessoa/:id', handlePessoa);

export default {
  fetch: router.handle,
};

Quando você abre esse arquivo, enxerga o mapa completo de toda a API. Quer adicionar uma rota nova? É só escrever uma linha.

2. db.js — o almoxarife (camada de acesso aos dados). O banco de dados D1 é entregue para o código dentro de env.DB no método fetch. Aqui neste arquivo, eu crio “atalhos” (wrappers) para não ter que repetir código: coisas como tratar listas que voltam vazias, converter textos JSON de colunas dinâmicas e padronizar mensagens de erro.

export async function findCandidato(env, inscricao) {
  const { results } = await env.DB.prepare(
    'SELECT * FROM candidatos WHERE inscricao = ?'
  ).bind(inscricao).all();
  return results[0] || null;
}

A grande vantagem é isolar todos os comandos de busca (SQL) em um só lugar. Quando a estrutura da busca muda (e ela vai mudar!), você só altera um arquivo, e não cinco.

3. pessoaHandler.js, feedbackHandler.js — o cozinheiro (a lógica). É aqui que a mágica acontece. Cada arquivo handler (manipulador) recebe o pedido do usuário (request) e os dados de ambiente (env), chama o db.js para pegar as informações e formata a resposta final.

import { findCandidato } from './db.js';

export async function handlePessoa(request, env) {
  const { id } = request.params;
  const candidato = await findCandidato(env, id);
  if (!candidato) return Response.json({ error: 'not found' }, { status: 404 });
  return Response.json(candidato);
}

Sem comandos SQL espalhados no handler. Sem regras do sistema dentro do roteador. Cada arquivo faz uma coisa só.

Por que essa separação importa

A tentação de quem está começando é colocar tudo dentro do handler — as buscas no banco (SQL), as regras e a formatação visual. Para 2 rotas, funciona. O problema aparece na terceira rota que precisa exatamente da mesma busca: você copia e cola o SQL. Na quinta rota, você já tem 5 cópias idênticas da mesma busca espalhadas pelo projeto. Quando a estrutura do banco mudar, você terá que caçar e alterar 5 lugares diferentes.

Com a camada db.js, a busca vive num lugar só. Se a estrutura do banco mudou, você mexe em apenas um arquivo. Os handlers continuam funcionando perfeitamente porque eles apenas chamam a função pronta, sem se importar com a busca crua em SQL.

E o arquivo de rotas fica limpo: qualquer pessoa que entrar no projeto abre o index.js e enxerga todo o mapa da API em 20 linhas, sem ter que ler dezenas de regras para entender quais caminhos existem.

As vantagens que observei em produção

Depois de aplicar essa estrutura em um sistema real (um simulador de concursos com picos enormes de acesso no dia do resultado):

  1. Latência baixíssima. Latência é o tempo de resposta. Como a API e o banco rodam no edge (servidores espalhados pelo mundo todo, muito próximos de onde o usuário está fisicamente), a resposta chega em menos de 30 milissegundos na maioria das vezes — algo que um servidor tradicional em um data center distante (como na Virgínia, EUA) jamais entregaria para um usuário no Brasil.
  2. Atualizações de banco (migrações) limpas. O D1 aceita comandos de banco (SQL puro) diretamente via linha de comando no terminal usando o Wrangler com npx wrangler d1 migrations apply. Isso permite versionar as mudanças do banco sem precisar de ORMs (ferramentas pesadas de tradução de código para banco).
  3. Escala sem pânico. Quando 500 candidatos acessam o simulador ao mesmo tempo para ver as notas, o Workers lida com esse aumento de tráfego instantaneamente. O D1 aguenta essa quantidade de acessos simultâneos que facilmente derrubaria um banco de dados tradicional mal configurado.

Isso não é porque a “Cloudflare é mágica”, mas sim porque o modelo serverless (sem servidor fixo para gerenciar) focado no edge, quando bem organizado, elimina um monte de trabalho operacional que você teria mantendo uma máquina virtual (como AWS EC2) e um banco Postgres tradicional.

O que aprendi

A lição que fica para qualquer projeto é: separar as responsabilidades no código não é frescura de tutorial — é economizar tempo no futuro. Eu resisti bastante para criar o arquivo db.js e separá-lo dos handlers porque achava burocracia para um projeto pequeno. Na quinta rota com código duplicado, percebi que organizar não tem a ver com o tamanho do projeto, mas sim com o custo de fazer uma alteração nele depois. Alterar o banco com comandos SQL espalhados em 5 arquivos leva horas; alterar com tudo centralizado leva minutos.

E a segunda lição, focada no mundo de serviços em nuvem (serverless): no edge, cada arquivo deve ser leve para carregar. Como o V8 Isolate recarrega e avalia o seu código constantemente, colocar arquivos pesados ou importações desnecessárias no topo do index.js aumenta o tempo de resposta na primeira inicialização (o famoso cold start). Manter o roteador enxuto e a lógica separada em handlers não é apenas organização — é garantir a melhor performance. No edge, arquitetura limpa e velocidade andam sempre juntas.

Leia também

Comentários

Carregando comentários…

Deixe um comentário