Clean Architecture no Frontend React: Separando Concerns de Verdade

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
O problema real: regra de negócio morando dentro de componente
Abra qualquer projeto React com mais de 20 telas. Procure um useEffect que faz fetch, transforma dados, aplica regra de negócio e seta estado no mesmo bloco. Esse padrão aparece em quase todo codebase que cresceu sem separação de camadas.
O resultado é previsível: testar a lógica de cálculo de desconto exige renderizar componente, mockar hook de roteamento e simular evento de clique. Uma regra que deveria ser uma função pura de 10 linhas vira refém do React.
Clean Architecture no frontend não significa replicar a estrutura de um backend Java com 47 pastas. Significa que a regra de negócio roda sem importar React, que trocar Axios por fetch nativo não exige mexer em 30 arquivos, e que um teste unitário de domínio executa em milissegundos sem jsdom.
Se você já leu o post sobre Clean Architecture com TypeScript e Node.js, a ideia central é a mesma: dependências apontam para dentro, nunca para fora. A diferença está em como adaptar isso para um ambiente onde o framework (React) é onipresente.
As camadas e o que vai em cada uma
A divisão que funciona para projetos React de médio porte (10-50 telas, 3+ devs) usa quatro camadas:
| Camada | Responsabilidade | Depende de | Exemplo concreto |
|---|---|---|---|
| Domain | Entidades, value objects, regras puras | Nada | calculateOrderTotal, OrderStatus |
| Application | Use cases, orquestração de regras | Domain | CreateOrderUseCase, ApplyDiscountUseCase |
| Infrastructure | HTTP clients, storage, APIs externas | Application (via interface) | HttpOrderRepository, LocalStorageCartGateway |
| Presentation | Componentes React, hooks, páginas | Application (via interface) | useCreateOrder, OrderPage |
A regra de ouro: Domain não importa nada de fora. Application importa Domain. Infrastructure e Presentation implementam interfaces definidas em Application.
Domain: lógica que não sabe que React existe
// src/domain/entities/Order.ts
export type OrderStatus = "draft" | "confirmed" | "shipped" | "delivered";
export interface OrderItem {
productId: string;
name: string;
unitPrice: number;
quantity: number;
}
export interface Order {
id: string;
items: OrderItem[];
status: OrderStatus;
couponCode: string | null;
createdAt: Date;
}
// Regra de negócio pura: desconto por cupom aplicado ao total
export function calculateOrderTotal(
items: OrderItem[],
discountPercent: number
): number {
const subtotal = items.reduce(
(sum, item) => sum + item.unitPrice * item.quantity,
0
);
// Desconto nunca ultrapassa 30%, independente do cupom
const clampedDiscount = Math.min(discountPercent, 30);
return subtotal * (1 - clampedDiscount / 100);
}
export function canCancelOrder(order: Order): boolean {
// Pedido só pode ser cancelado antes do envio
return order.status === "draft" || order.status === "confirmed";
}Esse arquivo não importa React, Axios, Zustand nem qualquer dependência externa. Testar calculateOrderTotal é chamar a função com argumentos e comparar o retorno. Sem render, sem provider, sem mock.
Application: use cases com contratos explícitos
A camada Application define o que o sistema faz sem dizer como. Ela declara interfaces (ports) que Infrastructure vai implementar.
// src/application/ports/OrderRepository.ts
import type { Order } from "@/domain/entities/Order";
// Port: contrato que a infraestrutura deve cumprir
export interface OrderRepository {
findById(id: string): Promise<Order | null>;
save(order: Order): Promise<void>;
findByStatus(status: string): Promise<Order[]>;
}// src/application/usecases/CreateOrderUseCase.ts
import type { Order, OrderItem } from "@/domain/entities/Order";
import type { OrderRepository } from "@/application/ports/OrderRepository";
import { calculateOrderTotal } from "@/domain/entities/Order";
interface CreateOrderInput {
items: OrderItem[];
couponCode: string | null;
discountPercent: number;
}
interface CreateOrderOutput {
order: Order;
total: number;
}
export class CreateOrderUseCase {
// Dependência injetada via construtor, não importada diretamente
constructor(private readonly orderRepository: OrderRepository) {}
async execute(input: CreateOrderInput): Promise<CreateOrderOutput> {
const total = calculateOrderTotal(input.items, input.discountPercent);
if (input.items.length === 0) {
throw new Error("Pedido precisa ter pelo menos um item");
}
const order: Order = {
id: crypto.randomUUID(),
items: input.items,
status: "draft",
couponCode: input.couponCode,
createdAt: new Date(),
};
await this.orderRepository.save(order);
return { order, total };
}
}O use case depende da interface OrderRepository, não de axios.post. Se amanhã a API mudar de REST para GraphQL, o use case não muda.
Infrastructure: onde o mundo externo entra
// src/infrastructure/repositories/HttpOrderRepository.ts
import type { Order } from "@/domain/entities/Order";
import type { OrderRepository } from "@/application/ports/OrderRepository";
// Adaptador que implementa o port usando fetch nativo
export class HttpOrderRepository implements OrderRepository {
constructor(private readonly baseUrl: string) {}
async findById(id: string): Promise<Order | null> {
const response = await fetch(`${this.baseUrl}/orders/${id}`);
if (response.status === 404) return null;
if (!response.ok) throw new Error(`Erro ao buscar pedido: ${response.status}`);
const data = await response.json();
return this.toDomain(data);
}
async save(order: Order): Promise<void> {
const response = await fetch(`${this.baseUrl}/orders`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(order),
});
if (!response.ok) throw new Error(`Erro ao salvar pedido: ${response.status}`);
}
async findByStatus(status: string): Promise<Order[]> {
const response = await fetch(`${this.baseUrl}/orders?status=${status}`);
if (!response.ok) throw new Error(`Erro ao listar pedidos: ${response.status}`);
const data = await response.json();
return data.map(this.toDomain);
}
// Mapeamento da resposta da API para o tipo do domínio
private toDomain(raw: Record<string, unknown>): Order {
return {
id: raw.id as string,
items: raw.items as Order["items"],
status: raw.status as Order["status"],
couponCode: (raw.couponCode as string) ?? null,
createdAt: new Date(raw.createdAt as string),
};
}
}Trocar fetch por Axios? Crie AxiosOrderRepository implementando a mesma interface. Nenhum use case muda. Nenhum componente muda.
Presentation: onde React finalmente aparece
A composição das dependências acontece num ponto de entrada. Esse é o lugar onde você instancia repositórios concretos e injeta nos use cases.
// src/infrastructure/container.ts
import { HttpOrderRepository } from "@/infrastructure/repositories/HttpOrderRepository";
import { CreateOrderUseCase } from "@/application/usecases/CreateOrderUseCase";
const API_URL = import.meta.env.VITE_API_URL ?? "http://localhost:3000";
// Composição raiz: único lugar que conhece implementações concretas
const orderRepository = new HttpOrderRepository(API_URL);
export const createOrderUseCase = new CreateOrderUseCase(orderRepository);O hook React consome o use case sem saber qual repositório está por trás:
// src/presentation/hooks/useCreateOrder.ts
import { useState } from "react";
import { createOrderUseCase } from "@/infrastructure/container";
import type { OrderItem } from "@/domain/entities/Order";
interface UseCreateOrderReturn {
execute: (items: OrderItem[], couponCode: string | null, discountPercent: number) => Promise<void>;
isLoading: boolean;
error: string | null;
}
export function useCreateOrder(): UseCreateOrderReturn {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function execute(
items: OrderItem[],
couponCode: string | null,
discountPercent: number
) {
setIsLoading(true);
setError(null);
try {
await createOrderUseCase.execute({ items, couponCode, discountPercent });
} catch (err) {
setError(err instanceof Error ? err.message : "Erro desconhecido");
} finally {
setIsLoading(false);
}
}
return { execute, isLoading, error };
}O componente fica limpo:
// src/presentation/pages/CreateOrderPage.tsx
import { useCreateOrder } from "@/presentation/hooks/useCreateOrder";
import type { OrderItem } from "@/domain/entities/Order";
const sampleItems: OrderItem[] = [
{ productId: "p1", name: "Teclado mecânico", unitPrice: 450, quantity: 1 },
{ productId: "p2", name: "Mouse ergonômico", unitPrice: 280, quantity: 2 },
];
export function CreateOrderPage() {
const { execute, isLoading, error } = useCreateOrder();
async function handleSubmit() {
await execute(sampleItems, "DESCONTO10", 10);
}
return (
<div>
<h1>Novo Pedido</h1>
{error && <p role="alert">{error}</p>}
<button onClick={handleSubmit} disabled={isLoading}>
{isLoading ? "Criando..." : "Criar Pedido"}
</button>
</div>
);
}O componente não sabe se os dados vêm de REST, GraphQL ou localStorage. Ele chama um hook que chama um use case que chama uma interface. Cada camada tem uma responsabilidade.
Anti-patterns: o que NÃO fazer
Erro 1: regra de negócio dentro do componente
// ERRADO: lógica de desconto acoplada ao componente
function OrderSummary({ items, couponCode }: Props) {
const [total, setTotal] = useState(0);
useEffect(() => {
let subtotal = items.reduce((s, i) => s + i.unitPrice * i.quantity, 0);
// Regra de negócio enterrada num useEffect
if (couponCode === "VIP") {
subtotal *= 0.8;
} else if (couponCode) {
subtotal *= 0.9;
}
setTotal(subtotal);
}, [items, couponCode]);
return <span>Total: R$ {total.toFixed(2)}</span>;
}O problema: testar essa regra de desconto exige renderizar o componente. Se outro componente precisa da mesma lógica, ou você duplica ou cria um hook que ainda depende de React.
// CORRETO: regra no domínio, componente só exibe
import { calculateOrderTotal } from "@/domain/entities/Order";
function OrderSummary({ items, discountPercent }: Props) {
// Cálculo puro, sem efeito colateral, sem estado
const total = calculateOrderTotal(items, discountPercent);
return <span>Total: R$ {total.toFixed(2)}</span>;
}Erro 2: importar Axios direto no hook
// ERRADO: hook acoplado a biblioteca HTTP específica
import axios from "axios";
export function useOrders() {
async function fetchOrders() {
// Trocar axios por fetch exige mudar TODOS os hooks
const { data } = await axios.get("/api/orders");
return data;
}
// ...
}// CORRETO: hook consome use case que consome interface
import { listOrdersUseCase } from "@/infrastructure/container";
export function useOrders() {
async function fetchOrders() {
return listOrdersUseCase.execute();
}
// ...
}Erro 3: criar 47 pastas para um CRUD de 3 telas
Clean Architecture não significa criar domain/, application/, infrastructure/ e presentation/ quando o projeto tem duas entidades e três páginas. A separação de camadas compensa quando:
- Há regras de negócio que precisam ser testadas independentemente da UI.
- Mais de um dev trabalha no mesmo módulo.
- A fonte de dados pode mudar (REST para GraphQL, API própria para BaaS como Supabase).
Para um formulário de contato com validação simples, um hook com Zod resolve. Não force a arquitetura onde ela não paga o custo.
Quando vale e quando não vale
| Cenário | Clean Architecture vale? | Alternativa |
|---|---|---|
| SaaS com 30+ telas e regras complexas | Sim | -- |
| Dashboard admin com CRUD simples | Parcialmente (domain + hooks) | Hooks diretos com service layer leve |
| Landing page com formulário | Não | Componente com validação inline |
| App com múltiplas fontes de dados | Sim | -- |
| Protótipo/MVP para validar ideia | Não | Tudo no componente, refatore depois |
Testando cada camada isoladamente
A grande vitória da separação aparece nos testes. Domain e Application rodam sem jsdom, sem React Testing Library, sem setup de provider.
// src/domain/entities/__tests__/Order.test.ts
import { describe, it, expect } from "vitest";
import { calculateOrderTotal, canCancelOrder } from "../Order";
import type { Order, OrderItem } from "../Order";
describe("calculateOrderTotal", () => {
const items: OrderItem[] = [
{ productId: "p1", name: "Item A", unitPrice: 100, quantity: 2 },
{ productId: "p2", name: "Item B", unitPrice: 50, quantity: 1 },
];
it("aplica desconto corretamente", () => {
// 250 * 0.9 = 225
expect(calculateOrderTotal(items, 10)).toBe(225);
});
it("limita desconto a 30% mesmo com valor maior", () => {
// 250 * 0.7 = 175, não 250 * 0.5 = 125
expect(calculateOrderTotal(items, 50)).toBe(175);
});
});
describe("canCancelOrder", () => {
it("permite cancelar pedido em draft", () => {
const order = { status: "draft" } as Order;
expect(canCancelOrder(order)).toBe(true);
});
it("bloqueia cancelamento de pedido enviado", () => {
const order = { status: "shipped" } as Order;
expect(canCancelOrder(order)).toBe(false);
});
});Esse teste executa em menos de 50ms. Sem DOM, sem mock de API, sem waitFor. Se a regra de desconto muda, o teste quebra imediatamente e aponta exatamente onde.
Para testar o use case, substitua o repositório por um in-memory:
// src/application/usecases/__tests__/CreateOrderUseCase.test.ts
import { describe, it, expect } from "vitest";
import { CreateOrderUseCase } from "../CreateOrderUseCase";
import type { Order } from "@/domain/entities/Order";
import type { OrderRepository } from "@/application/ports/OrderRepository";
// Repositório fake que guarda dados em memória
class InMemoryOrderRepository implements OrderRepository {
private orders: Order[] = [];
async findById(id: string) {
return this.orders.find((o) => o.id === id) ?? null;
}
async save(order: Order) {
this.orders.push(order);
}
async findByStatus(status: string) {
return this.orders.filter((o) => o.status === status);
}
// Método auxiliar para inspeção nos testes
getAll() {
return this.orders;
}
}
describe("CreateOrderUseCase", () => {
it("cria pedido com status draft", async () => {
const repo = new InMemoryOrderRepository();
const useCase = new CreateOrderUseCase(repo);
const result = await useCase.execute({
items: [{ productId: "p1", name: "Teclado", unitPrice: 300, quantity: 1 }],
couponCode: null,
discountPercent: 0,
});
expect(result.order.status).toBe("draft");
expect(result.total).toBe(300);
expect(repo.getAll()).toHaveLength(1);
});
it("rejeita pedido sem itens", async () => {
const repo = new InMemoryOrderRepository();
const useCase = new CreateOrderUseCase(repo);
await expect(
useCase.execute({ items: [], couponCode: null, discountPercent: 0 })
).rejects.toThrow("Pedido precisa ter pelo menos um item");
});
});Integração com estado global e data fetching
Se você usa TanStack Query ou SWR para cache de servidor, a camada de presentation absorve isso. O use case continua puro:
// src/presentation/hooks/useOrderQuery.ts
import { useQuery } from "@tanstack/react-query";
import { createOrderUseCase } from "@/infrastructure/container";
import type { OrderRepository } from "@/application/ports/OrderRepository";
// O hook de query consome o repositório diretamente para leitura,
// porque TanStack Query já gerencia cache e revalidação
export function useOrderQuery(orderId: string) {
return useQuery({
queryKey: ["order", orderId],
queryFn: () => orderRepository.findById(orderId),
});
}Para mutações, o hook chama o use case e invalida o cache. A regra de negócio continua no domínio. O cache fica na presentation. Cada coisa no seu lugar.
Se o projeto usa Server Components no Next.js, os use cases podem rodar no servidor sem alteração. A camada de domínio não depende de ambiente de execução.
Estrutura de pastas
src/
├── domain/
│ └── entities/
│ ├── Order.ts
│ └── __tests__/
│ └── Order.test.ts
├── application/
│ ├── ports/
│ │ └── OrderRepository.ts
│ └── usecases/
│ ├── CreateOrderUseCase.ts
│ └── __tests__/
│ └── CreateOrderUseCase.test.ts
├── infrastructure/
│ ├── repositories/
│ │ └── HttpOrderRepository.ts
│ └── container.ts
└── presentation/
├── hooks/
│ ├── useCreateOrder.ts
│ └── useOrderQuery.ts
└── pages/
└── CreateOrderPage.tsxEssa estrutura não é dogma. Se o projeto usa monorepo com Turborepo, domain e application podem virar pacotes internos compartilhados entre web e mobile. O ponto é que a direção das dependências nunca inverte.
FAQ
Clean Architecture no frontend não é overengineering?
Depende do tamanho do projeto. Para um MVP com 5 telas e um dev, sim, é overhead desnecessário. Para um produto com regras de negócio complexas (cálculos financeiros, workflows com múltiplos estados, integrações com APIs externas), a separação se paga na primeira vez que você precisa trocar uma dependência ou testar lógica sem subir o DOM.
Preciso usar classes e injeção de dependência no React?
Não precisa usar classes em tudo. O domínio pode ser funções puras. Use cases como classes com construtor facilitam a injeção, mas funções que recebem o repositório como argumento funcionam igualmente. O que importa é o contrato (interface), não o mecanismo.
Como lidar com validação de formulário nessa arquitetura?
Validação de formato (email válido, campo obrigatório) fica na presentation com Zod ou Yup. Validação de regra de negócio (desconto máximo, quantidade mínima por pedido) fica no domínio. Se a validação responde "esse dado está no formato certo?", é presentation. Se responde "esse dado faz sentido no contexto do negócio?", é domínio.
Essa abordagem funciona com Next.js App Router?
Funciona. Server Components podem importar use cases diretamente (rodam no servidor). Client Components consomem via hooks como mostrado. A camada de domínio é agnóstica de ambiente, então funciona tanto no App Router quanto em SPA pura com Vite.
Como organizar quando tenho muitas entidades?
Agrupe por domínio de negócio, não por tipo de arquivo. Em vez de entities/Order.ts, entities/Product.ts, entities/User.ts todos na mesma pasta, considere domain/orders/, domain/products/, domain/auth/. Cada módulo de domínio com suas entidades, regras e testes. A aplicação segue a mesma divisão.
A posição que defendo
Clean Architecture no frontend React não é sobre seguir o diagrama de círculos do Uncle Bob ao pé da letra. É sobre uma decisão prática: regra de negócio não deve depender de framework de UI. Se calculateOrderTotal precisa de import React, algo deu errado.
A separação custa mais linhas de código e mais arquivos. Esse custo se justifica quando o projeto tem regras que mudam independentemente da interface, quando a equipe precisa testar lógica sem montar DOM, ou quando a fonte de dados pode trocar. Fora desses cenários, um hook bem escrito com uma service layer leve resolve, e forçar quatro camadas só adiciona indireção sem benefício.
A pergunta certa não é "devo usar Clean Architecture?". É "minha regra de negócio sobrevive se eu trocar React por outro framework amanhã?". Se a resposta é não e isso te incomoda, separe as camadas. Se a resposta é não e o projeto vai morrer antes de trocar de framework, gaste energia em outra coisa.

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
Guias de integração relacionados
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.


