Skip to main content

Architecture Patterns Aren't Rivals

· 16 min read
Pere Pages
Software Engineer
Three translucent architectural plans of the same small building — indigo, violet and lime — stacked and slightly offset on off-white paper, each a different drawing of one house

Model–View–Controller (MVC), Clean Architecture, Hexagonal and Vertical Slices keep getting compared as if a team had to pick one. They answer different questions at different levels of a system, and most real codebases use several at once without noticing.

The slide every architecture talk shows

Every architecture talk has the same slide: three boxes stacked on top of each other, labelled UI (user interface), business logic and data. Swap the title and the slide works for MVC, for Clean Architecture, for Hexagonal Architecture, and for most of what sits in between. That's why teams end up in meetings arguing whether to "go Clean or go Vertical Slice", as if those were two brands of the same product.

They aren't. The mental model this post argues for is simple: an architecture pattern is an answer to one specific question about a codebase, and different patterns answer different questions. Some answer "how does a screen get its state?", some answer "which way do the imports point?", some answer "what is the top-level folder?". Two patterns that answer different questions don't compete; they stack. Two that answer the same question are real alternatives, and those are the only comparisons worth having.

The taxonomy problem

The confusion starts with how these patterns are presented: as a flat list. Comparison articles put MVC, Model–View–ViewModel (MVVM), Clean, Hexagonal and Vertical Slice Architecture in the same table with the same columns, which implies they sit on the same axis. It's a category error, like comparing a floor plan with a wiring diagram and asking which one describes the house better.

What makes the error easy to commit is shared vocabulary. Every pattern talks about layers, separation and decoupling, so they sound alike. The differences only show up once you ask what, exactly, each one separates, and at which scale: a single screen, a module, the whole application, or the repository's folder tree.

Different patterns solve different dimensions

Sorted by the question each one answers, the familiar names fall into six groups:

PatternQuestion it answersScope
MVC, MVP, MVVMHow does a screen's state and input logic stay out of the view?One screen or component
HMVC, MVVM-C, VIPERHow are screens composed, and who owns navigation between them?A module or flow of screens
Clean ArchitectureWhich way do source-code dependencies point between layers?The whole application
Hexagonal ArchitectureWhere is the boundary between the application and the outside world?The application's edges
Screaming ArchitectureDoes the code's structure reveal what the system does?The folder tree
Vertical Slice ArchitectureIs the unit of organization a technical layer or a feature?The folder tree and the request path

Each group is worth a precise sentence, because the precision is where the differences live.

Presentation architecture: MVC, MVP, MVVM

All three keep logic out of the view; they differ in who talks to whom. In MVC, a controller interprets user input and the view observes the model directly[1]. In Model–View–Presenter (MVP), a presenter takes over input handling[2], and in its most common form, the Passive View, the view just forwards events and the presenter pushes data back through a view interface[1]. In MVVM, the view binds to a view-model that exposes state and commands and knows nothing about the view[3], a specialization of Martin Fowler's Presentation Model[4]. These three genuinely compete: they are three answers to the same question.

Presentation structure: HMVC, MVVM-C, VIPER

One level up, the question becomes how screens fit together. Hierarchical MVC (HMVC) nests MVC triads into a tree of independently controlled regions[5]. MVVM-Coordinator (MVVM-C) pairs MVVM with coordinators, a pattern Soroush Khanlou described for iOS: objects that own navigation so view-models don't push screens themselves[6]. VIPER (View, Interactor, Presenter, Entity, Routing) splits each module into five roles, with routing as a first-class role[7]. VIPER is interesting because it already bundles answers to several questions: it's MVP for the screen, a use-case object for the logic and a router for navigation.

Dependency direction: Clean Architecture

Clean Architecture is mostly one rule. Robert C. Martin's Dependency Rule says source-code dependencies can only point inwards, towards the higher-level policies; nothing in an inner circle may know anything about an outer one[8]. The concentric circles are illustrative. What the pattern actually constrains is the direction of every import.

System boundaries: Hexagonal Architecture

Alistair Cockburn's Hexagonal Architecture, also called Ports and Adapters, draws one boundary: the application core on the inside, everything else on the outside[9]. The core declares ports, interfaces written in its own terms. Adapters plug into them: driving adapters (a UI, a test, a command-line tool) call into the core, and driven adapters (a database, a payment provider, a Hypertext Transfer Protocol (HTTP) application programming interface (API)) are called by it. It says nothing about how many layers sit inside the hexagon.

Organization: Screaming Architecture and Vertical Slices

The last two are about the shape of the codebase itself. Screaming Architecture says the top-level structure should announce the domain (orders/, invoices/, shipments/) instead of the framework (controllers/, models/, views/)[10]. Vertical Slice Architecture goes further and makes each request or feature the unit of organization, minimizing coupling between slices and maximizing coupling within one[11]. A slice owns its whole path, from input to storage, and may take whatever internal shape it needs.

How they compose

Once each pattern is pinned to its question, the rule for combining them falls out: patterns on the same axis are alternatives; patterns on different axes compose. You pick one of MVC, MVP or MVVM for a given screen. But nothing stops that screen's view-model from calling a use case that depends only on ports (Hexagonal), whose imports all point inwards (Clean), inside a folder named after the feature it serves (Vertical Slice, Screaming).

That doesn't mean every combination is free. A few pairs rub against each other, and the friction is useful to know about:

PairRelationshipWhere the friction is
MVVM + HexagonalComposesNone: the view-model is simply a driving adapter
Vertical Slice + ScreamingComposesNone: slices named after features already scream
Clean + HexagonalComposes, heavily overlapsBoth invert dependencies at the boundary; Clean adds rings inside it
Vertical Slice + CleanComposes, with tensionBogard's slices resist shared layers across features; Clean is often implemented as exactly that
MVVM vs MVPCompetesSame question, different answer for the same screen
VIPER vs MVVMCompetesVIPER already contains a presentation pattern (MVP)

The Vertical Slice + Clean tension is the one that turns into team arguments, and it's real: a codebase with global domain/, application/ and infrastructure/ folders is organized by layer, which is the thing Vertical Slices reject. The resolution is to apply the Dependency Rule inside each slice rather than as a repository-wide folder structure. The rule is about which way the arrows point, not about where the folders go.

A concrete React example: Place Order

A checkout in a React application shows all of this at once. Since React 16.8, custom hooks give a natural home for view-model logic[12], which makes the combination below cheap to build. The feature is Place Order: take the cart's lines, reject an empty cart, persist the order, charge the customer, and tell the user what happened.

The folder tree is the Vertical Slice and the Screaming part. The feature is the top-level unit, and its name says what the system does:

src/
app/
compositionRoot.tsx # the only file that knows every adapter
features/
place-order/
domain/order.ts # Order, OrderLine, the empty-cart rule
application/ports.ts # PaymentGateway, OrderRepository
application/placeOrder.ts
adapters/httpOrderRepository.ts
adapters/stripePaymentGateway.ts
ui/usePlaceOrderViewModel.ts
ui/PlaceOrderView.tsx
track-shipment/
manage-returns/
shared/
httpClient.ts

The domain holds the rule that makes this an order, and imports nothing:

// features/place-order/domain/order.ts
export type OrderLine = { sku: string; quantity: number; unitPrice: number };
export type Order = { id: string; lines: OrderLine[]; total: number };

export class EmptyCartError extends Error {}

export function createOrder(id: string, lines: OrderLine[]): Order {
if (lines.length === 0) throw new EmptyCartError('Cannot place an empty order');
const total = lines.reduce((sum, line) => sum + line.quantity * line.unitPrice, 0);
return { id, lines, total };
}

The application layer declares the ports it needs, in its own vocabulary, and the use case depends only on them. This is the Hexagonal part:

// features/place-order/application/ports.ts
import { Order } from '../domain/order';

export interface OrderRepository {
save(order: Order): Promise<void>;
}

export interface PaymentGateway {
charge(orderId: string, amount: number): Promise<void>;
}
// features/place-order/application/placeOrder.ts
import { createOrder, Order, OrderLine } from '../domain/order';
import { OrderRepository, PaymentGateway } from './ports';

export type PlaceOrder = (lines: OrderLine[]) => Promise<Order>;

export function makePlaceOrder(deps: {
orders: OrderRepository;
payments: PaymentGateway;
newId: () => string;
}): PlaceOrder {
return async (lines) => {
const order = createOrder(deps.newId(), lines);
// A production flow would save the order as pending and confirm it after payment.
await deps.orders.save(order);
await deps.payments.charge(order.id, order.total);
return order;
};
}

The view-model is a custom hook. It turns the use case into state the view can render and commands the view can call, and it's the only place that maps domain errors to user-facing copy. This is the MVVM part:

// features/place-order/ui/usePlaceOrderViewModel.ts
import { useCallback, useState } from 'react';
import { EmptyCartError, OrderLine } from '../domain/order';
import { PlaceOrder } from '../application/placeOrder';

type Status =
| { kind: 'idle' }
| { kind: 'submitting' }
| { kind: 'placed'; orderId: string }
| { kind: 'failed'; message: string };

export function usePlaceOrderViewModel(placeOrder: PlaceOrder) {
const [status, setStatus] = useState<Status>({ kind: 'idle' });

const submit = useCallback(
async (lines: OrderLine[]) => {
setStatus({ kind: 'submitting' });
try {
const order = await placeOrder(lines);
setStatus({ kind: 'placed', orderId: order.id });
} catch (error) {
const message =
error instanceof EmptyCartError ? 'Your cart is empty.' : 'Something went wrong. Please try again.';
setStatus({ kind: 'failed', message });
}
},
[placeOrder],
);

return { status, submit, canSubmit: status.kind !== 'submitting' };
}

The view renders whatever the view-model exposes and knows nothing about HTTP or payments. Only the composition root knows which adapters exist, and it hands the slice a fully wired use case:

// app/compositionRoot.tsx
import { makePlaceOrder } from '../features/place-order/application/placeOrder';
import { httpOrderRepository } from '../features/place-order/adapters/httpOrderRepository';
import { stripePaymentGateway } from '../features/place-order/adapters/stripePaymentGateway';
import { PlaceOrderView } from '../features/place-order/ui/PlaceOrderView';
import { httpClient } from '../shared/httpClient';
import { v4 as uuid } from 'uuid';

const placeOrder = makePlaceOrder({
orders: httpOrderRepository(httpClient),
payments: stripePaymentGateway(httpClient),
newId: uuid,
});

export function Checkout() {
return <PlaceOrderView placeOrder={placeOrder} />;
}

Three patterns, three different constraints, no conflict. The diagram shows where each one lives; every solid arrow is a source-code dependency, and dotted arrows mark an adapter implementing a port (the adapters' own imports of Order are left out for readability):

The honest trade-offs:

  • This is more files than a single component with a fetch in it. For a form that posts to one endpoint and has no rules, it's ceremony. The structure earns its keep when the rule, the payment provider or the UI change on different schedules.
  • The domain lives inside the slice. When Order History also needs Order, you either let it keep its own read model (the Vertical Slice answer) or extract a shared module and accept the coupling. Neither is free.
  • The view-model is still React. It depends on useState, so it's a driving adapter, not part of the core. The use case and domain are plain TypeScript and can be tested without rendering anything.

Why shallow explanations make them look identical

Go back to the slide: separate UI, business logic, and data. The Place Order example satisfies that sentence. So would a Rails-style MVC app with fat models, a strict three-layer .NET solution, and a single useEffect that validates, saves and charges, split across three files for appearances. A description that every design satisfies doesn't discriminate between designs, so every pattern explained that way looks like every other.

The differences show up once you ask questions with sharper edges:

  1. Who depends on whom? Not "who talks to whom" at runtime; who imports whom in the source code.
  2. Where does business logic live? The empty-cart rule has one home. Name the file.
  3. What is the unit of organization? A layer (services/) or a capability (place-order/)?
  4. What can be replaced without affecting the domain? Swap the payment provider, the HTTP client, the UI framework: which changes reach order.ts?
  5. What direction do dependencies point? Towards the domain, or away from it?

Asked of the Place Order feature, each pattern answers some of these and stays silent on the rest. The silences are as informative as the answers, because they show what the pattern leaves you free to get wrong:

PatternUnit of organizationWhere the empty-cart rule livesDependency directionReplaceable without touching the domain
MVVMOne screenNot specified (often leaks into the view-model)View → view-model; beyond that, silentThe view
CleanRings (layers)Entities / use casesAlways inwardsAnything in an outer ring
HexagonalInside vs outsideInside the hexagonAdapters → portsAny adapter, driving or driven
ScreamingBusiness capabilityNot specifiedSilentSilent
Vertical SliceFeature / requestInside the sliceWithin the slice, free; across slices, minimalAnything the slice owns, without touching other slices

Read the columns, not the rows. MVVM on its own would let the empty-cart check live in usePlaceOrderViewModel and nothing in the pattern would object. Hexagonal would object, but it has no opinion about where the file lives. Vertical Slice decides where the file lives and has no opinion about which way its imports point. No pattern fills every column with a real constraint; the blanks in one row are answered by another, which is exactly why they compose.

A heuristic for reading architecture diagrams

When someone puts an architecture diagram on the screen, run it through these questions before discussing the boxes:

  1. Are the arrows dependencies or data flow? If the diagram doesn't say, it can't answer any of the questions above. Data flows both ways through a hexagon; dependencies don't.
  2. Which way do the arrows point relative to the domain? If the domain imports the database client, the diagram describes a layered app, whatever the title says.
  3. What does a top-level box represent? A technical role or a business capability tells you which organization axis the design chose.
  4. Pick one adapter and delete it. Trace what fails to compile. If the answer includes the domain, the boundary is decorative.
  5. Locate one real business rule. If two people point at different boxes, the diagram isn't describing the code.
  6. Count the axes. A diagram that mixes a screen pattern, a dependency rule and a folder layout in one picture is fine, as long as it doesn't present them as layers of the same thing.

The goal isn't to score the diagram against a named pattern. It's to find out which questions the design has answered deliberately and which ones it's answering by accident.

Conclusion

MVC, MVP and MVVM compete with each other. So do HMVC, MVVM-C and VIPER, within their own level. Everything else on the usual list describes a different dimension of the same system: the direction of imports, the edge of the application, the shape of the folder tree. A team that picks "Vertical Slices instead of Clean Architecture" has usually answered one question and skipped another.

Architecture patterns become useful only when you stop asking what boxes they contain and start asking what dependencies they constrain.

References

  1. Martin Fowler, GUI Architectures — martinfowler.com
  2. Mike Potel, MVP: Model-View-Presenter — The Taligent Programming Model for C++ and Java (1996)
  3. John Gossman, Introduction to Model/View/ViewModel pattern for building WPF apps — MSDN blog archive (2005)
  4. Martin Fowler, Presentation Model — martinfowler.com
  5. Jason Cai, Ranjit Kapila and Gaurav Pal, HMVC: The layered pattern for developing strong client tiers — JavaWorld, now InfoWorld (2000)
  6. Soroush Khanlou, The Coordinator (2015)
  7. Jeff Gilbert and Conrad Stoll, Architecting iOS Apps with VIPER — objc.io, issue 13 (2014)
  8. Robert C. Martin, The Clean Architecture (2012)
  9. Alistair Cockburn, The Hexagonal (Ports & Adapters) Architecture (2005)
  10. Robert C. Martin, Screaming Architecture (2011)
  11. Jimmy Bogard, Vertical Slice Architecture (2018)
  12. React, Building Your Own Hooks — reactjs.org