Clean Architecture no Frontend React: Separando Concerns de Verdade
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/ent
---
Leia o artigo completo em [https://www.vivodecodigo.com.br/react/clean-architecture-frontend-react-separando-concerns](https://www.vivodecodigo.com.br/react/clean-architecture-frontend-react-separando-concerns)