Skip to content
Learni
Voir tous les tutoriels
Architecture

Comment implémenter Clean Architecture en 2026

Read in English

Introduction

Clean Architecture, popularisée par Robert C. Martin (Uncle Bob), est une approche qui organise le code en couches concentriques pour maximiser la maintenabilité et la testabilité. Au centre, les entités métier indépendantes de tout framework. Autour, les use cases orchestrent la logique métier. Puis viennent les adapters (controllers, repositories) qui traduisent vers l'extérieur, et enfin les frameworks/drivers (base de données, UI).

Pourquoi l'adopter en 2026 ? Les apps modernes évoluent vite : APIs, microservices, IA. Clean Architecture rend le code indépendant des technologies (changez de DB sans toucher la logique métier), facile à tester (mockez les dépendances) et évolutif (ajoutez des features sans réécrire). Imaginez une app TODO : la logique 'créer une tâche' reste intacte si vous passez de SQLite à PostgreSQL.

Ce tutoriel beginner implémente une API REST TODO en TypeScript/Node.js. Code complet, fonctionnel : copiez-collez et lancez en 10 min. On progresse des entités aux controllers. (128 mots)

Prérequis

  • Node.js 20+ installé
  • Connaissances basiques en TypeScript et Express
  • Un éditeur comme VS Code
  • Terminal pour exécuter les commandes

Les 4 couches de Clean Architecture

Visualisez Clean Architecture comme un oignon : le cœur (entités) est pur, les couches extérieures dépendent des intérieures, jamais l'inverse (règle de dépendance : flèches pointent vers le centre).

  1. Entities : Objets métier purs (ex: Todo avec id, title, done).
  2. Use Cases : Logique applicative (ex: créer un Todo).
  3. Interface Adapters : Traducteurs (controllers HTTP → use cases, repositories implémentant des interfaces).
  4. Frameworks & Drivers : Express, DB (dépendent des adapters).
On commence par le centre.

Définir l'entité Todo

src/domain/entities/Todo.ts
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;
  }
}

Cette entité encapsule les données et comportements métier d'un Todo (ex: toggleDone). Elle est pure : pas de DB, pas de HTTP. Utilisez une classe pour l'immutabilité partielle et méthodes métier. Piège : évitez les getters/setters directs, préférez des méthodes explicites.

Implémenter les Use Cases

Les use cases sont les orchestrateurs : ils utilisent les entités et dépendent d'interfaces (inversion de dépendance). Exemple : CreateTodoUseCase appelle un repository (interface, pas implémentation).

Interface Repository

src/domain/repositories/TodoRepository.ts
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>;
}

L'interface définit le contrat sans implémentation. Le use case dépend de cette interface (injectée), pas d'une DB spécifique. Cela permet de tester facilement avec des mocks.

Use Case : Créer un Todo

src/application/use-cases/CreateTodoUseCase.ts
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;
  }
}

Ce use case valide les inputs, crée l'entité et persiste via le repository. Injection du repo en constructeur pour testabilité. Piège : toujours valider ici, pas dans le controller.

Use Case : Lister les Todos

src/application/use-cases/GetTodosUseCase.ts
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 : appelle le repo et retourne les données. Pas de logique métier complexe ici, mais extensible (ex: filtres futurs).

Les Interface Adapters

Adapters convertissent : controllers (HTTP → use cases), repositories (use cases → DB). On implémente d'abord un repo in-memory pour tester.

Repository In-Memory

src/infrastructure/repositories/InMemoryTodoRepository.ts
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);
    }
  }
}

Implémente l'interface avec stockage en mémoire. Copie defensive dans findAll pour immutabilité. Facile à remplacer par PostgreSQLRepository plus tard.

Controller Express

src/adapters/controllers/TodoController.ts
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 traduit HTTP vers use cases (injection). Gère erreurs HTTP. Pas de logique métier ici : rôle pur adaptateur.

Serveur principal

src/main.ts
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}`);
});

Wiring des dépendances : repo → use cases → controller → Express. Couche externe dépend des intérieures. Lancez avec ts-node src/main.ts après npm i express uuid ts-node @types/express @types/node.

Tester l'implémentation

Installez les deps : npm init -y && npm i express uuid ts-node typescript @types/node @types/express. Créez les dossiers src/domain, src/application, etc. Lancez npx ts-node src/main.ts. Testez :

  • POST /todos {title:'Acheter lait'} → 201
  • GET /todos → liste.

Parfait pour beginner : tout marche !

Bonnes pratiques

  • Inversion de dépendance : use cases dépendent d'interfaces, pas d'implémentations.
  • Une responsabilité par couche : entités = métier pur, use cases = orchestration.
  • Tests unitaires : mockez les repos/interfaces facilement.
  • Nommage clair : domain/ pour cœur, infrastructure/ pour DB.
  • Évolutivité : ajoutez PrismaRepository sans toucher le cœur.

Erreurs courantes à éviter

  • Dépendances inversées : ne mettez pas de DB dans les entités.
  • Controllers obèses : ne validez pas les inputs là, déléguez aux use cases.
  • Oubli des interfaces : toujours définir TodoRepository avant implémentations.
  • États mutables : protégez les entités avec readonly où possible.

Pour aller plus loin