Introduction
Clean Architecture, conceptualized by Robert C. Martin (Uncle Bob), aims to create software applications independent of frameworks, databases, or UI layers. Picture your code as an onion: at the core, pure business rules (entities and use cases); on the outer layers, technical details (DB, UI). Why adopt it in 2026? It makes your apps testable, maintainable, and scalable. In this beginner tutorial, we'll build a simple TODO API in TypeScript: create, read, update, and delete tasks. Each layer depends only on the inner ones, never the reverse. The result: decoupled code that's easy to refactor. Ready to turn your monolithic projects into solid architectures? (112 words)
Prerequisites
- Node.js 20+ installed
- Basic TypeScript knowledge
- An editor like VS Code
- npm or yarn for dependencies
Initialize the project and install dependencies
mkdir clean-architecture-todo
cd clean-architecture-todo
npm init -y
npm install express typescript ts-node @types/node @types/express
npm install -D @types/express
npx tsc --init
mkdir -p src/{domain,application,infrastructure,adapters,presentation}
touch src/domain/entities/Todo.ts
touch src/domain/repositories/TodoRepository.tsThis command sets up a Node.js project with TypeScript and Express. We create the Clean Architecture folder structure: domain (business rules), application (use cases), infrastructure (implementations), adapters (controllers), presentation (frameworks). This separates concerns from the start, avoiding the pitfalls of flat projects.
Step 1: Define domain entities
Entities form the core of Clean Architecture: pure business objects with no external dependencies. For our TODOs, a Todo entity with id, title, description, and status. No DB or HTTP here – just pure business logic.
Create the Todo entity
export interface Todo {
id: string;
title: string;
description: string;
completed: boolean;
}
export class TodoEntity implements Todo {
constructor(
public id: string,
public title: string,
public description: string,
public completed: boolean = false
) {}
toggle(): void {
this.completed = !this.completed;
}
update(title: string, description: string): void {
this.title = title;
this.description = description;
}
}The TodoEntity implements the Todo interface with business methods like toggle() to flip the status. It's fully independent: testable without mocks. Pitfall to avoid: don't add DB logic here, or you'll break domain independence.
Step 2: Define repositories (interfaces)
Repositories are abstract interfaces for data access, defined in the domain. They expose methods like findById() or save(), without any implementation. This lets you inject any DB (InMemory, Mongo, SQL) without touching the core.
TodoRepository interface
import { Todo } from '../entities/Todo';
export interface TodoRepository {
save(todo: Todo): Promise<Todo>;
findById(id: string): Promise<Todo | null>;
findAll(): Promise<Todo[]>;
update(id: string, todo: Todo): Promise<Todo>;
delete(id: string): Promise<void>;
}This interface defines the contract for any Todo repository. Use cases will use it without knowing the implementation. Benefit: dependency inversion – the domain doesn't depend on infrastructure.
Step 3: Implement use cases
Use cases orchestrate application logic: they use entities and repositories. Example: CreateTodoUseCase validates inputs and persists via the repository. Analogy: a conductor directing without playing an instrument.
Use case: Create a Todo
import { TodoRepository } from '../../domain/repositories/TodoRepository';
import { TodoEntity } from '../../domain/entities/Todo';
import { v4 as uuidv4 } from 'uuid';
export class CreateTodoUseCase {
constructor(private todoRepository: TodoRepository) {}
async execute(title: string, description: string): Promise<string> {
if (!title || title.length < 3) {
throw new Error('Titre trop court');
}
const todo = new TodoEntity(uuidv4(), title, description);
const savedTodo = await this.todoRepository.save(todo);
return savedTodo.id;
}
}This use case validates business inputs, creates the entity, and calls the repository. It depends on an interface, not a specific implementation. Install uuid with npm i uuid @types/uuid if needed. Pitfall: don't handle presentation errors here to avoid polluting business logic.
Use case: List Todos
import { TodoRepository } from '../../domain/repositories/TodoRepository';
export class GetTodosUseCase {
constructor(private todoRepository: TodoRepository) {}
async execute(): Promise<any[]> {
const todos = await this.todoRepository.findAll();
return todos.map(todo => ({
id: todo.id,
title: todo.title,
description: todo.description,
completed: todo.completed
}));
}
}Simple retrieval and mapping to DTO (Data Transfer Object) to avoid exposing internal entities. This protects the domain from external changes.
Step 4: Implement the in-memory repository
In infrastructure, we implement the repository for quick tests. Later, swap it for Prisma or Mongoose without changing anything else.
InMemory repository
import { Todo } from '../../domain/entities/Todo';
import { TodoRepository } from '../../domain/repositories/TodoRepository';
export class InMemoryTodoRepository implements TodoRepository {
private todos: Todo[] = [];
async save(todo: Todo): Promise<Todo> {
this.todos.push(todo);
return todo;
}
async findById(id: string): Promise<Todo | null> {
return this.todos.find(t => t.id === id) || null;
}
async findAll(): Promise<Todo[]> {
return [...this.todos];
}
async update(id: string, todo: Todo): Promise<Todo> {
const index = this.todos.findIndex(t => t.id === id);
if (index === -1) throw new Error('Todo non trouvé');
this.todos[index] = todo;
return todo;
}
async delete(id: string): Promise<void> {
const index = this.todos.findIndex(t => t.id === id);
if (index > -1) this.todos.splice(index, 1);
}
}Synchronous/asynchronous implementation to match the interface. Defensive copy with spread for findAll(). Ideal for prototypes; easily scales to a real DB.
Step 5: Create adapters (controllers)
Adapters translate HTTP requests to use cases. They depend on use cases, not the other way around.
Todo controller
import { Request, Response } from 'express';
import { CreateTodoUseCase } from '../../application/use-cases/CreateTodoUseCase';
import { GetTodosUseCase } from '../../application/use-cases/GetTodosUseCase';
export class TodoController {
constructor(
private createTodoUseCase: CreateTodoUseCase,
private getTodosUseCase: GetTodosUseCase
) {}
async create(req: Request, res: Response): Promise<void> {
try {
const { title, description } = req.body;
const id = await this.createTodoUseCase.execute(title, description);
res.status(201).json({ id });
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
}
async getAll(req: Request, res: Response): Promise<void> {
const todos = await this.getTodosUseCase.execute();
res.json(todos);
}
}The controller injects use cases and handles HTTP (status, JSON). Clear separation: no business logic here. Add other methods like update/delete similarly.
Step 6: Wire it up in the entry point
Finally, the framework layer (Express) assembles everything: instantiates repositories and use cases.
Main server
import express from 'express';
import { InMemoryTodoRepository } from '../infrastructure/repositories/InMemoryTodoRepository';
import { CreateTodoUseCase } from '../application/use-cases/CreateTodoUseCase';
import { GetTodosUseCase } from '../application/use-cases/GetTodosUseCase';
import { TodoController } from '../adapters/controllers/TodoController';
const app = express();
app.use(express.json());
const todoRepo = new InMemoryTodoRepository();
const createTodoUseCase = new CreateTodoUseCase(todoRepo);
const getTodosUseCase = new GetTodosUseCase(todoRepo);
const todoController = new TodoController(createTodoUseCase, getTodosUseCase);
app.post('/todos', (req, res) => todoController.create(req, res));
app.get('/todos', (req, res) => todoController.getAll(req, res));
const PORT = 3000;
app.listen(PORT, () => {
console.log(`Serveur sur http://localhost:${PORT}`);
});
export default app;Dependency wiring via composition. Run with npx ts-node src/presentation/server.ts. Test POST/GET with curl or Postman. Easy to swap the repo for a real DB.
Best practices
- Dependency inversion: Always inject interfaces, never concrete implementations into the core.
- DTOs everywhere: Never expose entities to outer layers to avoid coupling.
- Unit tests: Mock repositories to test use cases in isolation.
- IoC container: Use InversifyJS or Awilix for scalable wiring.
- Scalability: Add ports/adapters for new drivers (queues, caches).
Common mistakes to avoid
- Framework leakage: Don't import Express into use cases – it breaks independence.
- Anemic entities: Add business logic to entities, not just getters/setters.
- Overloaded repositories: Limit to 5-7 methods; decompose if needed.
- Untyped errors: Use custom Error classes for better handling.
Next steps
- Book: "Clean Architecture" by Uncle Bob
- Example GitHub repo: Clean TypeScript
- Video: YouTube series on DDD + Clean Architecture
- Advanced Learni Dev trainings on DDD and hexagonal architecture.