Onde SOLID, Clean Architecture e padrões de domínio valem no frontend, onde viram overhead, e como reconhecer o ponto em que algo deixa de ser overengineering.
A arquitetura certa pro frontend não vem do backend. Vem do produto, do tamanho do time, e de quantas perguntas você pode adiar com honestidade. SOLID e Clean Architecture têm partes que valem; o resto vira ritual, e ritual no front custa caro porque cada camada que você adiciona aparece no bundle do usuário.
Esse texto é o que eu queria ter lido cinco anos atrás. Não é manifesto contra padrão. É um exercício de quando aplicar, quando não aplicar, e como reconhecer o ponto exato em que algo deixa de ser overengineering.
System design no frontend não é design system
A confusão começa no vocabulário. "System design" virou sinônimo de "biblioteca de componentes" em muito time de produto. Não é. System design no frontend é a mesma disciplina do backend, com um conjunto de perguntas diferentes:
- Onde a fronteira entre módulos cai?
- Como o dado flui (server → cliente → URL → cache)?
- Qual o modelo de erro?
- Qual o contrato com o backend, e quem é dono dele?
- Qual estratégia de renderização (SPA, SSR, SSG, RSC)?
- Como você publica?
Design system, por outro lado, é uma das saídas do system design: as primitivas visuais (botão, input, modal). Útil, mas é a camada mais superficial. Quando alguém diz "vamos fazer system design" e abre Figma, você sabe que vai sair de lá com Button e Card, não com decisões sobre cache.
A primeira responsabilidade do arquiteto frontend é separar essas duas conversas. Sem isso, todo trade-off vira opinião de design e nada de estrutura sai do papel.
As cinco perguntas antes de decidir qualquer coisa
Antes de escolher framework, padrão, ou organização de pasta, eu passo por cinco perguntas. Em ordem.
Quem consome esse frontend? Site de marketing (foco em SEO e LCP), interface interna (foco em produtividade do user, zero SEO), app voltado pra cliente (foco em UX rica e perf de runtime), ou ferramenta especializada (canvas, IDE, planilha) onde a complexidade do domínio importa mais que a complexidade da UX? Cada resposta abre arquiteturas diferentes.
Quantos engenheiros vão mexer simultaneamente? Um arquiteto sozinho otimiza por velocidade e descartabilidade. Cinco pessoas em rotação otimizam por contratos explícitos. Cinquenta pessoas em squads independentes otimizam por isolamento total (module federation, micro-frontends). Não é o mesmo problema.
Qual a vida útil esperada? Uma campanha de Natal vive três meses. Um produto SaaS vive cinco anos. As decisões que valem em três meses (zero teste, zero abstração, código descartável) são suicidas em cinco anos.
Onde está o complexo: domínio ou UX? CRUD com forms tem complexidade de UX (validação, estados, microcopy) mas domínio raso. Editor de canvas tem o oposto. SOLID e Clean Architecture nascem do domínio rico; aplicá-los num CRUD é overhead garantido.
Quanto controle do tempo de carga importa? Se LCP de 1s é diferencial competitivo (sites de mídia, e-commerce), você está num jogo de gramas e arquitetura limita ou libera ganhos. Se LCP de 3s é aceitável (dashboard interno), você pode escolher conforto sobre performance sem culpa.
Eu não conheço time que tenha tempo de fazer arquitetura considerada. O que dá pra fazer é responder essas cinco em quinze minutos antes de digitar a primeira linha. Esses quinze minutos pagam meses.
SOLID aplicado ao frontend: o que sobrevive
SOLID veio de Bob Martin, contexto OOP backend dos anos 90. Cada letra reaparece no frontend com vigor diferente.
Single Responsibility. Vale sempre, mas o conceito de "responsabilidade" no front é mais fluido. Um componente carrega estado + estilo + comportamento + acessibilidade. "Um motivo pra mudar" no frontend traduz melhor pra "uma audiência pra agradar". Um Button que precisa atender designer (visual), product manager (analytics), engenheiro (a11y) e legal (terms) tem quatro audiências e por isso quatro motivos pra mudar. Saudável aceitar isso e desenhar as props de fato pra cada audiência.
Open/Closed. Componentes "abertos pra extensão" via API explícita é bom. "Abertos" via oito props booleanas é o que Sandi Metz chamou de the wrong abstraction: você passou tanto da hora de extrair que extrair ainda dói. Heurística: se a soma de props booleanas excede cinco, o componente quer ser dois.
Liskov Substitution. Quase não aplicável no JSX/HTML por si só. Aparece em design de variantes (<Button variant="primary"> vs <Button variant="ghost"> precisam responder ao mesmo conjunto de event handlers e props básicas). Não é o foco da disciplina no front.
Interface Segregation. Vale forte. Props "kitchen sink" (vinte props onde só usa três) é o anti-pattern mais comum em UI library. Componentes pequenos com props especializadas envelhecem melhor que componentes "genéricos". Tailwind levou esse princípio ao extremo: Adam Wathan defendeu que CSS reutilizável e HTML reutilizável são objetivos opostos, e a escolha está em qual dependência você quer.
Dependency Inversion. Aqui cai 80% do cargo cult. No backend, inverter dependência tem custo controlado: você cria interface, plugia adapter no startup, ganha testabilidade e flexibilidade pra trocar transporte. No frontend, o "transporte" raramente muda (você não substitui REST por gRPC numa SPA na quinta-feira). Abstrair fetch por trás de interface UserRepository em React é, em 90% dos casos, ritual.
Resumindo: SOLID tem cinco letras, três valem direto (S, O, I), uma se traduz com cuidado (L em variants), e uma é cilada (D em quase tudo). Aceitar isso libera tempo pra arquitetar de verdade.
Clean Architecture: o que pegar e o que doer aceitar largar
Clean Architecture do Bob Martin é um framework geral, não receita pronta. As partes que sobrevivem em apps React modernos:
Use cases / interactors. No backend viram serviços. No frontend, viram custom hooks que retornam ações:
function useCheckoutSubmission() {
const [state, setState] = useState<'idle' | 'submitting' | 'done' | 'error'>('idle')
const submit = useCallback(async (cart: Cart) => {
setState('submitting')
try {
await api.checkout(cart)
setState('done')
} catch {
setState('error')
}
}, [])
return { state, submit }
}
Isso é use case. Concentra a regra do fluxo num lugar, expõe ações nomeadas, mantém o componente burro. Vale.
Entities + Value Objects. Vale se você tem domínio rico. Editor de canvas precisa de Shape, Layer, Selection como entidades com comportamento. Planilha precisa de Cell, Formula, Range. CRUD não. Pra um app que mostra lista de produtos e formulário de pedido, criar classes pra Product e Order quando o backend já devolve JSON é peso morto.
Repositories abstraindo HTTP. Em 99% dos casos, é camada por ritual. Você não troca fetch por axios em um sprint; quando troca, é refactor global de qualquer jeito. A camada não te protege do que ela diz proteger.
A exceção que confirma a regra: apps que precisam funcionar offline com fila de sincronização (PWA serio) genuinamente se beneficiam de repository, porque o "transporte" varia entre HTTP, IndexedDB e SyncManager. Aí vale.
Dependency rule (UI não importa de infra, infra não importa de domain): vale como heurística, não religião. No front, quase sempre vira "components não importam de fetch directly". Cumprir com hooks resolve sem precisar de quatro pastas e arquitetura hexagonal.
Conclusão: pegar de Clean Architecture é selecionar use cases (custom hooks com ações) e a heurística "código que muda junto, fica junto". O resto, na maioria dos apps, é peso.
Sete sinais concretos de overengineering
Lista que aplico em code review. Cada item é vermelho até prova em contrário.
- Três ou mais camadas pra renderizar uma lista de produtos.
Container → ListProvider → Repository → Service → Component. Se uma chamada GET vira cinco arquivos, alguém perdeu o objetivo.
- Custom hook envelopando
useState sem agregar valor. useToggle, useCounter, useBoolean. Em 1 linha de useState, custom hook é distração.
- Repository pattern num app que só fala com uma API. Camada que não protege de mudança real é decoração.
- Container/Presentational forçado em 2026. A separação que fazia sentido antes de hooks já está nos hooks. Forçar de novo é nostalgia.
- Folder structure com oito pastas antes da primeira feature.
domain/, application/, infrastructure/, presentation/, shared/, core/, lib/, utils/. Sem código, é taxonomia.
- State management global pra dado que só uma tela usa. Redux/Zustand pra carrinho de compras é defensável; pra
isModalOpen de um modal específico é overhead.
- Componente "genérico" com doze props booleanas.
<Button compact loading disabled primary ghost full-width icon-only no-padding rounded uppercase tabular>. É polvo com bandeira branca, citando Kent C. Dodds em AHA Programming.
Heurística geral por trás disso, citando Sandi Metz: é mais fácil sair de duplicação pra abstração do que de abstração errada pra abstração certa. Espere os padrões aparecerem três ou quatro vezes antes de extrair. Repetição visível ensina mais que abstração precoce.
Quando overengineering deixa de ser overengineering
A mesma estrutura que é overhead pra um app de cinco pessoas vira oxigênio pra um de cinquenta. Os gatilhos:
- Cinco ou mais engenheiros mexendo simultaneamente. A partir daí, contratos explícitos pagam a sobrecarga. Sem eles, todo merge vira teatro.
- Cinquenta mil ou mais linhas de código. O cérebro humano não segura mais que isso na cabeça toda. Camadas ajudam a navegar.
- Domínio genuinamente complexo. Editores, simuladores, IDEs, plataformas multi-tenant. Esses apps são complicados pela natureza do problema, não por opção arquitetural.
- Churn alto de pessoa. Quando um dev fica seis meses em média, camadas viram contrato de onboarding. O custo de explicar
useState vs useReducer pra alguém que vai embora em três meses é maior que o custo de ter um padrão obrigatório.
- Produto distribuído (multi-cliente, white label, ou multi-marca dentro da mesma empresa). Aí inversão de dependência paga: cada cliente vira um adapter.
A heurística inversa que aplico, vinda de Dan McKinley: você tem inovation tokens limitados. Cada padrão "moderno" que você adota antes de precisar gasta um token. Se você gastou todos antes da primeira feature complexa, não sobra pra quando a feature complexa chegar.
Um framework simples de system design no frontend
Quando vou desenhar um app do zero, passo por oito eixos. Cada um tem o "ruim, bom, melhor" relativo ao contexto.
Domínio. Identifique as capacidades de negócio (não as entidades). "Pagamento", "catálogo", "busca" são capacidades. "User", "Order" são entidades. Capacidade dita fronteira; entidade só dita schema.
Fronteiras. Pastas por capacidade, não por camada. Tudo que muda quando "pagamento" muda fica junto: rota, hook, API call, componente, teste:
src/features/
checkout/
api.ts
use-checkout.ts
checkout-page.tsx
checkout-summary.tsx
checkout.test.ts
catalog/
...
vs a versão ruim, separada por camada:
src/
components/
checkout/
CheckoutPage.tsx
hooks/
useCheckout.ts
api/
checkout.ts
tests/
checkout.test.ts
A segunda obriga abrir quatro pastas pra mexer numa feature. A primeira mantém o trabalho local.
Estado. Três tipos, três tratamentos:
// Server state — TanStack Query, SWR, fetch loaders
const { data: user } = useQuery(['user', id], () => fetchUser(id))
// URL state — search params (filtros, paginação, tab ativa)
const [params, setParams] = useSearchParams()
const tab = params.get('tab') ?? 'overview'
// UI state — useState local quando só essa árvore precisa
const [isOpen, setOpen] = useState(false)
Confundir os três é a fonte clássica de bug e re-render. Estado de servidor em useState força refetch manual. Estado de URL em useState quebra back button. Estado de UI em store global é overkill.
Rotas. Locale como prefixo + slugs declarados uma vez, não espalhados pelo app:
// Ruim — rotas hardcoded em vários lugares, locale escapando aqui e ali
<Link to="/checkout">Checkout</Link>
<Link to={`/${locale}/orders`}>Pedidos</Link>
navigate('/orders/' + orderId)
// Bom — tabela de rotas como única fonte, locale sempre aplicado
const routes = {
checkout: (locale: Locale) => `/${locale}/checkout`,
orderDetail: (locale: Locale, id: string) =>
`/${locale}/orders/${id}`,
} as const
<Link to={routes.checkout(locale)}>Checkout</Link>
<Link to={routes.orderDetail(locale, order.id)}>...</Link>
URLs traduzidas (Pattern B: /pt/artigos/... vs /en/articles/...) ajudam SEO em mercados não-anglos mas exigem um resolver que traduz slug entre locales. Pra app interno ou produto B2B, slug compartilhado (Pattern A) basta.
Erro. Boundary por feature, não global. Um <ErrorBoundary> no topo derruba o site inteiro quando algo quebra no checkout. Um por feature mantém o resto do app vivo:
// Ruim — boundary global captura tudo, perde contexto
<ErrorBoundary fallback={<GenericError />}>
<App />
</ErrorBoundary>
// Bom — cada feature tem seu próprio fallback e mensagem
<Routes>
<Route
path="/checkout"
element={
<ErrorBoundary fallback={<CheckoutError />}>
<CheckoutPage />
</ErrorBoundary>
}
/>
<Route
path="/catalog"
element={
<ErrorBoundary fallback={<CatalogError />}>
<CatalogPage />
</ErrorBoundary>
}
/>
</Routes>
O global ainda pode existir como rede de segurança última, mas nunca como única defesa. Quando você fala "não conseguimos processar agora" pro usuário do checkout, é porque o checkout falhou. Não o site. O fallback genérico no topo perde essa nuance e queima credibilidade.
Performance. Code-split por rota é grátis (já vem do lazy()). Vendor split por chunk é onde a maioria esquece:
// Ruim — tudo no entry, primeira página baixa o bundle inteiro
import EditorCanvas from './features/editor/EditorCanvas' // só usado em /editor
import DashboardCharts from './features/dashboard/Charts' // só usado em /dashboard
export const router = createBrowserRouter([
{ path: '/', element: <Home /> },
{ path: '/editor', element: <EditorCanvas /> },
{ path: '/dashboard', element: <DashboardCharts /> },
])
// Bom — code-split por rota + vendor chunks separados no Vite
const EditorCanvas = lazy(() => import('./features/editor/EditorCanvas'))
const DashboardCharts = lazy(() => import('./features/dashboard/Charts'))
// vite.config.ts
build: {
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('react-router')) return 'vendor-router'
if (id.includes('i18next')) return 'vendor-i18n'
if (id.includes('react-helmet-async')) return 'vendor-helmet'
},
},
},
}
Cada vendor que muda em ritmo diferente do seu código deve ser chunk separado. Browser cacheia long, primeira visita dói uma vez, próximas voam.
Renderização. SSR/RSC custa servidor + complexidade. Pre-render por rota cobre o caso comum:
// Ruim — SSR religioso pra conteúdo essencialmente estático
// (Next.js / Remix com Node em produção, ISR, headers de cache afinados...)
// Pra portfolio, blog técnico, marketing site: peso morto.
// Bom — pre-render por rota
// Crawler vê HTML pronto, usuário recebe o mesmo HTML, React hidrata por cima.
dist/pt/index.html
dist/pt/writing/<slug>/index.html
dist/pt/projects/<slug>/index.html
// Cada um com <title>, meta description, hreflang, JSON-LD já no HTML estático.
SSR/RSC ganha quando o conteúdo MUDA por requisição (feed pessoal, multi-tenant com branding, dashboard com dado quente). Pra conteúdo conhecido em build, pre-render entrega o mesmo SEO sem servidor.
Deploy. Bucket estático ou edge é o default razoável. Server real só quando precisa:
# Ruim — Docker + Node + nginx pra servir HTML estático
docker build -t app . && docker run -p 80:80 app
# + provisionamento, CI pesado, alertas, escalonamento, patches do SO...
# Bom — edge/bucket como default
pnpm build && vercel deploy --prod
# ou: netlify deploy --prod, ou: cloudflare pages deploy, ou: aws s3 sync + cloudfront
# Custo: cents/mês pra tráfego médio. CDN global incluso. SSL automático.
Server real (Node, Bun, edge functions) entra quando você precisa de SSR genuíno, API próxima do edge, ou compliance que exige infraestrutura própria. Comece pelo barato, suba quando a feature exigir.
A heurística que aplico antes de QUALQUER nova camada
Antes de adicionar dependência, abstração, hook, serviço, contexto, store — pergunto uma coisa:
Isso me dá mais coisa pra cuidar, ou menos?
Se a resposta é "mais", precisa pagar por si: ganho de testabilidade real, redução de duplicação que ardia, ou ganho de perf mensurável. Vale o trade.
Se a resposta é "menos" (verdadeiramente menos, não "menos no curto prazo"), provavelmente vale.
A maior parte das decisões ruins de arquitetura frontend que vi não foram por falta de padrão. Foram por excesso dele.
O melhor arquiteto frontend é o que sabe quando não aplicar
A imagem que me ajuda: arquitetura no frontend é discípulo do KISS, não do SOLID estrito. SOLID é caixa de ferramentas, e como toda caixa, tem ferramenta que você não usa em décadas.
Aplico um teste depois de seis meses: abro o repositório e tento mexer em algo que não tocava há tempo. Se dói, alguma fronteira drenou (negócio mudou, desenho não acompanhou). Refatorar fronteira é manutenção normal. Se não dói, o desenho fez seu trabalho.
Você não saberá se a sua arquitetura é boa hoje. Saberá em seis meses, quando voltar pra mexer.
Para continuar lendo
Os textos que me formaram a opinião acima.
Enable JavaScript for the full experience.