Where SOLID, Clean Architecture and domain patterns earn their keep on the front, where they become overhead, and how to spot the point where something stops being overengineering.
The right frontend architecture does not come from the backend. It comes from the product, the team size, and how many questions you can honestly defer. SOLID and Clean Architecture have parts that pay off; the rest becomes ritual, and ritual on the front is expensive because every layer you add ships in the user's bundle.
This is what I wish I had read five years ago. It is not a manifesto against patterns. It is an exercise in when to apply them, when not to, and how to recognize the exact point where something stops being overengineering.
System design on the front is not design system
The confusion starts with vocabulary. "System design" has become a synonym for "component library" in many product teams. It is not. System design on the frontend is the same discipline as on the backend, with a different set of questions:
- Where do module boundaries fall?
- How does data flow (server → client → URL → cache)?
- What is the error model?
- What is the contract with the backend, and who owns it?
- What rendering strategy (SPA, SSR, SSG, RSC)?
- How do you publish?
Design system, on the other hand, is one of the outputs of system design: the visual primitives (button, input, modal). Useful, but the shallowest layer. When someone says "let's do system design" and opens Figma, you know you will leave with Button and Card, not with caching decisions.
The first job of the frontend architect is to separate those two conversations. Without that, every trade-off becomes a design opinion and no structure ever ships.
Five questions before deciding anything
Before choosing a framework, a pattern, or a folder layout, I run through five questions. In order.
Who consumes this frontend? Marketing site (SEO and LCP focus), internal tool (productivity focus, zero SEO), customer-facing app (rich UX and runtime perf focus), or specialized tool (canvas, IDE, spreadsheet) where the complexity of the domain matters more than the UX? Each answer opens up different architectures.
How many engineers will work on it simultaneously? A solo architect optimizes for speed and disposability. Five people in rotation optimize for explicit contracts. Fifty people in independent squads optimize for total isolation (module federation, micro-frontends). It is not the same problem.
What is the expected lifespan? A Christmas campaign lives three months. A SaaS product lives five years. The decisions that hold for three months (zero tests, zero abstraction, throwaway code) are suicide at five years.
Where is the complex part: domain or UX? A CRUD with forms has UX complexity (validation, states, microcopy) but shallow domain. A canvas editor has the opposite. SOLID and Clean Architecture were born for rich domains; applying them to a CRUD is guaranteed overhead.
How much does load-time control matter? If 1s LCP is a competitive edge (news sites, e-commerce), you are in a grams game and architecture caps or unlocks gains. If 3s LCP is acceptable (internal dashboard), you can pick comfort over performance without guilt.
I do not know teams with time for thoughtful architecture. What you can do is answer these five in fifteen minutes before typing the first line. Those fifteen minutes pay for months.
SOLID applied to the front: what survives
SOLID came from Bob Martin, an OOP backend context from the 90s. Each letter reappears on the front with different force.
Single Responsibility. Holds always, but "responsibility" on the front is more fluid. A component carries state plus styling plus behavior plus accessibility. "One reason to change" on the front translates better to "one audience to please". A Button that has to serve designer (visual), product manager (analytics), engineer (a11y), and legal (terms) has four audiences and therefore four reasons to change. Healthy to accept that and shape the props for each audience.
Open/Closed. Components "open for extension" via an explicit API is good. "Open" via eight boolean props is what Sandi Metz called the wrong abstraction: you waited so long to extract that extracting still hurts. Heuristic: if the boolean prop count exceeds five, the component wants to be two.
Liskov Substitution. Barely applicable to JSX/HTML on its own. Shows up in variant design (<Button variant="primary"> vs <Button variant="ghost"> must respond to the same set of event handlers and base props). Not the focus of the discipline on the front.
Interface Segregation. Holds strongly. Kitchen-sink props (twenty props where you use three) is the most common anti-pattern in UI libraries. Small components with specialized props age better than "generic" ones. Tailwind took this principle to the extreme: Adam Wathan argued that reusable CSS and reusable HTML are opposite goals, and the choice is about which dependency you want.
Dependency Inversion. Here is where 80% of the cargo cult lands. On the backend, inverting dependencies has controlled cost: you create an interface, plug in an adapter at startup, get testability and flexibility to swap transports. On the frontend, the "transport" rarely changes (you do not swap REST for gRPC in an SPA on a Thursday). Abstracting fetch behind interface UserRepository in React is, in 90% of cases, ritual.
Bottom line: SOLID has five letters, three hold directly (S, O, I), one translates carefully (L in variants), and one is a trap (D in nearly everything). Accepting that frees time to actually architect.
Clean Architecture: what to keep and what to let go
Bob Martin's Clean Architecture is a general framework, not a ready-made recipe. The parts that survive in modern React apps:
Use cases / interactors. On the backend they become services. On the frontend they become custom hooks that return actions:
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 }
}
That is a use case. Centralizes the flow rule in one place, exposes named actions, keeps the component dumb. Holds.
Entities + Value Objects. Hold if you have a rich domain. A canvas editor needs Shape, Layer, Selection as entities with behavior. A spreadsheet needs Cell, Formula, Range. A CRUD does not. For an app that shows a product list and an order form, creating classes for Product and Order when the backend already returns JSON is dead weight.
Repositories abstracting HTTP. In 99% of cases, a ritual layer. You do not swap fetch for axios in a sprint; when you do, it is a global refactor anyway. The layer does not shield you from what it claims to shield you from.
The exception that proves the rule: apps that need to work offline with sync queues (serious PWA) genuinely benefit from a repository, because the "transport" varies among HTTP, IndexedDB, and SyncManager. There, it earns its keep.
Dependency rule (UI does not import from infra, infra does not import from domain): hold as heuristic, not religion. On the front it almost always means "components do not import fetch directly". Honoring it with hooks solves the problem without four folders and hexagonal architecture.
Conclusion: from Clean Architecture, pick use cases (custom hooks with actions) and the heuristic "code that changes together stays together". The rest, in most apps, is weight.
Seven concrete signs of overengineering
A list I apply in code review. Each one is red until proven otherwise.
- Three or more layers to render a product list.
Container → ListProvider → Repository → Service → Component. If one GET call becomes five files, someone lost sight of the goal.
- Custom hook wrapping
useState with no added value. useToggle, useCounter, useBoolean. Over a one-line useState, a custom hook is a distraction.
- Repository pattern in an app that only talks to one API. A layer that does not protect against real change is decoration.
- Container/Presentational forced in 2026. The separation that made sense before hooks is already inside hooks. Forcing it again is nostalgia.
- Folder structure with eight folders before the first feature.
domain/, application/, infrastructure/, presentation/, shared/, core/, lib/, utils/. Without code, it is taxonomy.
- Global state management for data only one screen uses. Redux/Zustand for shopping cart is defensible; for
isModalOpen of a specific modal it is overhead.
- "Generic" component with twelve boolean props.
<Button compact loading disabled primary ghost full-width icon-only no-padding rounded uppercase tabular>. An octopus with a white flag, in Kent C. Dodds's words on AHA Programming.
General heuristic behind these, citing Sandi Metz: it is easier to go from duplication to abstraction than from a wrong abstraction to the right one. Wait for patterns to appear three or four times before extracting. Visible repetition teaches more than premature abstraction.
When overengineering stops being overengineering
The same structure that is overhead for a five-person app becomes oxygen for a fifty-person one. The triggers:
- Five or more engineers touching it simultaneously. From there, explicit contracts pay for the overhead. Without them, every merge is theater.
- Fifty thousand lines of code or more. The human brain cannot hold more than that whole. Layers help navigate.
- Genuinely complex domain. Editors, simulators, IDEs, multi-tenant platforms. Those apps are complicated by the nature of the problem, not by architectural choice.
- High people churn. When a dev stays six months on average, layers become onboarding contracts. The cost of explaining
useState vs useReducer to someone who leaves in three months is greater than the cost of having a mandatory pattern.
- Distributed product (multi-tenant, white label, or multi-brand within the same company). Then dependency inversion pays: each client becomes an adapter.
The inverse heuristic I apply, from Dan McKinley: you have a limited supply of innovation tokens. Every "modern" pattern you adopt before you need it spends a token. If you spent them all before the first complex feature, you have nothing left when the complex feature arrives.
A simple framework for frontend system design
When I design an app from scratch, I walk through eight axes. Each one has its "bad, good, better" relative to context.
Domain. Identify business capabilities (not entities). "Payment", "catalog", "search" are capabilities. "User", "Order" are entities. Capability dictates boundaries; entity only dictates schema.
Boundaries. Folders by capability, not by layer. Anything that changes when "payment" changes stays together: route, hook, API call, component, test:
src/features/
checkout/
api.ts
use-checkout.ts
checkout-page.tsx
checkout-summary.tsx
checkout.test.ts
catalog/
...
vs the bad version, split by layer:
src/
components/
checkout/
CheckoutPage.tsx
hooks/
useCheckout.ts
api/
checkout.ts
tests/
checkout.test.ts
The second forces opening four folders to change one feature. The first keeps the work local.
State. Three types, three treatments:
// Server state — TanStack Query, SWR, fetch loaders
const { data: user } = useQuery(['user', id], () => fetchUser(id))
// URL state — search params (filters, pagination, active tab)
const [params, setParams] = useSearchParams()
const tab = params.get('tab') ?? 'overview'
// UI state — local useState when only this subtree needs it
const [isOpen, setOpen] = useState(false)
Confusing the three is the classic bug and re-render source. Server state in useState forces manual refetch. URL state in useState breaks the back button. UI state in a global store is overkill.
Routes. Locale as prefix + slugs declared once, not scattered across the app:
// Bad — routes hardcoded everywhere, locale leaking here and there
<Link to="/checkout">Checkout</Link>
<Link to={`/${locale}/orders`}>Orders</Link>
navigate('/orders/' + orderId)
// Good — routes table as single source, locale always applied
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>
Translated URLs (Pattern B: /pt/artigos/... vs /en/articles/...) help SEO in non-English markets but require a resolver that translates slugs across locales. For internal apps or B2B products, a shared slug (Pattern A) is enough.
Error. Boundary per feature, not global. A top-level <ErrorBoundary> tears the whole site down when something breaks in checkout. One per feature keeps the rest of the app alive:
// Bad — a global boundary catches everything and loses context
<ErrorBoundary fallback={<GenericError />}>
<App />
</ErrorBoundary>
// Good — each feature has its own fallback and message
<Routes>
<Route
path="/checkout"
element={
<ErrorBoundary fallback={<CheckoutError />}>
<CheckoutPage />
</ErrorBoundary>
}
/>
<Route
path="/catalog"
element={
<ErrorBoundary fallback={<CatalogError />}>
<CatalogPage />
</ErrorBoundary>
}
/>
</Routes>
A global one can still exist as a last-resort safety net, never as the only defense. When you tell the checkout user "we could not process this right now", it is because checkout failed. Not the site. The generic top-level fallback erases that nuance and burns credibility.
Performance. Code-split per route is free (already comes from lazy()). Vendor split per chunk is where most people forget:
// Bad — everything in the entry, first page downloads the whole bundle
import EditorCanvas from './features/editor/EditorCanvas' // only used on /editor
import DashboardCharts from './features/dashboard/Charts' // only used on /dashboard
export const router = createBrowserRouter([
{ path: '/', element: <Home /> },
{ path: '/editor', element: <EditorCanvas /> },
{ path: '/dashboard', element: <DashboardCharts /> },
])
// Good — route-level code-split + separate vendor chunks in 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'
},
},
},
}
Each vendor that changes on a different cadence than your code should be its own chunk. The browser caches long; first visit pays once, the rest are free.
Rendering. SSR/RSC costs a server plus complexity. Pre-render per route covers the common case:
// Bad — religious SSR for essentially static content
// (Next.js / Remix with Node in production, ISR, hand-tuned cache headers...)
// For a portfolio, technical blog, marketing site: dead weight.
// Good — pre-render per route
// Crawler sees ready HTML, user gets the same HTML, React hydrates on top.
dist/pt/index.html
dist/pt/writing/<slug>/index.html
dist/pt/projects/<slug>/index.html
// Each one with <title>, meta description, hreflang, JSON-LD already in static HTML.
SSR/RSC wins when content CHANGES per request (personal feed, multi-tenant with branding, dashboard with live data). For content known at build time, pre-render delivers the same SEO without a server.
Deploy. Static bucket or edge is the reasonable default. Real server only when you need it:
# Bad — Docker + Node + nginx to serve static HTML
docker build -t app . && docker run -p 80:80 app
# plus provisioning, heavy CI, alerts, scaling, OS patches...
# Good — edge/bucket as the default
pnpm build && vercel deploy --prod
# or: netlify deploy --prod, or: cloudflare pages deploy, or: aws s3 sync + cloudfront
# Cost: cents/month for average traffic. Global CDN included. Automatic SSL.
A real server (Node, Bun, edge functions) shows up when you need genuine SSR, an API close to the edge, or compliance that requires owning the infra. Start cheap; level up when the feature actually demands it.
The heuristic I apply before ANY new layer
Before adding a dependency, abstraction, hook, service, context, store — I ask one thing:
Does this give me more to maintain, or less?
If the answer is "more", it has to pay for itself: real testability gain, reduction of duplication that hurt, or measurable perf gain. Worth the trade.
If the answer is "less" (truly less, not "less in the short term"), it is probably worth it.
Most of the bad frontend architecture decisions I have seen were not from lack of pattern. They were from excess of it.
The best frontend architect is the one who knows when not to apply
The mental image that helps: frontend architecture is a disciple of KISS, not of strict SOLID. SOLID is a toolbox, and like any toolbox, it has tools you do not use for decades.
I apply a test after six months: I open the repo and try to change something I have not touched in a while. If it hurts, some boundary drifted (the business changed, the design did not follow). Refactoring a boundary is normal maintenance. If it does not hurt, the design did its job.
You will not know if your architecture is good today. You will know in six months, when you come back to change it.
Further reading
The texts that shaped the opinion above.
Enable JavaScript for the full experience.