Layered Architecture: Building React Apps
Layered architecture divides a React application into four horizontal layers, each responsible for one concern: presentation renders UI, application orchestrates use cases, domain encodes business rules, and data adapts to external systems. Each layer depends only on the layer below it, creating a clear boundary and enabling independent testing. When requirements change at the business level, the presentation layer is unaffected. This is the most practical architecture for teams building mid-to-large React applications.
I use layered architecture in nearly every production React codebase. It is intuitive (developers quickly understand where code belongs), flexible (swap implementations at any layer), and enforces discipline without rigid frameworks. The key insight is that layers are orthogonal to features: you do not create one folder per layer; instead, each feature folder contains code from all layers, and layers are enforced by directory structure and imports.
The Four Layers
1. Presentation Layer (React components)
Components, hooks, state management, styling. Receives data from the application layer, renders it, and sends user actions back. No business logic. Examples: Button.jsx, UserCard.jsx, LoginForm.jsx.
2. Application Layer (Use cases, orchestration)
Orchestrates the domain and data layers. Represents a single user action or business operation. Stateless; returns data or throws errors. Examples: RegisterUserUseCase.js, FetchPostsUseCase.js.
3. Domain Layer (Business logic)
Pure functions and classes that encode business rules. No dependencies on React, adapters, or external systems. Examples: validateEmail.js, Money.js, User.js.
4. Data Layer (Adapters, APIs, storage)
Bridges to external systems: HTTP APIs, databases, file storage, caches. Implements interfaces defined by use cases. Examples: UserHttpAdapter.js, PostStorageAdapter.js.
The dependency graph flows downward: presentation depends on application, application on domain, domain has no dependencies.
┌──────────────────────────────────────┐
│ Presentation Layer (React) │
│ Components, Hooks, State, Styling │
└──────────────────────────────────────┘
↓ imports from
┌──────────────────────────────────────┐
│ Application Layer (Use Cases) │
│ Orchestration, Business Operations │
└──────────────────────────────────────┘
↓ imports from
┌──────────────────────────────────────┐
│ Domain Layer (Pure Logic) │
│ Entities, Value Objects, Rules │
└──────────────────────────────────────┘
↓ imports from
┌──────────────────────────────────────┐
│ Data Layer (Adapters) │
│ HTTP, Storage, External Services │
└──────────────────────────────────────┘
Practical Example: E-Commerce Cart Feature
Let's trace a feature—"Add item to cart"—through all layers.
Data Layer (Adapters):
// CartHttpAdapter
export class CartHttpAdapter {
async addItem(itemId, quantity) {
const response = await fetch('/api/cart', {
method: 'POST',
body: JSON.stringify({ itemId, quantity }),
});
return response.json();
}
async getCart() {
const response = await fetch('/api/cart');
return response.json();
}
}
Domain Layer (Business rules):
// Pure functions: no React, no HTTP
export class CartItem {
constructor(id, name, price, quantity) {
if (quantity <= 0) {
throw new Error('Quantity must be positive');
}
this.id = id;
this.name = name;
this.price = price;
this.quantity = quantity;
}
total() {
return this.price * this.quantity;
}
}
export function calculateCartTotal(items) {
return items.reduce((sum, item) => sum + item.total(), 0);
}
Application Layer (Use case):
export class AddItemToCartUseCase {
constructor(cartAdapter) {
this.cartAdapter = cartAdapter;
}
async execute(itemId, quantity) {
// Validate domain rules
if (quantity <= 0) {
throw new Error('Quantity must be positive');
}
// Call adapter to persist
const cartData = await this.cartAdapter.addItem(itemId, quantity);
// Transform to domain objects
const items = cartData.items.map(
(item) => new CartItem(item.id, item.name, item.price, item.quantity)
);
return {
items,
total: calculateCartTotal(items),
};
}
}
Presentation Layer (Component):
import { useState } from 'react';
import { AddItemToCartUseCase } from '../usecases/AddItemToCartUseCase';
import { CartHttpAdapter } from '../adapters/CartHttpAdapter';
export function ProductCard({ product }) {
const [quantity, setQuantity] = useState(1);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const handleAddToCart = async () => {
setLoading(true);
setError(null);
try {
const adapter = new CartHttpAdapter();
const useCase = new AddItemToCartUseCase(adapter);
await useCase.execute(product.id, quantity);
alert('Added to cart!');
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
return (
<div>
<h2>{product.name}</h2>
<p>${product.price}</p>
<input
type="number"
value={quantity}
onChange={(e) => setQuantity(parseInt(e.target.value))}
min="1"
/>
{error && <p style={{color: 'red'}}>{error}</p>}
<button onClick={handleAddToCart} disabled={loading}>
{loading ? 'Adding...' : 'Add to Cart'}
</button>
</div>
);
}
Testing Each Layer
Domain layer: test pure functions directly.
describe('CartItem', () => {
it('calculates total correctly', () => {
const item = new CartItem(1, 'Widget', 10, 5);
expect(item.total()).toBe(50);
});
it('rejects zero quantity', () => {
expect(() => new CartItem(1, 'Widget', 10, 0)).toThrow();
});
});
describe('calculateCartTotal', () => {
it('sums all items', () => {
const items = [
new CartItem(1, 'A', 10, 2), // 20
new CartItem(2, 'B', 15, 1), // 15
];
expect(calculateCartTotal(items)).toBe(35);
});
});
Application layer: test use cases with mocked adapters.
describe('AddItemToCartUseCase', () => {
it('calls adapter with correct arguments', async () => {
const mockAdapter = {
addItem: jest.fn().mockResolvedValue({
items: [{ id: 1, name: 'Widget', price: 10, quantity: 5 }],
}),
};
const useCase = new AddItemToCartUseCase(mockAdapter);
await useCase.execute(1, 5);
expect(mockAdapter.addItem).toHaveBeenCalledWith(1, 5);
});
it('throws error if quantity is invalid', async () => {
const mockAdapter = { addItem: jest.fn() };
const useCase = new AddItemToCartUseCase(mockAdapter);
await expect(useCase.execute(1, 0)).rejects.toThrow('Quantity must be positive');
});
});
Presentation layer: test component with mocked use case.
import { render, screen, fireEvent } from '@testing-library/react';
import { ProductCard } from './ProductCard';
it('displays error if adding to cart fails', async () => {
const mockAdapter = {
addItem: jest.fn().mockRejectedValue(new Error('Cart unavailable')),
};
// (In real tests, you would mock the adapter at the provider level)
render(<ProductCard product={{ id: 1, name: 'Widget', price: 10 }} />);
const button = screen.getByText('Add to Cart');
fireEvent.click(button);
expect(await screen.findByText('Cart unavailable')).toBeInTheDocument();
});
Import Rules (Enforce with Linting)
To maintain layering, use an ESLint rule to prevent upward imports:
- Presentation can import from Application, Domain, Data.
- Application can import from Domain, Data.
- Domain imports only built-in modules and third-party libraries (never React, adapters, or adapters' dependencies).
- Data imports Application and Domain only for interfaces (types, classes).
ESLint configuration (simplified):
// .eslintrc.js
rules: {
'no-restricted-imports': [
'error',
{
patterns: [
// Domain cannot import from other layers
'domains/*/components',
'domains/*/adapters',
'react',
],
},
],
}
Comparing Layer Implementations
| Layer | Purpose | Examples | Can Import From |
|---|---|---|---|
| Presentation | Render UI, handle events, show feedback | Components, Hooks, Styling | Application, Domain, Data |
| Application | Orchestrate domain and data layers | Use Cases, Validators, Mappers | Domain, Data |
| Domain | Pure business logic, invariants | Entities, Value Objects, Rules | Nothing (pure JS) |
| Data | Bridge to external systems | HTTP Adapters, Storage, APIs | Domain (for types/entities) |
Key Takeaways
- Layered architecture divides the app into four levels: presentation, application, domain, and data.
- Each layer depends only on the layer below it, enabling independent testing and swapping implementations.
- Domain logic is pure, tested directly, and independent of React; presentation is thin and focused on UI.
- Use ESLint to enforce layering rules and prevent upward imports.
- Testing is straightforward: mock lower layers and test each layer in isolation.
Frequently Asked Questions
Is layered architecture the only architecture pattern?
No. Hexagonal architecture (covered in article 7), onion architecture, and Clean Architecture are variations. Layered is the most accessible for teams starting out; hexagonal is more flexible if you have many external dependencies.
Do all features have to span all layers?
Mostly, yes. A feature like "add item to cart" touches presentation (component), application (use case), domain (validation), and data (adapter). Simple features like "toggle dark mode" might skip the domain layer if there is no business logic.
What if a use case needs data from multiple adapters?
A use case can depend on multiple adapters:
export class CreateOrderUseCase {
constructor(cartAdapter, inventoryAdapter, orderAdapter) {
this.cart = cartAdapter;
this.inventory = inventoryAdapter;
this.order = orderAdapter;
}
async execute() {
const cart = await this.cart.getCart();
for (const item of cart.items) {
await this.inventory.reserve(item.id, item.quantity);
}
return this.order.create(cart);
}
}
This is clean; use cases orchestrate multiple adapters to achieve a business goal.
How do I test a component that uses a use case?
Mock the use case or inject it via a hook or context. Do not render the entire layer stack in a component test; mock the application layer:
jest.mock('../usecases/AddItemToCartUseCase');
AddItemToCartUseCase.mockImplementation(() => ({
execute: jest.fn().mockResolvedValue(/* ... */),
}));
Or use dependency injection via Context (article 5).