API Layers em Aplicações Fullstack: O Código que Falta entre Fetch e Produção
Resumo rápido
Este post cobre a lacuna que existe entre fazer um fetch funcionar e ter uma camada de API que sobrevive a 18 meses de evolução de produto. Vou mostrar como estruturo as camadas de comunicação em projetos fullstack com TypeScript, tanto no lado do cliente (React/Next.js) quanto no servidor (Node.js/Express/Fastify), com código funcional, tipagem compartilhada e tratamento de erro que não depende de try/catch espalhado em 47 arquivos.
O problema que ninguém estrutura direito
Em 2021, assumi um projeto Next.js com 14 meses de desenvolvimento acumulado. O time tinha 6 desenvolvedores. Encontrei 83 chamadas fetch espalhadas por componentes React, cada uma com seu próprio try/catch, seu próprio formato de erro, sua própria lógica de retry. Algumas convertiam a resposta com .json(), outras com .text() e depois JSON.parse. Sete delas não tratavam status 4xx como erro. O tempo médio para adicionar um novo endpoint consumido no frontend era de 45 minutos, incluindo debugging de tipagem.
Depois de reestruturar as camadas de API com o padrão que vou descrever, esse tempo caiu para 8 minutos. Erros de serialização em produção foram de ~12 por semana para zero nos 4 meses seguintes.
A raiz do problema: a maioria dos projetos fullstack JavaScript trata a comunicação HTTP como detalhe de implementação em vez de camada arquitetural. Ninguém desenha essa camada. Ela simplesmente acontece.
Anatomia das camadas
Eu organizo a comunicação em três camadas distintas, tanto no cliente quanto no servidor:
┌─────────────────────────────────────┐
│ Camada de Domínio (types/schemas) │ ← compartilhada
├─────────────────────────────────────┤
│ Camada de Transporte (HTTP client) │ ← infra
├─────────────────────────────────────┤
│ Camada de Serviço (use cases) │ ← lógica de negócio
└─────────────────────────────────────┘
A camada de domínio define os contratos. A camada de transporte sabe falar HTTP. A camada de serviço orquestra chamadas e transforma dados. Nenhum componente React ou controller Express toca HTTP diretamente.
Camada 1: Contratos compartilhados com Zod
O ponto de partida é um pacote (ou diretório) de schemas que tanto o frontend quanto o backend importam. Uso Zod porque ele faz validação em runtime e inferência de tipo em compile time ao mesmo tempo.
// packages/contracts/src/user.ts
import { z } from "zod";
export const UserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
displayName: z.string().min(1).max(120),
role: z.enum(["admin", "member", "viewer"]),
createdAt: z.string().datetime(), // ISO 8601 como string, não Date
});
// Inferência direta evita manter tipo e schema em sincronia manual
export type User = z.infer<typeof UserSchema>;
export const CreateUserPayload = UserSchema.omit({
id: true,
createdAt: true,
});
export type CreateUserPayload = z.infer<typeof CreateUserPayload>;
// Schema de resposta paginada reutilizável
export const PaginatedResponse = <T extends z.ZodType>(itemSchema: T) =>
z.object({
items: z.array(itemSchema),
total: z.number().int().nonneg(),
page: z.number().int().positive(),
pageSize: z.number().int().positive(),
});
export const PaginatedUsersSchema = PaginatedResponse(UserSchema);
export type PaginatedUsers = z.infer<typeof PaginatedUsersSchema>;
Por que createdAt é string e não Date? Porque JSON não tem tipo Date. Se você colocar z.coerce.date(), o schema funciona na validação mas quebra a serialização quando o backend responde e o frontend parseia. Manter como ISO string e converter para Date apenas onde a UI precisa elimina uma classe inteira de bugs.
Se você usa monorepo com arquitetura bem definida, esse pacote contracts fica como dependência interna do workspace.
Camada 2: HTTP Client tipado no frontend
A segunda camada encapsula toda a mecânica HTTP. Eu construo um wrapper fino so
Leia o artigo completo em https://vivodecodigo.com.br/backend/api-layers-fullstack-javascript-fetch-tipagem-ponta-a-ponta