Pular para o conteúdo

Arquitetura no Frontend

A separação hexagonal transporta-se quase literalmente para o front, em três camadas:

  • Domínio / lógica — regras de negócio do cliente: custom hooks, use cases, stores, funções puras. Não conhece React de renderização nem HTTP.
  • UI — componentes que renderizam e capturam eventos. Idealmente “burros”: recebem dados e callbacks.
  • Infraestrutura — adapters concretos: API clients, localStorage, WebSocket, SDKs de terceiros.

A regra de dependência é a mesma da Clean Architecture: a UI e o domínio dependem de abstrações (portas); a infraestrutura implementa essas portas. O domínio nunca importa axios.

flowchart LR
    subgraph UI["Camada UI"]
        C[Componentes React]
    end
    subgraph DOM["Domínio / Lógica"]
        H[Hooks / Use Cases]
        P["Porta: UserRepository (interface)"]
    end
    subgraph INFRA["Infraestrutura"]
        A[HttpUserRepository - adapter]
        F[FakeUserRepository - teste]
    end
    C --> H
    H --> P
    A -. implementa .-> P
    F -. implementa .-> P
    A --> API[(REST/GraphQL)]

Ponto-chave: o API client é um adapter atrás de uma porta

Seção intitulada “Ponto-chave: o API client é um adapter atrás de uma porta”

Em vez de o componente (ou o hook) chamar fetch direto, ele depende de uma interface (porta). A implementação real com HTTP é apenas um adapter. Isso é DIP - Dependency Inversion Principle literal: o de-alto-nível (domínio) e o de-baixo-nível (HTTP) dependem ambos da abstração.

O ganho é Testabilidade e Arquitetura: em teste, injeta-se um fake que retorna dados em memória — sem rede, sem MSW, instantâneo e determinístico.

// domain/UserRepository.ts (a PORTA)
export type User = { id: string; name: string };
export interface UserRepository {
getById(id: string): Promise<User>;
}
// infra/HttpUserRepository.ts (o ADAPTER real)
import type { User, UserRepository } from "../domain/UserRepository";
export class HttpUserRepository implements UserRepository {
constructor(private readonly baseUrl: string) {}
async getById(id: string): Promise<User> {
const res = await fetch(`${this.baseUrl}/users/${id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
}
// domain/RepositoryContext.tsx (a porta de injeção pertence ao domínio = DIP)
import { createContext, useContext, type ReactNode } from "react";
import type { UserRepository } from "./UserRepository";
const RepoContext = createContext<UserRepository | null>(null);
export function RepositoryProvider({
repository,
children,
}: {
repository: UserRepository;
children: ReactNode;
}) {
return <RepoContext.Provider value={repository}>{children}</RepoContext.Provider>;
}
export function useUserRepository(): UserRepository {
const repo = useContext(RepoContext);
if (!repo) throw new Error("RepositoryProvider ausente");
return repo;
}
// domain/useUser.ts (use case como hook — depende da PORTA, não do adapter)
import { useEffect, useState } from "react";
import type { User } from "./UserRepository";
import { useUserRepository } from "./RepositoryContext";
export function useUser(id: string) {
const repo = useUserRepository();
const [user, setUser] = useState<User | null>(null);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let active = true;
repo
.getById(id)
.then((u) => active && setUser(u))
.catch((e) => active && setError(e as Error));
return () => {
active = false;
};
}, [id, repo]);
return { user, error };
}
// ui/UserName.tsx (Presentational — só renderiza)
import type { User } from "../domain/UserRepository";
export function UserName({ user }: { user: User | null; }) {
if (!user) return <span>Carregando…</span>;
return <strong>{user.name}</strong>;
}
// ui/UserNameContainer.tsx (Container — liga lógica à apresentação)
import { useUser } from "../domain/useUser";
import { UserName } from "./UserName";
export function UserNameContainer({ id }: { id: string }) {
const { user, error } = useUser(id);
if (error) return <span role="alert">Erro ao carregar</span>;
return <UserName user={user} />;
}

O teste então injeta um fake — sem tocar a rede:

UserNameContainer.test.tsx
import { render, screen } from "@testing-library/react";
import { RepositoryProvider } from "../domain/RepositoryContext";
import { UserNameContainer } from "./UserNameContainer";
import type { UserRepository } from "../domain/UserRepository";
const fakeRepo: UserRepository = {
getById: async (id) => ({ id, name: "Grace Hopper" }),
};
test("renderiza o nome vindo do repositório injetado", async () => {
render(
<RepositoryProvider repository={fakeRepo}>
<UserNameContainer id="7" />
</RepositoryProvider>
);
expect(await screen.findByText("Grace Hopper")).toBeInTheDocument();
});
  • Container / Presentational — separa “de onde vêm os dados” (container, com lógica) de “como aparecem” (presentational, puro). É SRP - Single Responsibility Principle em nível de componente e facilita reuso e teste. Exemplo acima.
  • Observer — o coração de todo state management reativo (Redux, Zustand, RxJS, signals). A store é o subject; componentes que assinam via seletor são os observers, re-renderizando quando o estado muda.
  • Strategy — trocar comportamento sem if/else proliferando. Ex.: uma tabela cujo algoritmo de ordenação/formatação é passado como função; um formulário cuja regra de validação é injetada. É OCP - Open-Closed Principle: aberto a novas estratégias, fechado a modificação.
// Strategy: formatação injetada, componente fechado a mudança
type PriceFormatter = (cents: number) => string;
const brl: PriceFormatter = (c) =>
(c / 100).toLocaleString("pt-BR", { style: "currency", currency: "BRL" });
const usd: PriceFormatter = (c) =>
(c / 100).toLocaleString("en-US", { style: "currency", currency: "USD" });
function Price({ cents, format }: { cents: number; format: PriceFormatter }) {
return <span>{format(cents)}</span>;
}
// <Price cents={19990} format={brl} /> → R$ 199,90

Contraste com Clean Architecture — e quando é overkill

Seção intitulada “Contraste com Clean Architecture — e quando é overkill”

Clean Architecture no front (entities → use cases → interface adapters → frameworks) é possível e valiosa quando o cliente concentra muita regra de negócio: um editor complexo, uma calculadora fiscal, um SPA offline-first com sincronização. Aí, portas, use cases e mappers explícitos pagam.

Mas para a maioria das telas — CRUD, listagens, formulários guiados pelo backend — a camada completa é over-engineering. O sinal de alarme: mais arquivos de mapeamento e interface do que lógica de fato. A régua prática, herdada de Frontend: isole o que tem regra e valor; não burocratize o que é montagem de tela. Comece extraindo a porta do API client (baixo custo, alto retorno em testabilidade) e só suba de nível quando a complexidade justificar.

  • fetch/axios dentro do componente de UI. Acopla renderização à rede, impede injeção de fake e viola SRP e DIP. Esconda atrás de uma porta.
  • “God component”. Componente de centenas de linhas que busca, calcula e renderiza — o controller-que-faz-tudo do frontend.
  • Regra de negócio no JSX. Cálculo de desconto/elegibilidade no corpo do render pertence a um use case/hook, não à camada de UI.
  • Context como banco de dados global. Injeção de dependência (repos, config) é bom uso; jogar todo o estado mutável da app num único Context causa re-renders em cascata — prefira uma store com seletores (Observer).
  • Clean Architecture completa em tela CRUD. Indireção sem retorno; o inverso do God component, igualmente nocivo.
  • Confundir framework com arquitetura. React é detalhe de infra da camada de UI; a lógica de domínio não deveria depender dele.

1. Por que dizer que um API client é “um adapter atrás de uma porta” e qual benefício isso traz?

Resposta

Porque o componente/hook depende de uma interface (a porta, ex. UserRepository), e a implementação HTTP é apenas um adapter que a satisfaz. Isso é DIP: alto e baixo nível dependem da abstração. O benefício direto é testabilidade — injeta-se um fake em memória, sem rede, tornando o teste rápido e determinístico.

2. Quais são as três camadas do frontend hexagonal e qual a regra de dependência entre elas?

Resposta

Domínio/lógica (hooks, use cases, stores), UI (componentes) e infraestrutura (API clients, storage, SDKs). A regra de dependência: UI e domínio dependem de abstrações/portas; a infraestrutura implementa essas portas. O domínio nunca importa detalhes concretos como axios.

3. Aponte o padrão GoF por trás de: (a) Redux/Zustand e (b) uma tabela cujo algoritmo de ordenação é passado por prop.

Resposta

(a) Observer — a store é o subject e os componentes que assinam via seletor são observers que re-renderizam na mudança de estado. (b) Strategy — o algoritmo é injetado como função, permitindo trocar comportamento sem modificar o componente (OCP).

4. Quando aplicar Clean Architecture completa no front é overkill, e qual o passo de menor custo para começar a arquitetar?

Resposta

É overkill em telas majoritariamente de “montagem” — CRUD, listagens, formulários guiados pelo backend — onde surgem mais arquivos de mapeamento/interface do que lógica real. O passo de menor custo e maior retorno é extrair a porta do API client (repository), o que já desacopla a rede e habilita testes com fake; sobe-se de nível só quando a regra de negócio no cliente justificar.