Introduction
Clean Architecture, conceptualisée par Robert C. Martin (Uncle Bob), vise à créer des applications logicielles indépendantes des frameworks, bases de données ou interfaces utilisateur. Imaginez votre code comme une oignon : au cœur, les règles métier pures (entités et use cases) ; à la périphérie, les détails techniques (DB, UI). Pourquoi l'adopter en 2026 ? Elle rend vos apps testables, maintenables et évolutives. Dans ce tutoriel beginner, nous construisons une API TODO simple en TypeScript : création, lecture, mise à jour et suppression de tâches. Chaque couche dépend uniquement de l'intérieur, pas l'inverse. Résultat : un code découplé, facile à refactoriser. Prêt à transformer vos projets monolithiques en architectures solides ? (128 mots)
Prérequis
- Node.js 20+ installé
- Connaissances de base en TypeScript
- Un éditeur comme VS Code
- npm ou yarn pour les dépendances
Initialiser le projet et installer les dépendances
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.tsCette commande initialise un projet Node.js avec TypeScript et Express. Nous créons la structure de dossiers Clean Architecture : domain (règles métier), application (use cases), infrastructure (implémentations), adapters (contrôleurs), presentation (frameworks). Cela sépare les préoccupations dès le départ, évitant les pièges des projets plats.
Étape 1 : Définir les entités du domaine
Les entités forment le cœur de Clean Architecture : objets métier purs, sans dépendances externes. Pour nos TODOs, une entité Todo avec id, titre, description et statut. Pas de DB ou HTTP ici – pure logique métier.
Créer l'entité Todo
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;
}
}L'entité TodoEntity implémente l'interface Todo avec des méthodes métier comme toggle() pour basculer le statut. C'est indépendant : testable sans mocks. Piège à éviter : ne pas ajouter de logique DB ici, sinon vous violez l'indépendance du domaine.
Étape 2 : Définir les repositories (interfaces)
Les repositories sont des interfaces abstraites pour l'accès aux données, définies dans le domaine. Ils exposent des méthodes comme findById() ou save(), sans implémentation. Cela permet d'injecter n'importe quelle DB (InMemory, Mongo, SQL) sans toucher le cœur.
Interface TodoRepository
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>;
}Cette interface définit le contrat pour tout repository Todo. Les use cases l'utiliseront sans connaître l'implémentation. Avantage : inversion de dépendance – le domaine ne dépend pas de l'infrastructure.
Étape 3 : Implémenter les use cases
Les use cases orchestrent la logique applicative : ils utilisent entités et repositories. Exemple : CreateTodoUseCase valide les inputs et persiste via repository. Analogie : un chef d'orchestre qui dirige sans jouer d'instrument.
Use case : Créer un 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;
}
}Ce use case valide les inputs métier, crée l'entité et appelle le repository. Il dépend d'une interface, pas d'une impl spécifique. Installez uuid via npm i uuid @types/uuid si needed. Piège : ne pas gérer les erreurs ici pour éviter la pollution métier.
Use case : Lister les 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 récupération et mapping vers DTO (Data Transfer Object) pour éviter d'exposer l'entité interne. Cela protège le domaine des changements externes.
Étape 4 : Implémenter le repository en mémoire
Dans infrastructure, on implémente le repository pour tests rapides. Plus tard, remplacez par Prisma ou Mongoose sans changer le reste.
Repository InMemory
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);
}
}Implémentation synchrone/asynchrone pour matcher l'interface. Copie défensive avec spread pour findAll(). Parfait pour prototypes ; scalable vers DB réelle.
Étape 5 : Créer les adapters (contrôleurs)
Les adapters traduisent les requêtes HTTP vers use cases. Ils dépendent des use cases, pas l'inverse.
Contrôleur Todo
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);
}
}Le contrôleur injecte les use cases et gère HTTP (status, JSON). Séparation claire : pas de logique métier ici. Ajoutez d'autres méthodes comme update/delete de même.
Étape 6 : Assembler dans le point d'entrée
Enfin, le framework layer (Express) wire tout : instancie repositories et use cases.
Serveur principal
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;Wiring des dépendances par composition. Lancez avec npx ts-node src/presentation/server.ts. Testez POST/GET avec curl ou Postman. Facile à swapper repo pour une DB.
Bonnes pratiques
- Inversion de dépendance : Toujours injecter les interfaces, jamais les implémentations concrètes dans le cœur.
- DTOs partout : N'exposez jamais les entités aux couches externes pour éviter les couplages.
- Tests unitaires : Mockez les repositories pour tester use cases isolément.
- Container IoC : Utilisez InversifyJS ou Awilix pour scaler le wiring.
- Évolutivité : Ajoutez des ports/adapters pour nouveaux drivers (queues, caches).
Erreurs courantes à éviter
- Fuite de framework : Ne pas importer Express dans use cases – brise l'indépendance.
- Entités anémiques : Ajoutez de la logique métier dans entités, pas juste des getters/setters.
- Repository trop gros : Limitez à 5-7 méthodes ; décomposez si needed.
- Pas d'erreurs typées : Utilisez custom Error classes pour une meilleure gestion.
Pour aller plus loin
- Livre : "Clean Architecture" d'Uncle Bob
- Repo GitHub exemple : Clean TypeScript
- Vidéo : Série YouTube sur DDD + Clean Arch
- Formations Learni Dev avancées sur DDD et hexagonal architecture.