Skip to main content

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

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 responsibilities
type 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 entity
type 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 extension
type 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 code
class 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 substitutable
type 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 interchangeably
function 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 interface
type 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 interfaces
type 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 need
function 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 identity
type UserId = string;
type User = {
id: UserId; // Identity
email: string;
name: string;
createdAt: Date;
};
// Two users with same email are still different entities
const 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 attributes
type 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 equal
const price1 = createMoney(100, "USD");
const price2 = createMoney(100, "USD");
// moneyEquals(price1, price2) === true

Domain Services

Domain services contain business logic that doesn’t naturally fit within a single entity:

// ✅ Domain Service: Business logic that spans multiple entities
class 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 Cart
type 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 on
Business Logic Layer
↓ depends on
Data Access Layer

This 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 Abstractions
Data Access Layer → depends on → Business Logic Abstractions
Business 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 implemented
type UserRepository = {
findById(id: string): Promise<User | null>;
findByEmail(email: string): Promise<User | null>;
save(user: User): Promise<void>;
};
// ✅ High-level module depends on abstraction
class 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 abstraction
class 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: LocalStorage
class 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 edge
function 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:

domain/entities/User.ts
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.ts
export 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.ts
export 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:

application/use-cases/GetUserByIdUseCase.ts
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.ts
export 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.ts
export 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:

infrastructure/api/ApiUserRepository.ts
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:

presentation/hooks/useUser.ts
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.tsx
export 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:

infrastructure/container.ts
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.tsx
const 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 service
export function useUserService(): UserService {
const container = useContainer();
return container.userService;
}

Repository Pattern with Caching

Implement caching at the repository level:

infrastructure/repositories/CachedUserRepository.ts
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:

application/use-cases/PlaceOrderUseCase.ts
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

domain/entities/Product.ts
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.ts
export 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.ts
export 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.ts
export 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.ts
export type ProductRepository = {
findById(id: ProductId): Promise<Product | null>;
findByCategory(category: Category): Promise<Product[]>;
search(query: string): Promise<Product[]>;
};
// domain/repositories/OrderRepository.ts
export 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

application/use-cases/AddToCartUseCase.ts
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.ts
export 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

presentation/hooks/useCart.ts
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.tsx
export 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

domain/services/__tests__/OrderDomainService.test.ts
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

application/use-cases/__tests__/AddToCartUseCase.test.ts
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

__tests__/integration/CartIntegration.test.ts
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 model
type 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 model
type 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 HTTP
type 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 abstraction
type 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:

domain/services/OrderService.ts
// ❌ Bad: Circular dependency
import { UserService } from "./UserService";
// domain/services/UserService.ts
import { OrderService } from "./OrderService";
// ✅ Good: Use events or domain services
// domain/events/OrderCreated.ts
export type OrderCreatedEvent = {
orderId: string;
userId: string;
};
// domain/services/OrderService.ts
class 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 object
class ApplicationService {
// Handles users, orders, products, payments, shipping...
async handleUserRegistration() {}
async handleOrderCreation() {}
async handlePaymentProcessing() {}
async handleShippingCalculation() {}
}
// ✅ Good: Focused services
class 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.ts
export 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 time

Strategy 2: Feature Flags

Use feature flags to gradually roll out new architecture:

application/config/FeatureFlags.ts
export const FeatureFlags = {
useNewArchitecture: process.env.USE_NEW_ARCHITECTURE === "true",
};
// presentation/hooks/useUser.ts
export 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 architecture

Best 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 TypeScript
export type User = {
id: string;
email: string;
};
// ❌ Bad: Depends on external library
import { 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:

domain/repositories/UserRepository.ts
export type UserRepository = {
findById(id: UserId): Promise<User | null>;
};
// Infrastructure must implement the interface
class ApiUserRepository implements UserRepository {
// TypeScript ensures correct implementation
}

✅ Keep Use Cases Focused

Each use case should do one thing:

// ✅ Good: Focused use case
class GetUserByIdUseCase {
async execute(id: UserId): Promise<User> {
// Single responsibility
}
}
// ❌ Bad: Multiple responsibilities
class UserManagementUseCase {
async execute(action: string, data: any): Promise<any> {
// Handles multiple use cases
}
}

✅ Use Dependency Injection

Inject dependencies rather than creating them:

// ✅ Good: Dependency injection
class UserService {
constructor(
private userRepository: UserRepository,
private emailService: EmailService,
) {}
}
// ❌ Bad: Creating dependencies
class UserService {
private userRepository = new ApiUserRepository(); // ❌ Tight coupling
}

✅ Test at the Right Level

Test business logic in isolation, integration at boundaries:

// ✅ Unit test domain logic
test("OrderDomainService.calculateSubtotal", () => {
// Test pure business logic
});
// ✅ Integration test use cases
test("AddToCartUseCase", async () => {
// Test with mocked repositories
});
// ✅ E2E test user flows
test("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