Ir para o conteúdo
Backend

API Layers em Aplicações Fullstack: O Código que Falta entre Fetch e Produção

Marcos Soares
Atualizado em 
14 minutos de leitura
Ilustracao 3D de camadas translucidas empilhadas com feixe de luz verde atravessando representando API layers fullstack
Ouça este artigo
0:00API Layers em Aplicações Fullstack: O Código que Falta entre Fetch e Produção--:--

Conteúdo técnico toda semana

Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.

Sem spam. Cancele a qualquer momento com 1 clique.

Neste artigo

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:

Text
┌─────────────────────────────────────┐
│  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.

TYPESCRIPT
// 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 sobre fetch que resolve três problemas: serialização consistente, tratamento de erro uniforme e tipagem de entrada/saída.

TYPESCRIPT
// src/lib/api-client.ts
import { z } from "zod";
 
// Erro estruturado que qualquer camada acima consegue inspecionar
export class ApiError extends Error {
  constructor(
    public readonly status: number,
    public readonly code: string,
    public readonly details?: unknown
  ) {
    super(`API Error ${status}: ${code}`);
    this.name = "ApiError";
  }
}
 
interface RequestConfig<TBody = unknown> {
  method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
  path: string;
  body?: TBody;
  headers?: Record<string, string>;
  signal?: AbortSignal;
}
 
const BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001";
 
export async function apiRequest<TResponse>(
  config: RequestConfig,
  // Schema opcional: se passado, valida a resposta em runtime
  responseSchema?: z.ZodType<TResponse>
): Promise<TResponse> {
  const url = `${BASE_URL}${config.path}`;
 
  const response = await fetch(url, {
    method: config.method,
    headers: {
      "Content-Type": "application/json",
      ...config.headers,
    },
    body: config.body ? JSON.stringify(config.body) : undefined,
    signal: config.signal,
  });
 
  if (!response.ok) {
    // Tenta extrair corpo de erro estruturado do backend
    const errorBody = await response.json().catch(() => null);
    throw new ApiError(
      response.status,
      errorBody?.code ?? "UNKNOWN_ERROR",
      errorBody?.details
    );
  }
 
  // 204 No Content não tem corpo
  if (response.status === 204) {
    return undefined as TResponse;
  }
 
  const data = await response.json();
 
  // Validação em runtime: captura drift entre backend e frontend
  if (responseSchema) {
    const parsed = responseSchema.safeParse(data);
    if (!parsed.success) {
      console.error("Response validation failed:", parsed.error.flatten());
      throw new ApiError(502, "RESPONSE_VALIDATION_FAILED", parsed.error.flatten());
    }
    return parsed.data;
  }
 
  return data as TResponse;
}

A validação com Zod no cliente parece redundante, mas salvou meu time em produção mais de uma vez. Em um projeto de e-commerce em 2023, o backend mudou o campo price de number para string (para suportar valores monetários com precisão). O frontend quebrou silenciosamente, mostrando NaN no carrinho. Com validação de schema no client, o erro teria sido capturado de forma explícita, com stack trace e contexto, em vez de se manifestar como número invisível na UI.

Para retry e timeout, eu não reimplemento essa lógica aqui. Uso a abordagem que descrevi em Fetch, Retry e Timeout como middleware sobre esse client.

Camada 3: Serviços de domínio no frontend

A terceira camada expõe funções com nomes de negócio. Nenhum componente React sabe que existe HTTP por baixo.

TYPESCRIPT
// src/services/user-service.ts
import { apiRequest } from "@/lib/api-client";
import {
  UserSchema,
  PaginatedUsersSchema,
  type User,
  type PaginatedUsers,
  type CreateUserPayload,
} from "@my-app/contracts";
 
export const UserService = {
  async list(page = 1, pageSize = 20): Promise<PaginatedUsers> {
    return apiRequest(
      { method: "GET", path: `/users?page=${page}&pageSize=${pageSize}` },
      PaginatedUsersSchema
    );
  },
 
  async getById(id: string): Promise<User> {
    return apiRequest(
      { method: "GET", path: `/users/${id}` },
      UserSchema
    );
  },
 
  async create(payload: CreateUserPayload): Promise<User> {
    return apiRequest(
      { method: "POST", path: "/users", body: payload },
      UserSchema
    );
  },
 
  async delete(id: string): Promise<void> {
    // Sem schema: resposta 204 não tem corpo
    await apiRequest({ method: "DELETE", path: `/users/${id}` });
  },
};

O componente React consome assim:

TYPESCRIPT
// src/app/users/page.tsx
"use client";
 
import { useEffect, useState } from "react";
import { UserService } from "@/services/user-service";
import { ApiError } from "@/lib/api-client";
import type { PaginatedUsers } from "@my-app/contracts";
 
export default function UsersPage() {
  const [data, setData] = useState<PaginatedUsers | null>(null);
  const [error, setError] = useState<string | null>(null);
 
  useEffect(() => {
    const controller = new AbortController();
 
    UserService.list(1, 20)
      .then(setData)
      .catch((err) => {
        if (err instanceof ApiError && err.status === 403) {
          setError("Sem permissão para listar usuários.");
          return;
        }
        setError("Erro inesperado. Tente novamente.");
      });
 
    return () => controller.abort();
  }, []);
 
  if (error) return <p>{error}</p>;
  if (!data) return <p>Carregando...</p>;
 
  return (
    <ul>
      {data.items.map((user) => (
        <li key={user.id}>{user.displayName} ({user.role})</li>
      ))}
    </ul>
  );
}

Zero fetch no componente. Zero JSON.parse. Zero response.ok. O componente lida com dados de domínio e estados de UI.

O lado do servidor: validação de entrada com o mesmo schema

No backend, os mesmos schemas do pacote contracts servem para validar payloads de entrada. Com Fastify, isso fica particularmente limpo:

TYPESCRIPT
// src/routes/users.ts
import { FastifyInstance } from "fastify";
import { CreateUserPayload, UserSchema } from "@my-app/contracts";
import { UserRepository } from "@/repositories/user-repository";
import { randomUUID } from "node:crypto";
 
export async function userRoutes(app: FastifyInstance) {
  app.post("/users", async (request, reply) => {
    // Valida o body com o schema compartilhado
    const parsed = CreateUserPayload.safeParse(request.body);
 
    if (!parsed.success) {
      return reply.status(400).send({
        code: "VALIDATION_ERROR",
        details: parsed.error.flatten(),
      });
    }
 
    const user = {
      id: randomUUID(),
      ...parsed.data,
      createdAt: new Date().toISOString(),
    };
 
    await UserRepository.insert(user);
 
    // Valida a saída também: garante que o repositório
    // não corrompeu dados silenciosamente
    const validated = UserSchema.parse(user);
    return reply.status(201).send(validated);
  });
}

Validar a saída no backend parece paranoia. Não é. Em um projeto com PostgreSQL e PgBouncer que mantive em 2022, uma migration alterou uma coluna de varchar para text e removeu a constraint de tamanho. O repositório começou a retornar displayName com 500+ caracteres (dados importados via CSV). A validação de saída com Zod capturou isso antes de chegar ao cliente.

O que NÃO fazer

Anti-pattern 1: Fetch direto no componente com tipagem por asserção

TYPESCRIPT
// ❌ Código que encontro em 80% dos projetos que audito
export default function Dashboard() {
  const [users, setUsers] = useState<User[]>([]);
 
  useEffect(() => {
    fetch("/api/users")
      .then((res) => res.json())
      // `as User[]` é mentira em runtime: se o backend mudar, isso quebra silenciosamente
      .then((data) => setUsers(data as User[]))
      .catch(console.error); // "tratamento" de erro que engole tudo
  }, []);
 
  return <div>{users.map((u) => u.displayName)}</div>;
}

Problemas: (1) as User[] não valida nada, apenas silencia o compilador; (2) console.error no catch significa que o usuário vê uma tela vazia sem feedback; (3) se o endpoint retornar { items: User[], total: number } em vez de User[], o .map explode com "users.map is not a function" em produção.

Anti-pattern 2: Erro como string genérica

TYPESCRIPT
// ❌ Backend que retorna texto puro como erro
app.post("/users", async (req, res) => {
  try {
    const user = await createUser(req.body);
    res.json(user);
  } catch (err) {
    // O frontend não consegue distinguir "email duplicado" de "banco fora do ar"
    res.status(500).send("Something went wrong");
  }
});
TYPESCRIPT
// ✅ Erro estruturado com código máquina e detalhes
app.post("/users", async (req, res) => {
  try {
    const user = await createUser(req.body);
    res.status(201).json(user);
  } catch (err) {
    if (err instanceof DuplicateEmailError) {
      return res.status(409).json({
        code: "DUPLICATE_EMAIL",
        details: { field: "email", value: req.body.email },
      });
    }
    // Erro inesperado: loga internamente, retorna código genérico
    app.log.error(err);
    res.status(500).json({ code: "INTERNAL_ERROR" });
  }
});

O campo code é o contrato real entre backend e frontend. Status HTTP indica a categoria; code indica a semântica. O frontend usa code para decidir qual mensagem mostrar, qual campo destacar em vermelho, se deve redirecionar. Sem isso, você acaba parseando strings de erro com regex em produção (já vi isso acontecer).

Comparação de abordagens para comunicação fullstack

AspectoFetch espalhadoClient HTTP + ServicestRPCGraphQL
Tipagem ponta a pontaManual (as T)Via schemas compartilhadosAutomáticaVia codegen
Validação em runtimeNenhumaZod no client e serverZod integradoDepende do server
Overhead de setupZero~2h inicial~1h (com Next.js)~4h (schema + codegen)
Acoplamento front/backBaixo (mas frágil)Baixo e explícitoAlto (mesmo repo)Baixo
Curva para time novoNenhumaBaixaMédiaAlta
Adequado paraProtótipo, hackathonMaioria dos projetosMonorepo full TypeScriptAPIs públicas, múltiplos clients

Eu uso a abordagem de Client HTTP + Services na maioria dos projetos. Quando o time é 100% TypeScript e o projeto roda em monorepo, tRPC é uma escolha forte. GraphQL reservo para quando existem múltiplos consumidores com necessidades de dados diferentes (mobile, web, parceiros), o que justifica o custo do schema e do codegen.

Se o seu backend já segue design patterns sólidos em TypeScript, a camada de transporte se encaixa naturalmente como um adapter na borda da aplicação.

Serialização: o buraco negro silencioso

Um ponto que quase ninguém discute: a serialização JSON tem limitações reais que afetam sua API layer.

TYPESCRIPT
// O que o backend envia
const user = {
  id: "abc-123",
  balance: 1999999999999999n, // BigInt de um sistema financeiro
  createdAt: new Date("2024-01-15T10:30:00Z"),
};
 
JSON.stringify(user);
// TypeError: Do not know how to serialize a BigInt
 
// Mesmo sem BigInt, Date vira string:
JSON.stringify({ createdAt: new Date() });
// '{"createdAt":"2024-01-15T10:30:00.000Z"}'
// No cliente, JSON.parse NÃO reconstrói o Date. Fica como string.

É por isso que nos schemas Zod eu defino createdAt como z.string().datetime() e não como z.date(). O contrato reflete o que realmente trafega no JSON. A conversão para Date acontece na camada de serviço do frontend, apenas quando necessário para cálculos ou formatação.

Para BigInt, a solução é serializar como string no backend e usar z.string().transform(BigInt) no schema do frontend, ou, mais pragmaticamente, converter para number se a precisão permitir.

Relato: migração em projeto com 50+ endpoints

Em 2023, apliquei essa estrutura em um SaaS de gestão de frotas que tinha 54 endpoints REST. O frontend era Next.js 13 (App Router), o backend Fastify com Node.js e TypeScript. O time tinha 4 devs.

Antes da reestruturação:

  • 54 endpoints, 54 chamadas fetch ad hoc no frontend
  • 23 tipos duplicados (definidos tanto no front quanto no back, com divergências)
  • Tempo médio para integrar endpoint novo: 38 minutos
  • Bugs de serialização em produção: ~8 por mês

Depois:

  • 1 pacote contracts com 31 schemas Zod (alguns endpoints compartilham schemas)
  • 0 tipos duplicados
  • Tempo médio para integrar endpoint novo: 11 minutos
  • Bugs de serialização em produção nos 6 meses seguintes: 2 (ambos em endpoints legados que ainda não tinham sido migrados)

A migração levou 3 sprints. Não fizemos big bang. Cada dev migrava os endpoints das features que tocava. O client HTTP e os services foram criados na primeira sprint; o restante foi gradual.

Quando essa abordagem é demais

Se o seu projeto tem 5 endpoints e 1 dev, criar pacote de contratos compartilhados, API client tipado e service layer é over-engineering. Um fetch bem escrito com tipos manuais resolve. O ponto de inflexão, na minha experiência, fica em torno de 15+ endpoints ou 3+ devs no mesmo codebase. Abaixo disso, a burocracia não se paga.

Para APIs serverless simples, como as que descrevi em Deno + Deno KV, um client HTTP fino sem service layer intermediária é suficiente.

Outra situação onde essa abordagem não encaixa: quando frontend e backend são times completamente separados com ciclos de deploy independentes. Nesse caso, o pacote contracts compartilhado cria acoplamento de deploy. A alternativa é gerar tipos a partir de um schema OpenAPI, invertendo a direção da dependência.

Minha posição

A camada de API é infraestrutura, não detalhe. Trate-a com o mesmo rigor que você trata conexão com banco de dados ou autenticação. Ninguém espalha pg.query("SELECT ...") em 40 componentes React, mas faz exatamente o equivalente com fetch.

Schemas compartilhados com Zod resolvem 90% dos problemas de drift entre frontend e backend em projetos TypeScript fullstack. Não porque Zod é mágico, mas porque forçam um contrato explícito que é verificável em compile time e em runtime. Se você está em um monorepo TypeScript e ainda mantém tipos duplicados entre client e server, está desperdiçando horas por semana em bugs que não precisam existir.

Comece pelo API client. Depois extraia os services. Por último, compartilhe os schemas. Nessa ordem, cada passo já entrega valor antes do próximo.

FAQ

Preciso de Zod ou posso usar apenas interfaces TypeScript?

Interfaces TypeScript desaparecem em runtime. Elas protegem contra erros de digitação no editor, mas não validam dados que chegam pela rede. Se o backend retornar { role: "superadmin" } e seu tipo espera "admin" | "member" | "viewer", o TypeScript não vai reclamar em produção. Zod (ou io-ts, ou Valibot, ou ArkType) reclama. Para contratos de API, validação em runtime não é opcional.

Isso substitui tRPC?

Não substitui. tRPC faz tudo isso com menos boilerplate porque elimina a camada HTTP como abstração visível. A abordagem que descrevi funciona quando você não quer ou não pode acoplar frontend e backend no mesmo framework, ou quando o backend serve múltiplos clientes. Se seu projeto é Next.js fullstack em monorepo e o time é 100% TypeScript, tRPC é provavelmente a escolha mais produtiva.

Como lido com autenticação nessa estrutura?

O API client recebe um header Authorization via configuração global ou por request. Eu injeto o token no apiRequest através de um middleware ou wrapper que lê de cookie/session. O service layer não sabe que autenticação existe. Isso mantém a separação de responsabilidades e facilita testar services com mocks do API client.

Devo validar a resposta no frontend em produção? Isso não afeta performance?

A validação Zod de um objeto com 10 campos leva ~0.02ms. Em uma lista de 100 itens, ~0.5ms. O custo é irrelevante comparado à latência de rede. Eu mantenho validação ativa em produção. O custo de um bug de serialização que chega ao usuário (ticket de suporte, investigação, hotfix, deploy) é ordens de magnitude maior que 0.5ms por request.

Como testo os services sem subir o backend?

Mock do apiRequest. Como o service layer depende apenas da função apiRequest, você substitui ela em testes unitários com uma implementação que retorna dados fixos. Testes de integração usam MSW (Mock Service Worker) para interceptar requests HTTP reais no nível de rede. Isso testa inclusive a serialização e a validação Zod, sem precisar de um servidor rodando.

Marcos Soares

Escrito por

Marcos Soares

Fullstack Developer · CEO da Agência Poti

Fullstack Developer e CEO da Agência Poti. Mais de 20 anos construindo arquiteturas cloud-native com React, Next.js e sistemas distribuídos. Parceiro comercial do estúdio iellou design. Fundador do Vivo de Código.

Comentários

Participe da discussão

Seja o primeiro a comentar!

Continue Aprofundando

Conteúdo técnico toda semana

Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.

Sem spam. Cancele a qualquer momento com 1 clique.