Clean Architecture, popularized by Robert C. Martin, is a software design philosophy that separates business rules from implementation details. The core idea: dependencies point inward. The inner layers (entities, use cases) know nothing about outer layers (frameworks, databases, UI). This creates systems that are independent of frameworks, testable in isolation, and easy to adapt to changing requirements. While traditionally applied to backend systems, Clean Architecture is equally powerful for full‑stack applications. This guide explains the layers, demonstrates a concrete TypeScript implementation for a full‑stack todo app (React frontend + Node backend), and shows how to organize code for long‑term maintainability.
Clean Architecture typically consists of four layers, each with clear responsibilities and dependency rules.
src/
├── domain/ # Entities (no external dependencies)
│ ├── entities/
│ │ └── Todo.ts
│ └── repositories/
│ └── ITodoRepository.ts # Interface (abstraction)
├── application/ # Use Cases
│ └── useCases/
│ ├── CreateTodo.ts
│ ├── GetTodos.ts
│ └── ToggleTodo.ts
├── infrastructure/ # Concrete implementations of repositories
│ ├── database/
│ │ └── PrismaTodoRepository.ts
│ └── web/
│ ├── express/
│ │ └── TodoController.ts
│ └── react/
│ ├── hooks/
│ │ └── useTodos.ts
│ └── components/
│ └── TodoList.tsx
└── index.ts
The domain layer contains the core business entities and repository interfaces. No framework code allowed.
export interface TodoProps {
id: string;
title: string;
completed: boolean;
createdAt: Date;
}
export class Todo {
private props: TodoProps;
private constructor(props: TodoProps) {
this.props = props;
}
static create(title: string): Todo {
return new Todo({
id: crypto.randomUUID(),
title,
completed: false,
createdAt: new Date(),
});
}
static restore(props: TodoProps): Todo {
return new Todo(props);
}
toggle(): void {
this.props.completed = !this.props.completed;
}
updateTitle(title: string): void {
if (title.length < 3) throw new Error('Title too short');
this.props.title = title;
}
get id(): string { return this.props.id; }
get title(): string { return this.props.title; }
get completed(): boolean { return this.props.completed; }
get createdAt(): Date { return this.props.createdAt; }
}
import { Todo } from '../entities/Todo';
export interface ITodoRepository {
save(todo: Todo): Promise;
findAll(): Promise;
findById(id: string): Promise;
delete(id: string): Promise;
}
Use cases orchestrate the domain entities and repositories. They are framework‑agnostic and contain application‑specific logic.
import { ITodoRepository } from '../../domain/repositories/ITodoRepository';
import { Todo } from '../../domain/entities/Todo';
export class CreateTodo {
constructor(private todoRepository: ITodoRepository) {}
async execute(title: string): Promise {
const todo = Todo.create(title);
await this.todoRepository.save(todo);
return todo;
}
}
import { ITodoRepository } from '../../domain/repositories/ITodoRepository';
import { Todo } from '../../domain/entities/Todo';
export class GetTodos {
constructor(private todoRepository: ITodoRepository) {}
async execute(): Promise {
return this.todoRepository.findAll();
}
}
This layer implements the repository interfaces using real databases (Prisma, TypeORM) and provides controllers/API endpoints.
import { PrismaClient } from '@prisma/client';
import { ITodoRepository } from '../../../domain/repositories/ITodoRepository';
import { Todo } from '../../../domain/entities/Todo';
export class PrismaTodoRepository implements ITodoRepository {
constructor(private prisma: PrismaClient) {}
async save(todo: Todo): Promise {
await this.prisma.todo.upsert({
where: { id: todo.id },
update: {
title: todo.title,
completed: todo.completed,
},
create: {
id: todo.id,
title: todo.title,
completed: todo.completed,
createdAt: todo.createdAt,
},
});
}
async findAll(): Promise {
const records = await this.prisma.todo.findMany();
return records.map(record => Todo.restore(record));
}
async findById(id: string): Promise {
const record = await this.prisma.todo.findUnique({ where: { id } });
return record ? Todo.restore(record) : null;
}
async delete(id: string): Promise {
await this.prisma.todo.delete({ where: { id } });
}
}
import { Request, Response } from 'express';
import { CreateTodo } from '../../../../application/useCases/CreateTodo';
import { GetTodos } from '../../../../application/useCases/GetTodos';
import { ToggleTodo } from '../../../../application/useCases/ToggleTodo';
export class TodoController {
constructor(
private createTodo: CreateTodo,
private getTodos: GetTodos,
private toggleTodo: ToggleTodo
) {}
async post(req: Request, res: Response) {
const { title } = req.body;
const todo = await this.createTodo.execute(title);
res.status(201).json(todo);
}
async getAll(req: Request, res: Response) {
const todos = await this.getTodos.execute();
res.json(todos);
}
async patch(req: Request, res: Response) {
const { id } = req.params;
const todo = await this.toggleTodo.execute(id);
res.json(todo);
}
}
The composition root is where all dependencies are wired together. This is the only place that knows about concrete implementations.
import express from 'express';
import { PrismaClient } from '@prisma/client';
import { PrismaTodoRepository } from './infrastructure/database/PrismaTodoRepository';
import { CreateTodo } from './application/useCases/CreateTodo';
import { GetTodos } from './application/useCases/GetTodos';
import { ToggleTodo } from './application/useCases/ToggleTodo';
import { TodoController } from './infrastructure/web/express/TodoController';
const app = express();
app.use(express.json());
const prisma = new PrismaClient();
const todoRepository = new PrismaTodoRepository(prisma);
const createTodo = new CreateTodo(todoRepository);
const getTodos = new GetTodos(todoRepository);
const toggleTodo = new ToggleTodo(todoRepository);
const todoController = new TodoController(createTodo, getTodos, toggleTodo);
app.post('/todos', (req, res) => todoController.post(req, res));
app.get('/todos', (req, res) => todoController.getAll(req, res));
app.patch('/todos/:id', (req, res) => todoController.patch(req, res));
app.listen(3000, () => console.log('Server running'));
The same principles apply to frontend: separate UI components from business logic, state management, and data fetching. Use custom hooks as interface adapters.
import { useEffect, useState } from 'react';
import { Todo } from '../../../domain/entities/Todo';
import { ITodoRepository } from '../../../domain/repositories/ITodoRepository';
import { ApiTodoRepository } from '../repositories/ApiTodoRepository';
export const useTodos = () => {
const [todos, setTodos] = useState([]);
const [loading, setLoading] = useState(true);
const repository: ITodoRepository = new ApiTodoRepository();
const loadTodos = async () => {
const all = await repository.findAll();
setTodos(all);
setLoading(false);
};
const createTodo = async (title: string) => {
const todo = Todo.create(title);
await repository.save(todo);
await loadTodos();
};
const toggleTodo = async (id: string) => {
await repository.toggle?.(id); // custom method
await loadTodos();
};
useEffect(() => { loadTodos(); }, []);
return { todos, loading, createTodo, toggleTodo };
};
import { useTodos } from '../hooks/useTodos';
export const TodoList = () => {
const { todos, loading, createTodo, toggleTodo } = useTodos();
const [newTitle, setNewTitle] = useState('');
if (loading) return Loading...;
return (
setNewTitle(e.target.value)} />
{todos.map(todo => (
-
toggleTodo(todo.id)} />
{todo.title}
))}
);
};
Because dependencies are injected, each layer can be tested in isolation. Use mocks for repositories.
import { CreateTodo } from './CreateTodo';
import { ITodoRepository } from '../../domain/repositories/ITodoRepository';
const mockRepo: ITodoRepository = {
save: jest.fn(),
findAll: jest.fn(),
findById: jest.fn(),
delete: jest.fn(),
};
test('creates a todo and saves it', async () => {
const useCase = new CreateTodo(mockRepo);
const todo = await useCase.execute('Test');
expect(todo.title).toBe('Test');
expect(mockRepo.save).toHaveBeenCalledWith(todo);
});
| Aspect | Clean Architecture | Traditional Layered (e.g., MVC) |
|---|---|---|
| Testability | High – use cases can be tested without framework/DB | Medium – often tied to ORM or web framework |
| Framework independence | Yes – inner layers know nothing about Express/React/Prisma | No – typically coupled to framework |
| Learning curve | Steeper – requires discipline and inversion of control | Lower – more conventional |
| Boilerplate | More files, more interfaces | Less |
A fintech company adopted Clean Architecture for their transaction processing system. The domain layer contained complex validation rules (anti‑money laundering checks, daily limits). The use cases orchestrated multiple repositories (transaction, user, fraud detection). By keeping the domain pure, they could:
Development velocity increased after an initial investment in setup.
Hexagonal Architecture is a sibling concept that emphasizes “ports” (interfaces) and “adapters” (implementations). Clean Architecture’s use cases and repositories are essentially ports. Combining both gives you a powerful, testable system that can easily swap external services.
Clean Architecture is a proven way to protect your business logic from the constant churn of frameworks, UI libraries, and databases. By separating concerns into concentric layers, you gain testability, framework independence, and clarity. While the upfront discipline is higher, the payoff is a system that can evolve with your business for years. Start by identifying a core domain module, extract interfaces, and use dependency injection. The examples in this guide give you a blueprint for both frontend and backend. Adopt Clean Architecture incrementally – your future self will thank you.
Remember: The goal is not to follow dogma, but to create systems that are easy to change. Clean Architecture is a tool to achieve that.