Clean Architecture in Practice: Layer Separation in Frontend
When I first read the Clean Architecture book, I thought "I need to apply this to my frontend." So I tried. UseCase classes, Repository interfaces, Entity layers — I built the whole thing. Then I tried using it. A simple CRUD screen required 15 files.
I was following Clean Architecture too literally.
Uncle Bob's Clean Architecture is a set of design principles for large enterprise backends. Frontend is a different context — UI state, user events, and rendering cycles are the core concerns. You can't just copy the backend approach.
This post takes the essential principles of Clean Architecture and shows how to apply them practically in a frontend context.
The Core Principles Worth Keeping
First, let's extract only what's essential from Uncle Bob's original ideas:
1. The Dependency Rule
Outer layers can depend on inner layers.
Inner layers must not know about outer layers.
In plain terms: business logic must not know about UI frameworks, databases, or API clients.
UI Components → Application Logic → Domain Rules
↑ ↑ ↑
(outer) (middle) (inner)
Arrow direction = dependency direction
Inner layers are unaware of outer layers
2. Separation of Concerns
- Domain: Business rules, pure data types
- Application: Use cases, orchestration
- Infrastructure: External world (APIs, localStorage, router)
- Presentation: UI, components, styles
3. Framework Independence
Core business logic must not depend on React. If you switch to Vue tomorrow, the business logic should work unchanged.
Frontend Layer Design
Translating these principles into a practical frontend structure:
src/
├── domain/ # 1. Domain Layer (innermost)
│ ├── entities/ # Business entity types
│ ├── usecases/ # Use case interfaces
│ └── repositories/ # Repository interfaces
│
├── application/ # 2. Application Layer
│ ├── usecases/ # Use case implementations
│ └── services/ # Application services
│
├── infrastructure/ # 3. Infrastructure Layer (external world)
│ ├── api/ # HTTP clients, API integrations
│ ├── storage/ # localStorage, sessionStorage
│ └── repositories/ # Repository implementations
│
└── presentation/ # 4. Presentation Layer (outermost)
├── components/ # React components
├── hooks/ # Custom hooks (connecting to application layer)
└── pages/ # Page components
Let's walk through each layer with a shopping cart example.
Layer 1: Domain
The domain layer is written in the language of the business. No React, no API calls. Just pure business rules.
// domain/entities/product.ts
export interface Product {
id: string;
name: string;
price: number;
stock: number;
}
// domain/entities/cart.ts
export interface CartItem {
product: Product;
quantity: number;
}
export interface Cart {
items: CartItem[];
}
// Domain rules — pure functions
export function calculateCartTotal(cart: Cart): number {
return cart.items.reduce(
(total, item) => total + item.product.price * item.quantity,
0
);
}
export function isProductInStock(product: Product, requestedQuantity: number): boolean {
return product.stock >= requestedQuantity;
}
export function findCartItem(cart: Cart, productId: string): CartItem | undefined {
return cart.items.find(item => item.product.id === productId);
}
// domain/repositories/cart.repository.ts
// Interface only (implementation lives in infrastructure layer)
export interface CartRepository {
getCart(): Promise<Cart>;
saveCart(cart: Cart): Promise<void>;
}
export interface ProductRepository {
findById(id: string): Promise<Product | null>;
findAll(): Promise<Product[]>;
}
Domain layer characteristics:
- No external dependencies (no React, axios, browser APIs)
- Pure TypeScript
- Trivially easy to test (no mocks needed)
Layer 2: Application
The application layer implements use cases. Scenarios like "user adds a product to the cart."
// application/usecases/addToCart.ts
import { Cart, findCartItem } from '../../domain/entities/cart';
import { isProductInStock } from '../../domain/entities/cart';
import { CartRepository } from '../../domain/repositories/cart.repository';
import { ProductRepository } from '../../domain/repositories/product.repository';
export interface AddToCartInput {
productId: string;
quantity: number;
}
export interface AddToCartResult {
success: boolean;
error?: 'PRODUCT_NOT_FOUND' | 'OUT_OF_STOCK' | 'QUANTITY_EXCEEDED';
cart?: Cart;
}
export class AddToCartUseCase {
constructor(
private cartRepo: CartRepository,
private productRepo: ProductRepository
) {}
async execute(input: AddToCartInput): Promise<AddToCartResult> {
// 1. Fetch product
const product = await this.productRepo.findById(input.productId);
if (!product) {
return { success: false, error: 'PRODUCT_NOT_FOUND' };
}
// 2. Fetch cart
const cart = await this.cartRepo.getCart();
// 3. Check stock
const existingItem = findCartItem(cart, input.productId);
const totalRequestedQuantity = (existingItem?.quantity ?? 0) + input.quantity;
if (!isProductInStock(product, totalRequestedQuantity)) {
return { success: false, error: 'OUT_OF_STOCK' };
}
// 4. Update cart
const updatedCart = addItemToCart(cart, product, input.quantity);
// 5. Persist
await this.cartRepo.saveCart(updatedCart);
return { success: true, cart: updatedCart };
}
}
Application layer characteristics:
- Only depends on the domain layer (doesn't know infrastructure or UI)
- Uses repositories as interfaces (doesn't know implementations)
- Testable at the use case level
// Testing is clean — inject mocks via constructor
describe('AddToCartUseCase', () => {
it('successfully adds an in-stock product', async () => {
const mockProduct = { id: '1', name: 'Laptop', price: 1000000, stock: 5 };
const mockCartRepo = {
getCart: vi.fn().mockResolvedValue({ items: [] }),
saveCart: vi.fn().mockResolvedValue(undefined),
};
const mockProductRepo = {
findById: vi.fn().mockResolvedValue(mockProduct),
};
const useCase = new AddToCartUseCase(mockCartRepo, mockProductRepo);
const result = await useCase.execute({ productId: '1', quantity: 2 });
expect(result.success).toBe(true);
expect(result.cart?.items[0].quantity).toBe(2);
});
});
Layer 3: Infrastructure
The infrastructure layer communicates with the outside world. Repository interface implementations live here.
// infrastructure/repositories/productRepository.ts
import { Product } from '../../domain/entities/product';
import { ProductRepository } from '../../domain/repositories/product.repository';
import { ProductApiClient } from '../api/productApi';
export class ApiProductRepository implements ProductRepository {
constructor(private apiClient: ProductApiClient) {}
async findById(id: string): Promise<Product | null> {
return this.apiClient.getProduct(id);
}
async findAll(): Promise<Product[]> {
return this.apiClient.getAllProducts();
}
}
// infrastructure/repositories/cartRepository.ts
import { Cart } from '../../domain/entities/cart';
import { CartRepository } from '../../domain/repositories/cart.repository';
export class LocalStorageCartRepository implements CartRepository {
private readonly STORAGE_KEY = 'shopping_cart';
async getCart(): Promise<Cart> {
try {
const stored = localStorage.getItem(this.STORAGE_KEY);
if (!stored) return { items: [] };
return JSON.parse(stored) as Cart;
} catch {
return { items: [] };
}
}
async saveCart(cart: Cart): Promise<void> {
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(cart));
}
}
Infrastructure layer characteristics:
- Implements domain interfaces
- Depends on external technologies (axios, localStorage, browser APIs)
- Swappable — want IndexedDB instead of localStorage? Only this file changes
Layer 4: Presentation
The presentation layer handles UI. React components and custom hooks live here.
Custom hooks act as the bridge between application logic and UI:
// presentation/hooks/useAddToCart.ts
import { useState } from 'react';
import { AddToCartUseCase } from '../../application/usecases/addToCart';
import { getDependencies } from '../../infrastructure/di/container';
import { Cart } from '../../domain/entities/cart';
interface UseAddToCartState {
isLoading: boolean;
error: string | null;
cart: Cart | null;
}
export function useAddToCart() {
const [state, setState] = useState<UseAddToCartState>({
isLoading: false,
error: null,
cart: null,
});
const addToCart = async (productId: string, quantity: number) => {
setState(prev => ({ ...prev, isLoading: true, error: null }));
try {
const { cartRepo, productRepo } = getDependencies();
const useCase = new AddToCartUseCase(cartRepo, productRepo);
const result = await useCase.execute({ productId, quantity });
if (result.success) {
setState({ isLoading: false, error: null, cart: result.cart ?? null });
} else {
const errorMessage = {
PRODUCT_NOT_FOUND: 'Product not found',
OUT_OF_STOCK: 'Out of stock',
QUANTITY_EXCEEDED: 'Quantity exceeded',
}[result.error!];
setState({ isLoading: false, error: errorMessage, cart: null });
}
} catch {
setState({ isLoading: false, error: 'An unexpected error occurred', cart: null });
}
};
return { ...state, addToCart };
}
// presentation/components/ProductCard.tsx
import { useAddToCart } from '../hooks/useAddToCart';
import { Product } from '../../domain/entities/product';
export function ProductCard({ product }: { product: Product }) {
const { isLoading, error, addToCart } = useAddToCart();
return (
<div className="product-card">
<h3>{product.name}</h3>
<p>${(product.price / 100).toFixed(2)}</p>
<p>In stock: {product.stock}</p>
{error && <p className="error">{error}</p>}
<button
onClick={() => addToCart(product.id, 1)}
disabled={isLoading || product.stock === 0}
>
{isLoading ? 'Adding...' : 'Add to Cart'}
</button>
</div>
);
}
The component knows nothing about business logic. "When the button is clicked, call addToCart." That's its entire job.
When Clean Architecture Is Worth It vs Overkill
Honestly, this structure isn't always the right call.
Worth It When:
✓ Team of 5+ people
✓ Project expected to live 2+ years
✓ Complex business logic (billing, permissions, calculations)
✓ Targeting multiple platforms (web + mobile app)
✓ Testing is critical to the domain
✓ Backend API changes frequently
Overkill When:
✗ 1–2 person startup MVP
✗ Simple CRUD admin panel
✗ Project expected to be thrown away in 3 months
✗ Landing page with minimal business logic
✗ Team unfamiliar with this structure
Forcing this on a small project produces 15-file overhead for a single screen, and development velocity craters.
The Middle Ground: Practical Compromise
You don't need all of it. Take the essential principles:
// Minimal layer separation (small projects)
src/
├── api/ // API calls (infrastructure)
├── hooks/ // Business logic + state (application)
└── components/ // UI only (presentation)
Just this separation — API logic out of components, business logic in hooks, components as pure UI — is dramatically better than the everything-in-one-component pattern.
Before vs After
Before — everything mixed into one component:
// Everything tangled together
function ProductPage() {
const [products, setProducts] = useState([]);
const [cart, setCart] = useState({ items: [] });
useEffect(() => {
fetch('/api/products').then(r => r.json()).then(setProducts);
}, []);
function addToCart(product) {
// Business logic in the component
const existing = cart.items.find(i => i.id === product.id);
if (existing) {
setCart({ items: cart.items.map(i => i.id === product.id ? {...i, qty: i.qty+1} : i) });
} else {
setCart({ items: [...cart.items, { ...product, qty: 1 }] });
}
localStorage.setItem('cart', JSON.stringify(cart));
}
return <div>{products.map(p => <button onClick={() => addToCart(p)}>{p.name}</button>)}</div>;
}
After — layers separated:
// Component only handles UI
function ProductPage() {
const { products } = useProducts();
const { addToCart, isLoading, error } = useAddToCart();
return (
<div>
{products.map(product => (
<ProductCard
key={product.id}
product={product}
onAddToCart={(qty) => addToCart(product.id, qty)}
isLoading={isLoading}
/>
))}
{error && <ErrorBanner message={error} />}
</div>
);
}
The component has zero knowledge of business logic. Testable, replaceable, readable.
Closing: Understand the Principles, Gain the Tools
There's no single correct way to apply Clean Architecture. What matters is understanding why we separate layers:
- Business logic depending on UI → business logic dies with the UI
- Infrastructure leaking into the domain → changing the DB requires changing domain code
- Everything mixed into components → untestable, unreusable
Layer separation exists to avoid these problems. Whatever framework you use, whatever team size, these principles hold.
You don't need a perfect structure from day one. The most actionable thing you can do today: move API call code out of components. Start there.