
Clean Architecture in Practice: Layer Separation in Frontend
Applying Uncle Bob's Clean Architecture directly to frontend creates unnecessary complexity. Here's a practical, frontend-adapted layering strategy that actually works in production.

Applying Uncle Bob's Clean Architecture directly to frontend creates unnecessary complexity. Here's a practical, frontend-adapted layering strategy that actually works in production.
A deep dive into Robert C. Martin's Clean Architecture. Learn how to decouple your business logic from frameworks, databases, and UI using Entities, Use Cases, and the Dependency Rule. Includes Screaming Architecture and Testing strategies.

I imported a Server Component inside a Client Component, and it broke everything. Here’s how to use the Composition Pattern (Donut Pattern) to fix it and correctly separate Context Providers.

Obsessively wrapping everything in `useMemo`? It might be hurting your performance. Learn the hidden costs of memoization and when to actually use it.

Deployed your React app and getting 404 on refresh? Here's why Client-Side Routing breaks on static servers and how to fix it using Nginx, AWS S3, Apache, and Netlify redirects. Includes a debugging guide.

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.
First, let's extract only what's essential from Uncle Bob's original ideas:
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
Core business logic must not depend on React. If you switch to Vue tomorrow, the business logic should work unchanged.
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.
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:
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:
// 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);
});
});
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:
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.
Honestly, this structure isn't always the right call.
✓ 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
✗ 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.
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 — 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.
There's no single correct way to apply Clean Architecture. What matters is understanding why we separate layers:
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.