Frontend Architecture Patterns: A Field Guide with Real-World War Stories

// 2026-05-13// engineering// 18 min read
// views

When developers hear “architecture,” most of them picture a file tree. Folders named components/, utils/, hooks/, services/. That's organization, not architecture. Architecture is about how the layers of your app talk to each other — which layer knows about which, where data flows, and what happens when you need to change something six months from now.

You can rename a folder in five minutes. You can't swap an architecture without rewriting the app. File structure is a reversible decision. Architecture is a one-way door. That asymmetry is why so many teams get architecture wrong — they pick a pattern based on what's popular rather than what fits their constraints, and by the time they realize the mismatch, the codebase is too large to migrate.

MVC — The Grandfather

Model–View–Controller was introduced in 1979 at Xerox PARC. Nearly half a century old, and it was the first pattern most frontend developers encountered when SPAs arrived — because Backbone.js, Ember.js, and Angular 1.x all brought it to the browser.

Three layers. Model holds data and business logic. View renders UI. Controller sits between them, handling user input and coordinating updates.

MVCModel–View–Controller · 1979
The grandfather. Three layers, clear roles, one big controller.
observer coupling →user inputupdatesnotifiesrendersModelViewController
Click any layer to see details

That dashed red arrow from Model to View is the seed of every problem MVC has at scale. It's the observer pattern — the View subscribes to Model changes and updates itself automatically. Sounds elegant, but it means the View isn't purely passive. It contains observation logic, which tangles UI rendering with data awareness. Keep that red arrow in mind as you read about the patterns that came after. Every one of them exists partly to eliminate it.

Here's what MVC looks like in practice — a Backbone-style component for a product listing:

TS
1// Model — syncs with server, fires change events
2const Product = Backbone.Model.extend({
3 urlRoot: '/api/products',
4 defaults: { title: '', price: 0, inStock: true }
5});
6
7// View — observes Model, renders on change
8const ProductView = Backbone.View.extend({
9 initialize() {
10 this.listenTo(this.model, 'change', this.render);
11 },
12 events: { 'click .buy-btn': 'handleBuy' },
13 handleBuy() { this.model.set('inCart', true); },
14 render() {
15 this.$el.html(this.template(this.model.toJSON()));
16 return this;
17 }
18});

Clean, focused, easy to follow. One model, one view, one responsibility each. This is MVC at its best — the reason Backbone apps shipped fast. Now watch what happens as the product grows.

The God Component Problem

MVC's Achilles heel isn't the pattern itself — it's what happens to components over time. Every new feature tends to land in the component because “it needs access to the data and the UI, so it must go here.” Auth checks? Component. Search state? Component. WebSocket management? Component. Analytics tracking? Component.

Add features below and watch the component grow. Pay attention to the code on the left and the test complexity on the right.

ManageableProductList.tsx — 35 lines
In the controller:Basic Render
Latest code change
function ProductList() {
  const [products, setProducts] = useState([])
  const [loading, setLoading] = useState(true)

  useEffect(() => {
    fetch('/api/products')
      .then(r => r.json())
      .then(setProducts)
      .finally(() => setLoading(false))
  }, [])

  if (loading) return <Spinner />
  return <ProductGrid items={products} />
}
What this means
+ Basic Render
Clean and focused. One component, one job. Easy to test with React Testing Library.
To test "render products" you need
Basicmock
Add a feature to the controller

When Trello was built on Backbone in 2011, their Views were as clean as the example above. But as the app grew — activity feeds, power-ups, multi-board views, real-time collaboration — Views accumulated responsibility. They became tangled with observation logic, lifecycle management, and coordination between unrelated concerns. The kanban View that started as 50 lines eventually managed card rendering, drag-and-drop, keyboard shortcuts, real-time sync, and permission checks.

Angular 1.x hit the same wall differently. Google had hundreds of internal AngularJS apps, and the two-way binding between Model and View created cascading re-render bugs: one scope change could trigger updates across seemingly unrelated controllers. Facebook's frustration with similar MVC data-flow issues in their chat system is literally what motivated the creation of Flux — and eventually React itself.

MVP — The View Goes Passive

Look back at the MVC diagram. That red dashed arrow from Model to View — the observer coupling — means the View isn't just rendering UI. It's subscribing to data changes, processing updates, managing observation logic. Model–View–Presenter exists to cut that arrow.

MVPModel–View–Presenter · 1996
MVC's disciplined sibling. The View goes passive, the Presenter takes charge.
no direct View ↔ Model linkdelegates / updatesfetch / mutateViewPresenterModel
Click any layer to see details

No arrow between View and Model. The Presenter is the sole mediator. The View becomes a passive interface — a contract that the Presenter calls into:

TS
1// View (Presentational component) — zero logic, pure rendering
2function ProductCard({ title, price, onAddToCart }: ProductCardProps) {
3 return (
4 <div className="product-card">
5 <h3>{title}</h3>
6 <span>{price}</span>
7 <button onClick={onAddToCart}>Add to cart</button>
8 </div>
9 );
10}
11
12// Presenter (Container component) — owns all logic
13function ProductCardContainer({ productId }: { productId: string }) {
14 const product = useProduct(productId);
15 const { addToCart } = useCart();
16
17 if (!product) return <ProductCardSkeleton />;
18 return (
19 <ProductCard
20 title={product.name}
21 price={formatPrice(product.price)}
22 onAddToCart={() => addToCart(product.id)}
23 />
24 );
25}

Testing the View requires zero logic setup — render ProductCard with props and verify the output. Testing the Container means mocking useProduct and useCart, but no DOM rendering needed. Storybook becomes trivial: every presentational component works in isolation.

Dan Abramov's Influential Pattern

The most famous MVP adoption in frontend was Dan Abramov's 2015 blog post “Presentational and Container Components.” The pattern dominated React architecture for years. Presentational components (Views) received data via props and rendered UI — zero state, zero effects, zero data fetching. Container components (Presenters) handled all logic and passed formatted data down.

This made Storybook possible as a serious development tool. If your Views are truly passive — no hooks, no context, no side effects — you can render every visual state in isolation. Design systems like Shopify's Polaris and GitHub's Primer were built on this foundation: passive components that accept props and render, nothing more.

MVVM — Two-Way Binding Magic

Model–View–ViewModel was invented at Microsoft in 2005 for their WPF framework. The breakthrough idea: what if the View and its state were automatically synchronized? Change the data, the UI updates. Change the UI, the data updates. No manual wiring, no event listeners, no explicit setState calls. Declare the binding and the framework handles the rest.

MVVMModel–View–ViewModel · 2005
Two-way binding magic. Change the data, the UI updates. Change the UI, the data updates.
⟵ reactive binding ⟶two-way binding ⟷read / writeViewViewModelModel
Click any layer to see details

The double arrow between View and ViewModel is the defining feature. In MVC, you manually push data from Controller to View. In MVP, the Presenter explicitly calls View methods. In MVVM, the binding is declarative and automatic.

Evan You's Frustration That Built Vue.js

In 2013, Evan You was working at Google on AngularJS projects. He loved Angular's two-way binding but hated everything around it — the complexity, the boilerplate, the steep learning curve. His thought: take just the reactive data binding and build the simplest possible framework around it.

That side project became Vue.js. Vue is pure MVVM.

VUE
1<template>
2 <!-- View: binds reactively to ViewModel -->
3 <input v-model="username" />
4 <p>Hello, {{ displayName }}</p>
5</template>
6
7<script setup>
8// ViewModel: reactive state + derived state
9const username = ref('')
10const displayName = computed(() =>
11 username.value || 'stranger'
12)
13</script>

This isn't syntactic sugar. It's an architectural decision. The View never imperatively updates the DOM. The ViewModel never reaches into the View. The binding layer is the contract between them, and the framework enforces it. Vue apps tend to be more consistently structured than React apps — the framework nudges you toward MVVM whether you know the term or not.

Svelte took this further in 2019 — same MVVM reactivity model but compiled away at build time. Declare a variable and it's reactive. Bind it to an input with bind:value and it's two-way. The compiler generates the subscription wiring that Vue handles at runtime. KnockoutJS pioneered this approach for the browser back in 2010, years before any of these frameworks existed. Steve Sanderson built it as a pure MVVM JavaScript library — the first time web developers experienced declarative two-way binding.

Clean Architecture — Dependencies Point Inward

Robert Martin (Uncle Bob) published Clean Architecture in 2012, synthesizing ideas from Hexagonal Architecture, Onion Architecture, and DCI into a single model. The core principle: dependencies must point inward. Outer layers depend on inner layers. Inner layers know nothing about what's outside them.

CleanClean Architecture · 2012
Dependencies point inward. The core business logic depends on nothing external.
← innermost (no deps)← outermost (all deps)dependencies →point inwarddepends ondepends ondepends onEntitiesUse CasesAdaptersFrameworks
Click any layer to see details

This creates a powerful guarantee: swap your entire UI framework without touching business logic. Swap your storage layer without changing your domain rules. Each layer is a ring of protection around the core — and the core (Entities) has zero external dependencies.

Here's what that looks like in a frontend TypeScript project:

1src/
2 domain/ ← Pure business rules. Zero imports from React or browser APIs.
3 spreadsheet.ts ← class Cell { resolveFormula() { ... } }
4 validation.ts ← circular reference detection, type coercion rules
5
6 use-cases/ ← Application logic. Imports from domain only.
7 paste-range.ts ← Orchestrates: validate → check bounds → format → recalculate
8 export-csv.ts
9
10 adapters/ ← Translators. Imports from use-cases.
11 useSpreadsheet.ts ← React hook bridging components and use cases
12 storage-adapter.ts ← use case port → IndexedDB or REST API
13
14 ui/ ← Replaceable. Imports from adapters.
15 SpreadsheetGrid.tsx ← React rendering layer
16 Toolbar.tsx ← UI framework components

The dependency arrows always point inward. SpreadsheetGrid.tsx imports from useSpreadsheet.ts, which imports from paste-range.ts, which imports from spreadsheet.ts. Never the reverse. If you swap React for Solid, only ui/ changes. If you swap IndexedDB for a REST API, only adapters/storage-adapter.ts changes.

When Frontend Apps Get Thick Enough

Clean Architecture is often dismissed as backend-only, but it becomes essential when frontend apps have significant domain logic. Figma's rendering engine is a constraint solver and vector math library compiled to WebAssembly — pure domain logic with zero browser dependencies. The React UI is the outermost framework layer. The WebGL renderer is another adapter. Business rules about snapping, alignment, and auto-layout live in the innermost layer, testable without a browser.

The same applies to offline-first PWAs, collaborative editors, complex form builders, and spreadsheet apps. Whenever your frontend has business rules that exist independently of your UI framework — validation logic, data transformation, conflict resolution — those rules deserve their own layer. If your entire “business logic” is “fetch data and display it,” Clean Architecture adds ceremony without value.

How the Same Action Flows Differently

Now that you've seen MVC, MVVM, and Clean Architecture, watch the same user action flow through all three step by step. The differences are subtle but consequential — which layer does what, and when.

FlowTrace a user action through the layers
Pattern:
Action:
Click Next → or Auto ▶ to start
1view
Click captured
0ms
2controller
Handler fires
1ms
3model
State updated
2ms
4view
Observer triggered
15ms
5view
UI updated
16ms

Switch between patterns for the same action and notice: MVVM updates the UI before the API responds (optimistic update). Clean Architecture passes the request through four layers of transformation. MVC's component observes the store directly, coupling UI to data. These aren't theoretical distinctions — they determine how your app feels to users and how your code feels to developers.

Hexagonal Architecture — Ports and Adapters

Alistair Cockburn published Hexagonal Architecture in 2005. While Clean Architecture focuses on layers within an application, Hexagonal focuses on the boundaries between your application and the outside world.

The metaphor: your application is a hexagon. On the left, things drive your application (user clicks, route changes, keyboard shortcuts). On the right, your application drives things (API calls, storage, analytics). Between them, ports define the contracts and adapters implement them.

HexagonalHexagonal Architecture (Ports & Adapters) · 2005
Your app defines ports. The outside world plugs in adapters. Swap anything.
drives the app →← driven by the appcallstriggersusesfulfilled byDriving SideInput PortsCore LogicOutput PortsDriven Side
Click any layer to see details
TS
1// Output port — the core defines WHAT it needs, not HOW
2interface StoragePort {
3 save(doc: Document): Promise<void>;
4 load(id: string): Promise<Document>;
5}
6
7// Adapter for production — IndexedDB
8class IndexedDBStorage implements StoragePort {
9 async save(doc: Document) { /* IndexedDB write */ }
10 async load(id: string) { /* IndexedDB read */ }
11}
12
13// Adapter for tests — in-memory, no browser needed
14class InMemoryStorage implements StoragePort {
15 private docs = new Map<string, Document>();
16 async save(doc: Document) { this.docs.set(doc.id, doc); }
17 async load(id: string) { return this.docs.get(id)!; }
18}

The core logic calls storage.save(doc) without knowing if it's writing to IndexedDB, localStorage, or a REST API. Swap the adapter, the core stays untouched.

Ports and Adapters in Frontend Practice

The most widespread frontend adoption of hexagonal thinking is in rich text editors. ProseMirror — the engine behind TipTap, Notion's editor, and others — defines a core document model with transformation rules that have zero DOM dependency. Input adapters handle keyboard events, toolbar clicks, and collaborative edits from other users. Output adapters render to DOM, export to HTML/Markdown, or persist to any backend. You can swap the rendering layer without touching the document model.

Mock Service Worker (MSW) is another example of hexagonal thinking applied to frontend testing. Your components talk through ports (API calls). MSW plugs in as a driven-side adapter that intercepts network requests and returns mock data. Same components, same code paths, different adapter. No code changes needed — just swap the network layer.

Vertical Slices — Forget Layers, Think Features

Vertical Slice Architecture is the newest pattern on this list, popularized by Jimmy Bogard around 2018. It flips the traditional model on its head. Instead of organizing by technical layer (all components in one folder, all hooks in another, all API calls in a third), you organize by feature. Each feature is a self-contained slice that owns its UI, logic, and data access.

Vertical SlicesVertical Slice Architecture · 2018
Forget layers. Each feature is a self-contained slice from UI to data.
Feature AFeature BFeature CSearch UISearch LogicSearch APICart UICart LogicCart APIAuth UIAuth LogicAuth API
Click any layer to see details

Three independent columns, each owning everything from UI to data. A developer working on Search never touches Cart's code. A developer working on Auth never coordinates with the Search team. Each slice is a team boundary.

Feature-Sliced Design at Scale

In the Russian and European frontend community, Vertical Slices crystallized into a formal methodology called Feature-Sliced Design (FSD). Companies like Tinkoff Bank (one of Europe's largest digital banks), Yandex, and VK adopted it for large React and Vue codebases.

FSD defines a strict hierarchy: app/ pages/ features/ entities/ shared/. Within each feature, you own everything — components, hooks, API calls, types, tests. Cross-feature imports are forbidden except through well-defined public APIs.

The result at Tinkoff: 200+ frontend engineers working on a single monorepo with minimal merge conflicts. Each squad owns their features. Code review is fast because reviewers only need to understand one slice, not the entire application.

Next.js App Router: Slices by Default

If you're using Next.js 13+ with the App Router, you're already doing vertical slices. Each route segment is a natural slice with its own page.tsx, layout.tsx, loading.tsx, and error.tsx. Co-locate your components, hooks, and API calls next to the route that uses them:

1app/
2 dashboard/
3 page.tsx ← Dashboard slice: UI
4 use-analytics.ts ← Dashboard slice: Logic
5 analytics-api.ts ← Dashboard slice: Data
6 billing/
7 page.tsx ← Billing slice: UI
8 use-subscription.ts
9 billing-api.ts
10 settings/
11 page.tsx ← Settings slice: UI
12 use-preferences.ts
13 preferences-api.ts

Patterns Combine

Real applications rarely use a single pattern in isolation. The patterns we've covered aren't mutually exclusive — they operate at different scales and address different concerns.

MVC inside Vertical Slices. Each slice can use MVC internally. The Search slice has its own model (search state), view (search UI), and controller (search handlers). The slice boundary is Vertical Slices; the internal structure is MVC. Shopify's Hydrogen framework works this way — each route is a slice, each route's internals follow an MVC-ish pattern.

Clean Architecture inside Hexagonal boundaries. Rich text editors like TipTap define hexagonal port/adapter boundaries at the edges (keyboard input, DOM rendering, persistence), while using Clean Architecture's layered approach internally. Ports define how the editor connects to the outside world; the document model and transformation rules organize the logic within.

MVVM for the UI layer, Clean Architecture for business logic. Vue.js's MVVM handles the reactive UI layer. Below that, you can structure your business logic following Clean Architecture — entities for domain rules, use cases for application logic, adapters for API calls. The ViewModel becomes the bridge between Clean Architecture's adapters and the reactive View.

The hardest architectural decisions aren't “which pattern?” — they're “which patterns, and at which layers?” Keep that in mind for the scenario quiz later.

Compare Any Two Patterns

Every pattern makes trade-offs. Use the comparison tool below to see how any two patterns stack up across five dimensions. Click any dimension to see why each pattern scores the way it does.

CompareClick any dimension to see why
Pattern A
vs
Pattern B
MVC
Clean

These scores are directional, not absolute. A 9/10 in testability for Clean Architecture doesn't mean MVC code is untestable — it means Clean Architecture's structure makes testing structurally easier by default. Your team, your codebase, your constraints will shift these numbers.

Test Your Instincts

You've seen six patterns, their trade-offs, and their real-world stories. Each scenario below is based on a real company or situation at a specific point in time. The constraints are what they actually faced, and the answer is what they actually chose. Think about what you'd pick before clicking.

Scenario1 / 4
Score: 0

Trello, 2011

You're at Fog Creek Software, building a real-time kanban board as a single-page app. Your team is 6 people. You've picked Backbone.js. Cards, lists, boards — a CRUD app with real-time updates via WebSockets. Ship fast and iterate with user feedback.

Small team, fast iterationSPA with real-time updatesBackbone.js conventions available
Which pattern fits best?

What Stays With You

If you added three features to the FatControllerDemo and felt the test complexity ratchet up, you experienced why a single component can't hold everything — and why every subsequent pattern exists to distribute that concentrated responsibility.

If you stepped through the DependencyFlow and noticed MVVM updating the UI before the API responded, you saw the architectural decision that makes Vue apps feel instant — and the trade-off (rollback complexity) that comes with it.

If you got a ScenarioQuiz question wrong and the feedback explained why your specific choice didn't fit the constraints, you learned something harder to get from prose: architecture decisions are about matching patterns to contexts, not ranking patterns on a universal scale.

Three principles that hold across every pattern:

One-way doors deserve more thought than reversible decisions. Renaming a folder takes minutes. Changing which layer owns state, how data flows between layers, what depends on what — those decisions compound over time and cost months to undo. Spend your architecture energy there.

Start simpler than you think you need. Trello shipped to millions of users on Backbone MVC with a small team. Vue.js proved MVVM could be radically simple. You can add layers later. You cannot easily remove them.

The architecture should match the team, not just the code. Vertical Slices exist because large teams need isolation. Hexagonal Architecture exists because complex apps need stable contracts. MVC exists because small teams need velocity. The best architecture is the one that lets your specific team ship reliably at your specific scale — even if it's not the pattern you'd choose for a greenfield project with unlimited time.