Ir para o conteúdo
React

Clean Architecture no Frontend React: Separando Concerns de Verdade

Marcos Soares
Atualizado em 
14 minutos de leitura
Ilustracao 3D de cubo cristalino em camadas separadas com nucleo luminoso representando Clean Architecture no React
Ouça este artigo
0:00Clean 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:

CamadaResponsabilidadeDepende deExemplo concreto
DomainEntidades, value objects, regras purasNadacalculateOrderTotal, OrderStatus
ApplicationUse cases, orquestração de regrasDomainCreateOrderUseCase, ApplyDiscountUseCase
InfrastructureHTTP clients, storage, APIs externasApplication (via interface)HttpOrderRepository, LocalStorageCartGateway
PresentationComponentes React, hooks, páginasApplication (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

TypeScript
// 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.

TypeScript
// 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[]>;
}
TypeScript
// 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

TypeScript
// 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.

TypeScript
// 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:

TypeScript
// 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:

TypeScript
// 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

TypeScript
// 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.

TypeScript
// 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

TypeScript
// 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;
  }
  // ...
}
TypeScript
// 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árioClean Architecture vale?Alternativa
SaaS com 30+ telas e regras complexasSim--
Dashboard admin com CRUD simplesParcialmente (domain + hooks)Hooks diretos com service layer leve
Landing page com formulárioNãoComponente com validação inline
App com múltiplas fontes de dadosSim--
Protótipo/MVP para validar ideiaNãoTudo 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.

TypeScript
// 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:

TypeScript
// 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:

TypeScript
// 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

Text
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.tsx

Essa 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.

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

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.