Clean Architecture in Frontend: Domain-Driven Design and Dependency Inversion
Master clean architecture principles for frontend applications. Learn Domain-Driven Design, Dependency Inversion, SOLID principles, and practical patterns for scalable React applications.
Table of Contents
- Introduction
- Understanding Clean Architecture
- The SOLID Principles
- Domain-Driven Design in Frontend
- Dependency Inversion Principle
- Layered Architecture Structure
- Practical Implementation Patterns
- Real-World Example: E-Commerce Application
- Testing Clean Architecture
- Common Pitfalls and Anti-Patterns
- Migration Strategies
- Best Practices
- Conclusion
Introduction
Frontend applications have grown exponentially in complexity over the past decade. What once were simple HTML pages with JavaScript are now sophisticated single-page applications with complex state management, real-time updates, and intricate business logic. As applications scale, maintaining code quality, testability, and developer productivity becomes increasingly challenging without proper architectural foundations.
Clean Architecture, originally popularized by Robert C. Martin (Uncle Bob) for backend systems, provides a powerful framework for organizing frontend code. When combined with Domain-Driven Design (DDD) principles and Dependency Inversion, it creates a robust structure that keeps your application maintainable, testable, and scalable as it grows.
This comprehensive guide will teach you how to apply Clean Architecture principles to frontend applications. You’ll learn about SOLID principles, Domain-Driven Design concepts adapted for frontend, and Dependency Inversion patterns. Through practical examples and real-world scenarios, you’ll discover how to structure React applications that remain clean and maintainable even as complexity increases.
By the end of this guide, you’ll understand how to separate concerns, create testable code, and build frontend applications that can evolve gracefully over time. Whether you’re starting a new project or refactoring an existing one, these principles will help you write better code.
Understanding Clean Architecture
Clean Architecture is a software design philosophy that emphasizes separation of concerns and independence of frameworks, UI, and business logic. The core idea is to organize code into layers with clear dependencies, where inner layers don’t depend on outer layers.
The Core Principles
Clean Architecture is built on several fundamental principles:
Independence: The architecture should be independent of frameworks, UI libraries, databases, and external agencies. Your business logic shouldn’t care whether you’re using React, Vue, or vanilla JavaScript.
Testability: Business logic should be testable without UI, database, web server, or any external element. You should be able to test your core logic in isolation.
Independence of UI: The UI can change easily without changing the rest of the system. You should be able to swap React for Vue without rewriting business logic.
Independence of Database: You can swap out databases or data sources without changing business logic. Your domain logic shouldn’t know about SQL, MongoDB, or REST APIs.
Independence of External Services: Business rules don’t depend on external services. Third-party APIs, authentication providers, or payment gateways can be swapped without changing core logic.
The Dependency Rule
The most important rule in Clean Architecture is the Dependency Rule: source code dependencies can only point inward. Inner layers cannot know anything about outer layers.
┌─────────────────────────────────────┐│ Presentation Layer │ ← UI Components, Hooks│ (Outermost Layer) │├─────────────────────────────────────┤│ Application Layer │ ← Use Cases, Services├─────────────────────────────────────┤│ Domain Layer │ ← Business Logic, Entities│ (Innermost Layer) │└─────────────────────────────────────┘✅ Correct: Presentation Layer → Application Layer → Domain Layer
❌ Incorrect: Domain Layer → Application Layer → Presentation Layer
Benefits for Frontend Applications
Applying Clean Architecture to frontend applications provides several key benefits:
- Maintainability: Clear separation makes it easier to locate and modify code
- Testability: Business logic can be tested independently of UI and external dependencies
- Scalability: New features can be added without affecting existing code
- Team Collaboration: Different developers can work on different layers simultaneously
- Technology Flexibility: Easy to swap UI libraries, state management, or API clients
The SOLID Principles
SOLID is an acronym for five object-oriented design principles that complement Clean Architecture. Understanding these principles is essential for writing maintainable frontend code.
Single Responsibility Principle (SRP)
A class or module should have only one reason to change. Each component should have a single, well-defined responsibility.
❌ Anti-pattern: A component that handles UI rendering, API calls, and data transformation
// ❌ Bad: Multiple responsibilitiestype UserComponentProps = { userId: string;};
function UserComponent({ userId }: UserComponentProps) { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true);
useEffect(() => { // API call responsibility fetch(`/api/users/${userId}`) .then(res => res.json()) .then(data => { // Data transformation responsibility const transformed = { ...data, fullName: `${data.firstName} ${data.lastName}`, displayName: data.nickname || data.fullName }; setUser(transformed); setLoading(false); }); }, [userId]);
// UI rendering responsibility if (loading) return <div>Loading...</div>; return <div>{user.displayName}</div>;}✅ Best practice: Separate concerns into different modules
// ✅ Good: Domain entitytype User = { id: string; firstName: string; lastName: string; nickname?: string;};
// ✅ Good: Domain service (transformation logic)class UserDomainService { static getDisplayName(user: User): string { return user.nickname || `${user.firstName} ${user.lastName}`; }}
// ✅ Good: Application service (API calls)class UserService { async getUserById(id: string): Promise<User> { const response = await fetch(`/api/users/${id}`); return response.json(); }}
// ✅ Good: Presentation component (UI only)function UserComponent({ userId }: UserComponentProps) { const [user, setUser] = useState<User | null>(null); const [loading, setLoading] = useState(true); const userService = new UserService();
useEffect(() => { userService.getUserById(userId) .then(data => { setUser(data); setLoading(false); }); }, [userId]);
if (loading) return <div>Loading...</div>; if (!user) return <div>User not found</div>;
return <div>{UserDomainService.getDisplayName(user)}</div>;}Open/Closed Principle (OCP)
Software entities should be open for extension but closed for modification. You should be able to add new functionality without changing existing code.
// ✅ Good: Open for extensiontype PaymentMethod = { processPayment(amount: number): Promise<PaymentResult>;};
class CreditCardPayment implements PaymentMethod { async processPayment(amount: number): Promise<PaymentResult> { // Credit card processing logic return { success: true, transactionId: "..." }; }}
class PayPalPayment implements PaymentMethod { async processPayment(amount: number): Promise<PaymentResult> { // PayPal processing logic return { success: true, transactionId: "..." }; }}
// Adding new payment methods doesn't require changing existing codeclass CryptocurrencyPayment implements PaymentMethod { async processPayment(amount: number): Promise<PaymentResult> { // Crypto processing logic return { success: true, transactionId: "..." }; }}Liskov Substitution Principle (LSP)
Objects of a superclass should be replaceable with objects of its subclasses without breaking the application. Derived classes must be substitutable for their base classes.
// ✅ Good: Subtypes are substitutabletype Storage = { save(key: string, value: string): Promise<void>; get(key: string): Promise<string | null>;};
class LocalStorageAdapter implements Storage { async save(key: string, value: string): Promise<void> { localStorage.setItem(key, value); }
async get(key: string): Promise<string | null> { return localStorage.getItem(key); }}
class IndexedDBAdapter implements Storage { async save(key: string, value: string): Promise<void> { // IndexedDB implementation }
async get(key: string): Promise<string | null> { // IndexedDB implementation }}
// Both adapters can be used interchangeablyfunction useStorage(storage: Storage) { // Works with any Storage implementation return { save: storage.save.bind(storage), get: storage.get.bind(storage), };}Interface Segregation Principle (ISP)
Clients should not be forced to depend on interfaces they don’t use. Create specific interfaces rather than one general-purpose interface.
// ❌ Bad: Fat interfacetype UserRepository = { getUserById(id: string): Promise<User>; getUserByEmail(email: string): Promise<User>; createUser(user: User): Promise<User>; updateUser(user: User): Promise<User>; deleteUser(id: string): Promise<void>; getAllUsers(): Promise<User[]>; searchUsers(query: string): Promise<User[]>;};
// ✅ Good: Segregated interfacestype UserReader = { getUserById(id: string): Promise<User>; getUserByEmail(email: string): Promise<User>; getAllUsers(): Promise<User[]>;};
type UserWriter = { createUser(user: User): Promise<User>; updateUser(user: User): Promise<User>; deleteUser(id: string): Promise<void>;};
type UserSearcher = { searchUsers(query: string): Promise<User[]>;};
// Components only depend on what they needfunction UserList({ reader }: { reader: UserReader }) { // Only needs read operations}
function UserForm({ writer }: { writer: UserWriter }) { // Only needs write operations}Dependency Inversion Principle (DIP)
High-level modules should not depend on low-level modules. Both should depend on abstractions. This principle is so important that we’ll explore it in detail in the next section.
Domain-Driven Design in Frontend
Domain-Driven Design (DDD) is a software development approach that focuses on modeling software to match a domain according to input from domain experts. While originally conceived for backend systems, DDD principles are highly applicable to frontend applications.
Core Concepts
Domain: The sphere of knowledge or activity around which the application logic revolves. In an e-commerce app, the domain includes concepts like Products, Orders, Customers, and Shopping Carts.
Entities: Objects that have a unique identity and lifecycle. They are defined by their identity rather than their attributes.
Value Objects: Objects defined by their attributes rather than identity. Two value objects with the same attributes are considered equal.
Domain Services: Operations that don’t naturally fit within an entity or value object.
Repositories: Abstractions for accessing domain objects, providing a collection-like interface.
Entities vs Value Objects
Understanding the difference between entities and value objects is crucial for domain modeling:
// ✅ Entity: Defined by identitytype UserId = string;
type User = { id: UserId; // Identity email: string; name: string; createdAt: Date;};
// Two users with same email are still different entitiesconst user1: User = { id: "1", email: "john@example.com", name: "John", createdAt: new Date(),};const user2: User = { id: "2", email: "john@example.com", name: "John", createdAt: new Date(),};// user1 !== user2 (different identities)
// ✅ Value Object: Defined by attributestype Money = { amount: number; currency: string;};
function createMoney(amount: number, currency: string): Money { return { amount, currency };}
function moneyEquals(a: Money, b: Money): boolean { return a.amount === b.amount && a.currency === b.currency;}
// Two money objects with same attributes are equalconst price1 = createMoney(100, "USD");const price2 = createMoney(100, "USD");// moneyEquals(price1, price2) === trueDomain Services
Domain services contain business logic that doesn’t naturally fit within a single entity:
// ✅ Domain Service: Business logic that spans multiple entitiesclass OrderDomainService { static calculateTotal(order: Order, products: Product[]): Money { const subtotal = order.items.reduce((sum, item) => { const product = products.find((p) => p.id === item.productId); if (!product) return sum;
const itemTotal = product.price.amount * item.quantity; return sum + itemTotal; }, 0);
const tax = subtotal * 0.1; // 10% tax const total = subtotal + tax;
return createMoney(total, order.currency); }
static canCancel(order: Order): boolean { return order.status === "pending" || order.status === "confirmed"; }
static applyDiscount(order: Order, discount: Discount): Order { if (!discount.isValid()) { throw new Error("Invalid discount"); }
return { ...order, discount: discount.code, discountAmount: discount.amount, }; }}Aggregates
An aggregate is a cluster of domain objects treated as a single unit. The aggregate root is the only object that external code can hold references to:
// ✅ Aggregate Root: Shopping Carttype CartItem = { productId: string; quantity: number; price: Money;};
type ShoppingCart = { id: string; userId: string; items: CartItem[]; createdAt: Date; updatedAt: Date;};
class ShoppingCartAggregate { private cart: ShoppingCart;
constructor(cart: ShoppingCart) { this.cart = cart; }
// Business logic encapsulated in the aggregate addItem(product: Product, quantity: number): void { if (quantity <= 0) { throw new Error("Quantity must be positive"); }
const existingItem = this.cart.items.find( (item) => item.productId === product.id, );
if (existingItem) { existingItem.quantity += quantity; } else { this.cart.items.push({ productId: product.id, quantity, price: product.price, }); }
this.cart.updatedAt = new Date(); }
removeItem(productId: string): void { this.cart.items = this.cart.items.filter( (item) => item.productId !== productId, ); this.cart.updatedAt = new Date(); }
getTotal(): Money { const total = this.cart.items.reduce((sum, item) => { return sum + item.price.amount * item.quantity; }, 0);
return createMoney(total, "USD"); }
// Expose read-only access to internal state getCart(): Readonly<ShoppingCart> { return { ...this.cart }; }}Dependency Inversion Principle
The Dependency Inversion Principle (DIP) is the foundation of Clean Architecture. It states that high-level modules should not depend on low-level modules; both should depend on abstractions.
Understanding Dependencies
In traditional layered architecture, dependencies flow downward:
UI Layer ↓ depends onBusiness Logic Layer ↓ depends onData Access LayerThis creates tight coupling. If you change the data access layer, you might need to change business logic. If you change business logic, you might need to change the UI.
Inverting Dependencies
With Dependency Inversion, dependencies point inward toward abstractions:
UI Layer → depends on → Business Logic AbstractionsData Access Layer → depends on → Business Logic AbstractionsBusiness Logic → depends on → nothing (or other abstractions)Abstractions in TypeScript
In TypeScript, we use interfaces and types to create abstractions:
// ✅ Abstraction: Define what we need, not how it's implementedtype UserRepository = { findById(id: string): Promise<User | null>; findByEmail(email: string): Promise<User | null>; save(user: User): Promise<void>;};
// ✅ High-level module depends on abstractionclass UserService { constructor(private userRepository: UserRepository) {}
async getUserById(id: string): Promise<User> { const user = await this.userRepository.findById(id); if (!user) { throw new Error("User not found"); } return user; }
async createUser(email: string, name: string): Promise<User> { const existing = await this.userRepository.findByEmail(email); if (existing) { throw new Error("User already exists"); }
const user: User = { id: generateId(), email, name, createdAt: new Date(), };
await this.userRepository.save(user); return user; }}
// ✅ Low-level module implements abstractionclass ApiUserRepository implements UserRepository { async findById(id: string): Promise<User | null> { const response = await fetch(`/api/users/${id}`); if (!response.ok) return null; return response.json(); }
async findByEmail(email: string): Promise<User | null> { const response = await fetch(`/api/users?email=${email}`); if (!response.ok) return null; const users = await response.json(); return users[0] || null; }
async save(user: User): Promise<void> { await fetch("/api/users", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(user), }); }}
// ✅ Alternative implementation: LocalStorageclass LocalStorageUserRepository implements UserRepository { async findById(id: string): Promise<User | null> { const data = localStorage.getItem(`user:${id}`); return data ? JSON.parse(data) : null; }
async findByEmail(email: string): Promise<User | null> { // Implementation for local storage return null; }
async save(user: User): Promise<void> { localStorage.setItem(`user:${user.id}`, JSON.stringify(user)); }}
// ✅ Dependency injection at the edgefunction createUserService(): UserService { // Choose implementation based on environment or configuration const repository: UserRepository = process.env.NODE_ENV === "test" ? new LocalStorageUserRepository() : new ApiUserRepository();
return new UserService(repository);}Benefits of Dependency Inversion
- Testability: Easy to swap implementations for testing
- Flexibility: Change data sources without changing business logic
- Maintainability: Clear boundaries between layers
- Reusability: Business logic can be reused with different UIs or data sources
Layered Architecture Structure
Clean Architecture organizes code into layers with clear responsibilities. Let’s explore how to structure a frontend application following these principles.
Layer Structure
src/├── domain/ # Innermost layer - Business logic│ ├── entities/│ ├── value-objects/│ ├── services/│ └── repositories/ # Interfaces only├── application/ # Use cases and orchestration│ ├── services/│ ├── use-cases/│ └── dto/├── infrastructure/ # External concerns│ ├── api/│ ├── storage/│ └── adapters/└── presentation/ # Outermost layer - UI ├── components/ ├── hooks/ └── pages/Domain Layer
The domain layer contains pure business logic with no dependencies on external frameworks or libraries:
export type UserId = string;
export type User = { id: UserId; email: string; name: string; role: UserRole; createdAt: Date;};
export type UserRole = "admin" | "user" | "guest";
// domain/value-objects/Email.tsexport type Email = { value: string;};
export function createEmail(value: string): Email { if (!isValidEmail(value)) { throw new Error("Invalid email address"); } return { value };}
function isValidEmail(email: string): boolean { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);}
// domain/services/UserDomainService.tsexport class UserDomainService { static canAccessAdminPanel(user: User): boolean { return user.role === "admin"; }
static getDisplayName(user: User): string { return user.name || user.email; }}
// domain/repositories/UserRepository.ts (Interface only)export type UserRepository = { findById(id: UserId): Promise<User | null>; findByEmail(email: Email): Promise<User | null>; save(user: User): Promise<void>; delete(id: UserId): Promise<void>;};Application Layer
The application layer orchestrates use cases and coordinates between domain and infrastructure:
export class GetUserByIdUseCase { constructor(private userRepository: UserRepository) {}
async execute(userId: UserId): Promise<User> { const user = await this.userRepository.findById(userId); if (!user) { throw new Error("User not found"); } return user; }}
// application/use-cases/CreateUserUseCase.tsexport class CreateUserUseCase { constructor( private userRepository: UserRepository, private emailService: EmailService, ) {}
async execute(email: string, name: string): Promise<User> { const emailValueObject = createEmail(email);
// Check if user already exists const existing = await this.userRepository.findByEmail(emailValueObject); if (existing) { throw new Error("User already exists"); }
// Create user entity const user: User = { id: generateId(), email: emailValueObject.value, name, role: "user", createdAt: new Date(), };
// Save user await this.userRepository.save(user);
// Send welcome email (infrastructure concern) await this.emailService.sendWelcomeEmail(user.email);
return user; }}
// application/services/UserService.tsexport class UserService { constructor( private getUserByIdUseCase: GetUserByIdUseCase, private createUserUseCase: CreateUserUseCase, ) {}
async getUserById(id: UserId): Promise<User> { return this.getUserByIdUseCase.execute(id); }
async createUser(email: string, name: string): Promise<User> { return this.createUserUseCase.execute(email, name); }}Infrastructure Layer
The infrastructure layer implements the interfaces defined in the domain layer:
export class ApiUserRepository implements UserRepository { constructor(private apiClient: ApiClient) {}
async findById(id: UserId): Promise<User | null> { try { const response = await this.apiClient.get(`/users/${id}`); return this.mapToUser(response.data); } catch (error) { if (error.status === 404) return null; throw error; } }
async findByEmail(email: Email): Promise<User | null> { try { const response = await this.apiClient.get(`/users?email=${email.value}`); const users = response.data.map(this.mapToUser); return users[0] || null; } catch (error) { if (error.status === 404) return null; throw error; } }
async save(user: User): Promise<void> { const data = this.mapToApiFormat(user); await this.apiClient.post("/users", data); }
async delete(id: UserId): Promise<void> { await this.apiClient.delete(`/users/${id}`); }
private mapToUser(data: any): User { return { id: data.id, email: data.email, name: data.name, role: data.role, createdAt: new Date(data.createdAt), }; }
private mapToApiFormat(user: User): any { return { id: user.id, email: user.email, name: user.name, role: user.role, createdAt: user.createdAt.toISOString(), }; }}
// infrastructure/storage/LocalStorageUserRepository.ts (for testing)export class LocalStorageUserRepository implements UserRepository { private getStorageKey(id: UserId): string { return `user:${id}`; }
async findById(id: UserId): Promise<User | null> { const data = localStorage.getItem(this.getStorageKey(id)); return data ? JSON.parse(data) : null; }
async findByEmail(email: Email): Promise<User | null> { // Implementation for searching by email in localStorage return null; }
async save(user: User): Promise<void> { localStorage.setItem(this.getStorageKey(user.id), JSON.stringify(user)); }
async delete(id: UserId): Promise<void> { localStorage.removeItem(this.getStorageKey(id)); }}Presentation Layer
The presentation layer handles UI concerns and depends on the application layer:
export function useUser(userId: UserId) { const [user, setUser] = useState<User | null>(null); const [loading, setLoading] = useState(true); const [error, setError] = useState<Error | null>(null);
const userService = useUserService(); // Dependency injection
useEffect(() => { userService.getUserById(userId) .then(setUser) .catch(setError) .finally(() => setLoading(false)); }, [userId, userService]);
return { user, loading, error };}
// presentation/components/UserProfile.tsxexport function UserProfile({ userId }: { userId: UserId }) { const { user, loading, error } = useUser(userId);
if (loading) return <div>Loading...</div>; if (error) return <div>Error: {error.message}</div>; if (!user) return <div>User not found</div>;
return ( <div> <h1>{UserDomainService.getDisplayName(user)}</h1> <p>{user.email}</p> <p>Role: {user.role}</p> </div> );}Practical Implementation Patterns
Let’s explore common patterns for implementing Clean Architecture in React applications.
Dependency Injection Container
Create a container to manage dependencies:
type Container = { userRepository: UserRepository; userService: UserService; // ... other services};
export function createContainer(): Container { // Infrastructure dependencies const apiClient = new ApiClient(process.env.API_URL); const userRepository: UserRepository = new ApiUserRepository(apiClient);
// Application dependencies const getUserByIdUseCase = new GetUserByIdUseCase(userRepository); const createUserUseCase = new CreateUserUseCase(userRepository, emailService); const userService = new UserService(getUserByIdUseCase, createUserUseCase);
return { userRepository, userService };}
// presentation/context/ContainerContext.tsxconst ContainerContext = createContext<Container | null>(null);
export function ContainerProvider({ children }: { children: React.ReactNode }) { const container = useMemo(() => createContainer(), []); return ( <ContainerContext.Provider value={container}> {children} </ContainerContext.Provider> );}
export function useContainer(): Container { const container = useContext(ContainerContext); if (!container) { throw new Error('ContainerProvider not found'); } return container;}
// Custom hook for user serviceexport function useUserService(): UserService { const container = useContainer(); return container.userService;}Repository Pattern with Caching
Implement caching at the repository level:
export class CachedUserRepository implements UserRepository { private cache = new Map<UserId, User>(); private cacheTimeout = 5 * 60 * 1000; // 5 minutes
constructor( private baseRepository: UserRepository, private cacheStorage: Storage, ) {}
async findById(id: UserId): Promise<User | null> { // Check memory cache first const cached = this.cache.get(id); if (cached) return cached;
// Check persistent cache const cachedData = await this.cacheStorage.get(`user:${id}`); if (cachedData) { const user = JSON.parse(cachedData); this.cache.set(id, user); return user; }
// Fetch from base repository const user = await this.baseRepository.findById(id); if (user) { this.cache.set(id, user); await this.cacheStorage.set(`user:${id}`, JSON.stringify(user)); }
return user; }
async save(user: User): Promise<void> { await this.baseRepository.save(user); this.cache.set(user.id, user); await this.cacheStorage.set(`user:${user.id}`, JSON.stringify(user)); }
async delete(id: UserId): Promise<void> { await this.baseRepository.delete(id); this.cache.delete(id); await this.cacheStorage.remove(`user:${id}`); }
// ... other methods}Use Case Pattern
Encapsulate business logic in use cases:
export class PlaceOrderUseCase { constructor( private orderRepository: OrderRepository, private productRepository: ProductRepository, private paymentService: PaymentService, private inventoryService: InventoryService, ) {}
async execute(orderData: PlaceOrderDTO): Promise<Order> { // 1. Validate order data this.validateOrderData(orderData);
// 2. Check product availability const products = await this.getProducts(orderData.items); await this.checkAvailability(products, orderData.items);
// 3. Calculate total const total = this.calculateTotal(products, orderData.items);
// 4. Process payment const paymentResult = await this.paymentService.processPayment({ amount: total, method: orderData.paymentMethod, });
if (!paymentResult.success) { throw new Error("Payment failed"); }
// 5. Reserve inventory await this.inventoryService.reserveItems(orderData.items);
// 6. Create order const order: Order = { id: generateId(), userId: orderData.userId, items: orderData.items, total, status: "confirmed", paymentId: paymentResult.transactionId, createdAt: new Date(), };
await this.orderRepository.save(order);
return order; }
private validateOrderData(data: PlaceOrderDTO): void { if (!data.userId) throw new Error("User ID required"); if (!data.items || data.items.length === 0) { throw new Error("Order must contain items"); } }
private async getProducts(items: OrderItem[]): Promise<Product[]> { const productIds = items.map((item) => item.productId); return Promise.all( productIds.map((id) => this.productRepository.findById(id)), ).then((products) => products.filter((p): p is Product => p !== null)); }
private async checkAvailability( products: Product[], items: OrderItem[], ): Promise<void> { for (const item of items) { const product = products.find((p) => p.id === item.productId); if (!product) { throw new Error(`Product ${item.productId} not found`); } if (product.stock < item.quantity) { throw new Error(`Insufficient stock for ${product.name}`); } } }
private calculateTotal(products: Product[], items: OrderItem[]): Money { const subtotal = items.reduce((sum, item) => { const product = products.find((p) => p.id === item.productId)!; return sum + product.price.amount * item.quantity; }, 0);
return createMoney(subtotal, "USD"); }}Real-World Example: E-Commerce Application
Let’s build a complete example of an e-commerce application following Clean Architecture principles.
Domain Layer
export type ProductId = string;
export type Product = { id: ProductId; name: string; description: string; price: Money; stock: number; category: Category; createdAt: Date;};
export type Category = "electronics" | "clothing" | "books" | "home";
// domain/entities/Order.tsexport type OrderId = string;
export type OrderItem = { productId: ProductId; quantity: number; price: Money;};
export type OrderStatus = | "pending" | "confirmed" | "shipped" | "delivered" | "cancelled";
export type Order = { id: OrderId; userId: string; items: OrderItem[]; total: Money; status: OrderStatus; shippingAddress: Address; createdAt: Date; updatedAt: Date;};
// domain/value-objects/Address.tsexport type Address = { street: string; city: string; state: string; zipCode: string; country: string;};
export function createAddress( street: string, city: string, state: string, zipCode: string, country: string,): Address { if (!street || !city || !state || !zipCode || !country) { throw new Error("All address fields are required"); } return { street, city, state, zipCode, country };}
// domain/services/OrderDomainService.tsexport class OrderDomainService { static canCancel(order: Order): boolean { return order.status === "pending" || order.status === "confirmed"; }
static canModify(order: Order): boolean { return order.status === "pending"; }
static calculateSubtotal(items: OrderItem[]): Money { const total = items.reduce((sum, item) => { return sum + item.price.amount * item.quantity; }, 0); return createMoney(total, "USD"); }
static calculateShippingCost(total: Money, address: Address): Money { // Business logic for shipping calculation const baseShipping = 10; const internationalMultiplier = address.country !== "US" ? 2 : 1; return createMoney(baseShipping * internationalMultiplier, total.currency); }}
// domain/repositories/ProductRepository.tsexport type ProductRepository = { findById(id: ProductId): Promise<Product | null>; findByCategory(category: Category): Promise<Product[]>; search(query: string): Promise<Product[]>;};
// domain/repositories/OrderRepository.tsexport type OrderRepository = { findById(id: OrderId): Promise<Order | null>; findByUserId(userId: string): Promise<Order[]>; save(order: Order): Promise<void>; update(order: Order): Promise<void>;};Application Layer
export class AddToCartUseCase { constructor( private productRepository: ProductRepository, private cartRepository: CartRepository, ) {}
async execute( userId: string, productId: ProductId, quantity: number, ): Promise<Cart> { // Validate quantity if (quantity <= 0) { throw new Error("Quantity must be positive"); }
// Get product const product = await this.productRepository.findById(productId); if (!product) { throw new Error("Product not found"); }
// Check stock if (product.stock < quantity) { throw new Error("Insufficient stock"); }
// Get or create cart let cart = await this.cartRepository.findByUserId(userId); if (!cart) { cart = this.createEmptyCart(userId); }
// Add item to cart const existingItem = cart.items.find( (item) => item.productId === productId, ); if (existingItem) { existingItem.quantity += quantity; } else { cart.items.push({ productId, quantity, price: product.price, }); }
cart.updatedAt = new Date(); await this.cartRepository.save(cart);
return cart; }
private createEmptyCart(userId: string): Cart { return { id: generateId(), userId, items: [], createdAt: new Date(), updatedAt: new Date(), }; }}
// application/services/CartService.tsexport class CartService { constructor( private addToCartUseCase: AddToCartUseCase, private removeFromCartUseCase: RemoveFromCartUseCase, private getCartUseCase: GetCartUseCase, ) {}
async addItem( userId: string, productId: ProductId, quantity: number, ): Promise<Cart> { return this.addToCartUseCase.execute(userId, productId, quantity); }
async removeItem(userId: string, productId: ProductId): Promise<Cart> { return this.removeFromCartUseCase.execute(userId, productId); }
async getCart(userId: string): Promise<Cart> { return this.getCartUseCase.execute(userId); }}Presentation Layer
export function useCart() { const [cart, setCart] = useState<Cart | null>(null); const [loading, setLoading] = useState(true); const cartService = useCartService(); const userId = useUserId();
useEffect(() => { if (userId) { cartService.getCart(userId) .then(setCart) .finally(() => setLoading(false)); } }, [userId, cartService]);
const addItem = useCallback(async (productId: ProductId, quantity: number) => { if (!userId) return; const updatedCart = await cartService.addItem(userId, productId, quantity); setCart(updatedCart); }, [userId, cartService]);
const removeItem = useCallback(async (productId: ProductId) => { if (!userId) return; const updatedCart = await cartService.removeItem(userId, productId); setCart(updatedCart); }, [userId, cartService]);
return { cart, loading, addItem, removeItem, itemCount: cart?.items.reduce((sum, item) => sum + item.quantity, 0) || 0, total: cart ? OrderDomainService.calculateSubtotal(cart.items) : null };}
// presentation/components/ProductCard.tsxexport function ProductCard({ product }: { product: Product }) { const { addItem } = useCart(); const [quantity, setQuantity] = useState(1);
const handleAddToCart = async () => { try { await addItem(product.id, quantity); // Show success message } catch (error) { // Show error message } };
return ( <div className="product-card"> <h3>{product.name}</h3> <p>{product.description}</p> <p>${product.price.amount}</p> <p>Stock: {product.stock}</p> <input type="number" min="1" max={product.stock} value={quantity} onChange={(e) => setQuantity(Number(e.target.value))} /> <button onClick={handleAddToCart}>Add to Cart</button> </div> );}Testing Clean Architecture
Clean Architecture makes testing significantly easier because business logic is isolated from external dependencies.
Unit Testing Domain Logic
describe("OrderDomainService", () => { describe("canCancel", () => { it("should return true for pending orders", () => { const order: Order = { id: "1", userId: "user1", items: [], total: createMoney(100, "USD"), status: "pending", shippingAddress: createAddress( "123 Main St", "City", "State", "12345", "US", ), createdAt: new Date(), updatedAt: new Date(), };
expect(OrderDomainService.canCancel(order)).toBe(true); });
it("should return false for shipped orders", () => { const order: Order = { ...baseOrder, status: "shipped", };
expect(OrderDomainService.canCancel(order)).toBe(false); }); });
describe("calculateSubtotal", () => { it("should calculate correct subtotal", () => { const items: OrderItem[] = [ { productId: "1", quantity: 2, price: createMoney(10, "USD") }, { productId: "2", quantity: 1, price: createMoney(20, "USD") }, ];
const subtotal = OrderDomainService.calculateSubtotal(items);
expect(subtotal.amount).toBe(40); expect(subtotal.currency).toBe("USD"); }); });});Testing Use Cases with Mocks
describe("AddToCartUseCase", () => { let useCase: AddToCartUseCase; let mockProductRepository: jest.Mocked<ProductRepository>; let mockCartRepository: jest.Mocked<CartRepository>;
beforeEach(() => { mockProductRepository = { findById: jest.fn(), findByCategory: jest.fn(), search: jest.fn(), };
mockCartRepository = { findByUserId: jest.fn(), save: jest.fn(), };
useCase = new AddToCartUseCase(mockProductRepository, mockCartRepository); });
it("should add item to existing cart", async () => { const product: Product = { id: "product1", name: "Test Product", description: "Test", price: createMoney(10, "USD"), stock: 10, category: "electronics", createdAt: new Date(), };
const existingCart: Cart = { id: "cart1", userId: "user1", items: [], createdAt: new Date(), updatedAt: new Date(), };
mockProductRepository.findById.mockResolvedValue(product); mockCartRepository.findByUserId.mockResolvedValue(existingCart); mockCartRepository.save.mockResolvedValue();
const result = await useCase.execute("user1", "product1", 2);
expect(result.items).toHaveLength(1); expect(result.items[0].quantity).toBe(2); expect(mockCartRepository.save).toHaveBeenCalled(); });
it("should throw error if product not found", async () => { mockProductRepository.findById.mockResolvedValue(null);
await expect(useCase.execute("user1", "product1", 1)).rejects.toThrow( "Product not found", ); });
it("should throw error if insufficient stock", async () => { const product: Product = { id: "product1", name: "Test Product", description: "Test", price: createMoney(10, "USD"), stock: 5, category: "electronics", createdAt: new Date(), };
mockProductRepository.findById.mockResolvedValue(product);
await expect(useCase.execute("user1", "product1", 10)).rejects.toThrow( "Insufficient stock", ); });});Integration Testing
describe("Cart Integration", () => { let container: Container;
beforeEach(() => { // Use test implementations container = createTestContainer(); });
it("should add item to cart and persist", async () => { const cartService = container.cartService; const userId = "test-user";
// Add item const cart = await cartService.addItem(userId, "product1", 2);
expect(cart.items).toHaveLength(1); expect(cart.items[0].quantity).toBe(2);
// Retrieve cart const retrievedCart = await cartService.getCart(userId);
expect(retrievedCart.id).toBe(cart.id); expect(retrievedCart.items).toHaveLength(1); });});Common Pitfalls and Anti-Patterns
Understanding common mistakes helps avoid them in your own projects.
❌ Anemic Domain Model
Don’t create domain objects that are just data containers without behavior:
// ❌ Bad: Anemic domain modeltype Order = { id: string; items: OrderItem[]; total: number;};
function calculateTotal(order: Order): number { // Logic outside the domain object return order.items.reduce((sum, item) => sum + item.price * item.quantity, 0);}
// ✅ Good: Rich domain modeltype Order = { id: string; items: OrderItem[]; getTotal(): Money;};
function createOrder(id: string, items: OrderItem[]): Order { return { id, items, getTotal(): Money { return this.items.reduce( (sum, item) => { return sum + item.price.amount * item.quantity; }, createMoney(0, "USD"), ); }, };}❌ Leaking Infrastructure Concerns into Domain
Keep infrastructure details out of domain logic:
// ❌ Bad: Domain knows about HTTPtype UserRepository = { getUser(id: string): Promise<User>;};
class UserService { async getUser(id: string): Promise<User> { const response = await fetch(`/api/users/${id}`); // ❌ HTTP in domain return response.json(); }}
// ✅ Good: Domain uses abstractiontype UserRepository = { findById(id: string): Promise<User | null>;};
class UserService { constructor(private userRepository: UserRepository) {}
async getUser(id: string): Promise<User> { const user = await this.userRepository.findById(id); if (!user) throw new Error("User not found"); return user; }}❌ Circular Dependencies
Avoid circular dependencies between layers:
// ❌ Bad: Circular dependencyimport { UserService } from "./UserService";
// domain/services/UserService.tsimport { OrderService } from "./OrderService";
// ✅ Good: Use events or domain services// domain/events/OrderCreated.tsexport type OrderCreatedEvent = { orderId: string; userId: string;};
// domain/services/OrderService.tsclass OrderService { constructor(private eventBus: EventBus) {}
createOrder(order: Order): void { // Create order logic this.eventBus.publish(new OrderCreatedEvent(order.id, order.userId)); }}❌ God Objects
Avoid creating classes or modules that know too much:
// ❌ Bad: God objectclass ApplicationService { // Handles users, orders, products, payments, shipping... async handleUserRegistration() {} async handleOrderCreation() {} async handlePaymentProcessing() {} async handleShippingCalculation() {}}
// ✅ Good: Focused servicesclass UserService { async registerUser() {}}
class OrderService { async createOrder() {}}
class PaymentService { async processPayment() {}}Migration Strategies
Migrating an existing application to Clean Architecture requires careful planning and incremental changes.
Strategy 1: Strangler Fig Pattern
Gradually replace old code with new architecture:
// Step 1: Create new domain layer alongside old code// domain/entities/User.ts (new)export type User = { /* ... */};
// Step 2: Create adapter that bridges old and new// infrastructure/adapters/LegacyUserAdapter.tsexport class LegacyUserAdapter implements UserRepository { constructor(private legacyApi: LegacyApi) {}
async findById(id: string): Promise<User | null> { // Convert from old format to new domain model const legacyUser = await this.legacyApi.getUser(id); return this.mapToDomain(legacyUser); }
private mapToDomain(legacy: LegacyUser): User { return { id: legacy.id, email: legacy.emailAddress, // Map old field names name: legacy.fullName, // ... }; }}
// Step 3: Gradually migrate features// Start with new features using new architecture// Migrate existing features one at a timeStrategy 2: Feature Flags
Use feature flags to gradually roll out new architecture:
export const FeatureFlags = { useNewArchitecture: process.env.USE_NEW_ARCHITECTURE === "true",};
// presentation/hooks/useUser.tsexport function useUser(userId: string) { if (FeatureFlags.useNewArchitecture) { return useNewArchitectureUser(userId); } else { return useLegacyUser(userId); }}Strategy 3: Bottom-Up Migration
Start with domain layer and work outward:
// Phase 1: Extract domain models// Identify core business entities and extract them
// Phase 2: Create repositories// Define interfaces and create implementations
// Phase 3: Build use cases// Move business logic into use cases
// Phase 4: Update presentation layer// Refactor components to use new architectureBest Practices
Follow these best practices to maintain clean architecture in your frontend applications.
✅ Keep Domain Layer Pure
The domain layer should have zero dependencies on external libraries:
// ✅ Good: Pure TypeScriptexport type User = { id: string; email: string;};
// ❌ Bad: Depends on external libraryimport { v4 as uuid } from "uuid"; // ❌ Don't import in domain
export type User = { id: string; // Use string, generate UUID in infrastructure email: string;};✅ Use TypeScript for Type Safety
Leverage TypeScript’s type system to enforce architecture boundaries:
export type UserRepository = { findById(id: UserId): Promise<User | null>;};
// Infrastructure must implement the interfaceclass ApiUserRepository implements UserRepository { // TypeScript ensures correct implementation}✅ Keep Use Cases Focused
Each use case should do one thing:
// ✅ Good: Focused use caseclass GetUserByIdUseCase { async execute(id: UserId): Promise<User> { // Single responsibility }}
// ❌ Bad: Multiple responsibilitiesclass UserManagementUseCase { async execute(action: string, data: any): Promise<any> { // Handles multiple use cases }}✅ Use Dependency Injection
Inject dependencies rather than creating them:
// ✅ Good: Dependency injectionclass UserService { constructor( private userRepository: UserRepository, private emailService: EmailService, ) {}}
// ❌ Bad: Creating dependenciesclass UserService { private userRepository = new ApiUserRepository(); // ❌ Tight coupling}✅ Test at the Right Level
Test business logic in isolation, integration at boundaries:
// ✅ Unit test domain logictest("OrderDomainService.calculateSubtotal", () => { // Test pure business logic});
// ✅ Integration test use casestest("AddToCartUseCase", async () => { // Test with mocked repositories});
// ✅ E2E test user flowstest("User can add item to cart", async () => { // Test complete flow});Conclusion
Clean Architecture provides a powerful framework for building maintainable, testable, and scalable frontend applications. By separating concerns into layers, applying SOLID principles, and using Domain-Driven Design concepts, you can create applications that remain clean and manageable as they grow.
The key takeaways from this guide:
- Separate concerns into distinct layers (domain, application, infrastructure, presentation)
- Apply SOLID principles to create flexible, maintainable code
- Use Domain-Driven Design to model business logic effectively
- Invert dependencies so high-level modules don’t depend on low-level modules
- Test in isolation by keeping business logic independent of external dependencies
- Migrate incrementally using patterns like Strangler Fig or feature flags
Remember that Clean Architecture is a means to an end, not an end in itself. The goal is to write code that is easy to understand, test, and maintain. Start with the principles that provide the most value for your specific situation, and gradually adopt more advanced patterns as your application grows.
For more on related topics, check out our guides on design patterns in JavaScript, state management in React, and testing strategies. These resources complement Clean Architecture principles and help you build robust frontend applications.
As you apply these principles, you’ll find that your code becomes more testable, your team becomes more productive, and your applications become easier to maintain and extend. Start small, iterate, and continuously refine your architecture based on what works best for your team and project.
References
- Clean Architecture by Robert C. Martin - Original Clean Architecture article
- Domain-Driven Design by Eric Evans - DDD concepts and patterns
- SOLID Principles - Object-oriented design principles
- TypeScript Handbook - TypeScript documentation
- React Documentation - Official React documentation
- Testing Library - Testing utilities for React