
Arquitetura de Camada de API para Aplicações Frontend
Abre qualquer codebase React de tamanho médio e procura por fetch ou axios.get. Se você encontrar essas chamadas espalhadas em uma dúzia de componentes, você já tem o bug que este post explica. O endpoint muda, o formato da resposta muda, ou o time de backend renomeia um campo — e agora você está dando grep na aplicação inteira em vez de editar um arquivo só.
Isso não é um problema de ferramenta. É uma camada que falta.
A camada que ninguém desenha de propósito
A maioria das aplicações frontend faz sua camada de fetching crescer de forma orgânica. Um componente precisa de dado, então alguém adiciona um useEffect com uma chamada fetch. Funciona, então o próximo componente copia o padrão. Seis meses depois você tem:
- O mesmo endpoint chamado a partir de quatro componentes diferentes, cada um com um tratamento de erro ligeiramente diferente
- Formatos de resposta da API vazando direto para as props dos componentes
- Nenhum lugar único para adicionar headers de autenticação, retry ou logging
- Componentes impossíveis de testar porque testá-los significa mockar
fetch
A solução não é uma biblioteca. É um limite. Você precisa de uma camada que fica entre "como o dado é buscado" e "como o componente usa o dado", de forma que uma mudança de um lado nunca toque no outro.
Eu estruturo isso em três peças: adapters, services e hooks. Cada uma com um único trabalho.
As três peças
Adapters: falam com o formato da API, e só isso
O único trabalho de um adapter é transformar uma resposta HTTP crua em um formato que sua aplicação entende — e transformar o dado da sua aplicação no que a API espera receber de volta.
// adapters/user.adapter.ts
export type ApiUserDto = {
id: string
first_name: string
last_name: string
email_address: string
created_at: string
}
export type User = {
id: string
fullName: string
email: string
createdAt: Date
}
export function toUser(dto: ApiUserDto): User {
return {
id: dto.id,
fullName: `${dto.first_name} ${dto.last_name}`,
email: dto.email_address,
createdAt: new Date(dto.created_at),
}
}
Repara na nomenclatura. ApiUserDto é feio de propósito — ele espelha exatamente o que o backend manda, com snake_case e tudo mais. User é o tipo com o qual sua aplicação de fato trabalha. O adapter é o único arquivo que conhece os dois formatos.
Services: donos do endpoint, não do componente
Um service encapsula as chamadas HTTP de um recurso e retorna o dado já adaptado. Ele conhece a URL, o método e qual adapter aplicar — nada além disso.
// services/user.service.ts
import { toUser, type ApiUserDto, type User } from '@/adapters/user.adapter'
import { httpClient } from '@/lib/http-client'
export const userService = {
async getById(id: string): Promise<User> {
const dto = await httpClient.get<ApiUserDto>(`/users/${id}`)
return toUser(dto)
},
async update(id: string, changes: Partial<User>): Promise<User> {
const dto = await httpClient.patch<ApiUserDto>(`/users/${id}`, changes)
return toUser(dto)
},
}
httpClient aqui é um wrapper fino em cima do fetch que cuida da base URL, dos headers de autenticação e do parsing de erro uma única vez. Se seu backend trocar de REST para GraphQL amanhã, essa é a única camada que muda.
Hooks: conectam services aos componentes
O hook é onde vivem as preocupações específicas do React — estado de loading, cache, refetch. Ele chama o service; nunca chama fetch diretamente.
// hooks/use-user.ts
import { useQuery } from '@tanstack/react-query'
import { userService } from '@/services/user.service'
export function useUser(id: string) {
return useQuery({
queryKey: ['user', id],
queryFn: () => userService.getById(id),
})
}
O componente nunca importa httpClient, nunca vê ApiUserDto, e nunca sabe que o endpoint é /users/${id}:
// components/UserProfile.tsx
import { useUser } from '@/hooks/use-user'
function UserProfile({ userId }: { userId: string }) {
const { data: user, isLoading } = useUser(userId)
if (isLoading) return <Spinner />
return <h1>{user.fullName}</h1>
}
Por que essa separação realmente importa
Aqui está o cenário que vende essa arquitetura: o time de backend renomeia email_address para email e reestrutura created_at dentro de um objeto aninhado metadata.
Sem a camada, você fica editando cada componente que toca em dado de usuário, na esperança de ter encontrado todos.
Com a camada, você abre adapters/user.adapter.ts, atualiza o toUser, e pronto:
// ✅ Só o adapter muda
export function toUser(dto: ApiUserDto): User {
return {
id: dto.id,
fullName: `${dto.first_name} ${dto.last_name}`,
email: dto.email, // antes era dto.email_address
createdAt: new Date(dto.metadata.created_at), // antes era dto.created_at
}
}
Cada hook, cada componente, cada teste que usa User continua funcionando sem precisar de nenhuma mudança. É esse o ponto todo — o limite absorve a mudança em vez de propagá-la.
Erros comuns
- ❌ Pular o adapter "porque a API já está limpa". APIs limpas hoje ganham um campo novo, uma chave renomeada, ou um endpoint v2 amanhã. O adapter custa cinco minutos agora e economiza horas depois.
- ❌ Colocar chamadas de fetch direto dentro de custom hooks. Isso acopla sua camada de React à sua camada de HTTP. Você não consegue reaproveitar a lógica de fetch fora de um componente, nem testá-la sem renderizar um.
- ❌ Retornar a resposta crua do Axios/fetch a partir de um service. Se um service retorna
AxiosResponse<T>em vez deT, todo mundo que chama precisa saber que existe um.data. Desembrulhe isso dentro do service. - ❌ Um
api.tsgigante com todos os endpoints. Isso vira um gargalo de import e um ímã de conflito de merge. Separe os services por recurso —user.service.ts,orders.service.ts— do mesmo jeito que você separaria módulos de domínio no backend.
Quando usar / quando pular
| Situação | Recomendação |
|---|---|
| Aplicação pequena, uma ou duas chamadas de API no total | Pule. Um useQuery com um fetch inline resolve. |
| Vários componentes consumindo o mesmo recurso | Use o padrão completo. Você vai buscar o mesmo dado de mais lugares do que imagina. |
| API do backend instável ou ainda em evolução | Use. O adapter é seu seguro contra as mudanças. |
| Você está construindo um design system ou uma biblioteca de componentes compartilhada | Use. Componentes nunca deveriam assumir um formato específico de backend. |
O que fazer agora
Escolha um recurso da sua aplicação — usuários, pedidos, o que for buscado a partir de mais lugares — e separe ele nesses três arquivos. Não tenta refatorar tudo de uma vez; é assim que refatorações morrem pela metade.
Depois que você fizer isso para um recurso, o padrão se repete sozinho. O próximo endpoint que você adicionar vai naturalmente seguir essa mesma estrutura de três arquivos, e daqui a seis meses, uma renomeação do backend vai custar cinco minutos em vez de uma tarde inteira de grep.