Introduction
Clean Architecture, popularized by Robert C. Martin (Uncle Bob), organizes code into concentric layers to maximize maintainability and testability. At the center are business entities independent of any frameworks. Surrounding them, use cases orchestrate business logic. Next come adapters (controllers, repositories) that interface with the outside world, and finally frameworks/drivers (databases, UI).
Why adopt it in 2026? Modern apps evolve rapidly: APIs, microservices, AI. Clean Architecture keeps code technology-independent (switch DBs without altering business logic), easy to test (mock dependencies), and scalable (add features without rewrites). Picture a TODO app: the 'create task' logic stays untouched when moving from SQLite to PostgreSQL.
This beginner tutorial builds a TypeScript/Node.js REST TODO API. Complete, working code: copy-paste and run in 10 minutes. We progress from entities to controllers. (128 words)
Prerequisites
- Node.js 20+ installed
- Basic knowledge of TypeScript and Express
- An editor like VS Code
- Terminal to run commands
The 4 Layers of Clean Architecture
Think of Clean Architecture as an onion: the core (entities) is pure, outer layers depend on inner ones, never the reverse (dependency rule: arrows point inward).
- Entities: Pure business objects (e.g., Todo with id, title, done).
- Use Cases: Application logic (e.g., create a Todo).
- Interface Adapters: Translators (HTTP controllers → use cases, repositories implementing interfaces).
- Frameworks & Drivers: Express, databases (depend on adapters).
Define the Todo Entity
export interface Todo {
id: string;
title: string;
description?: string;
done: boolean;
createdAt: Date;
}
export class TodoEntity implements Todo {
public readonly id: string;
public readonly title: string;
public readonly description?: string;
public done: boolean;
public readonly createdAt: Date;
constructor(id: string, title: string, description?: string, done = false) {
this.id = id;
this.title = title;
this.description = description;
this.done = done;
this.createdAt = new Date();
}
markAsDone(): void {
this.done = true;
}
toggleDone(): void {
this.done = !this.done;
}
}This entity encapsulates the Todo's data and business behaviors (e.g., toggleDone). It's pure: no DB, no HTTP. Use a class for partial immutability and business methods. Pitfall: avoid direct getters/setters; prefer explicit methods.
Implement Use Cases
Use cases act as orchestrators: they use entities and depend on interfaces (dependency inversion). Example: CreateTodoUseCase calls a repository (interface only, no implementation).
Repository Interface
import { Todo } from '../entities/Todo';
export interface TodoRepository {
save(todo: Todo): Promise<Todo>;
findById(id: string): Promise<Todo | null>;
findAll(): Promise<Todo[]>;
delete(id: string): Promise<void>;
}The interface defines the contract without implementation. The use case depends on this interface (injected), not a specific DB. This makes testing easy with mocks.
Use Case: Create a Todo
import { v4 as uuidv4 } from 'uuid';
import { Todo, TodoEntity } from '../../domain/entities/Todo';
import { TodoRepository } from '../../domain/repositories/TodoRepository';
export class CreateTodoUseCase {
constructor(private todoRepository: TodoRepository) {}
async execute(title: string, description?: string): Promise<Todo> {
if (!title || title.trim().length < 3) {
throw new Error('Le titre doit faire au moins 3 caractères');
}
const id = uuidv4();
const todo = new TodoEntity(id, title.trim(), description?.trim());
const savedTodo = await this.todoRepository.save(todo);
return savedTodo;
}
}This use case validates inputs, creates the entity, and persists via the repository. Repository injected in constructor for testability. Pitfall: always validate here, not in the controller.
Use Case: List Todos
import { Todo } from '../../domain/entities/Todo';
import { TodoRepository } from '../../domain/repositories/TodoRepository';
export class GetTodosUseCase {
constructor(private todoRepository: TodoRepository) {}
async execute(): Promise<Todo[]> {
return await this.todoRepository.findAll();
}
}Simple: calls the repo and returns data. No complex business logic here, but extensible (e.g., future filters).
Interface Adapters
Adapters convert between layers: controllers (HTTP → use cases), repositories (use cases → DB). We'll first implement an in-memory repo for testing.
In-Memory 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 delete(id: string): Promise<void> {
const index = this.todos.findIndex(t => t.id === id);
if (index > -1) {
this.todos.splice(index, 1);
}
}
}Implements the interface with in-memory storage. Defensive copy in findAll for immutability. Easy to replace later with PostgreSQLRepository.
Express 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 todo = await this.createTodoUseCase.execute(title, description);
res.status(201).json(todo);
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
}
async list(req: Request, res: Response): Promise<void> {
try {
const todos = await this.getTodosUseCase.execute();
res.json(todos);
} catch (error) {
res.status(500).json({ error: 'Erreur serveur' });
}
}
}Controller translates HTTP to use cases (injected). Handles HTTP errors. No business logic here: pure adapter role.
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.list(req, res));
const PORT = 3000;
app.listen(PORT, () => {
console.log(`Serveur sur http://localhost:${PORT}`);
});Wires dependencies: repo → use cases → controller → Express. Outer layer depends on inner ones. Run with ts-node src/main.ts after npm i express uuid ts-node @types/express @types/node.
Test the Implementation
Install deps: npm init -y && npm i express uuid ts-node typescript @types/node @types/express. Create folders like src/domain, src/application, etc. Run npx ts-node src/main.ts. Test:
- POST /todos {title:'Buy milk'} → 201
- GET /todos → list.
Perfect for beginners: everything works!
Best Practices
- Dependency Inversion: Use cases depend on interfaces, not implementations.
- Single Responsibility per Layer: Entities = pure business, use cases = orchestration.
- Unit Tests: Easily mock repos/interfaces.
- Clear Naming: domain/ for core, infrastructure/ for DB.
- Scalability: Add PrismaRepository without touching the core.
Common Mistakes to Avoid
- Inverted Dependencies: Don't put DB code in entities.
- Bloated Controllers: Don't validate inputs there; delegate to use cases.
- Missing Interfaces: Always define TodoRepository before implementations.
- Mutable State: Use readonly on entities where possible.
Next Steps
- Book: "Clean Architecture" by Uncle Bob.
- Video: Uncle Bob Conference.
- Implement a real DB (Prisma).
- Check our advanced architecture courses at Learni.