Clean Architecture : arrêtez de mélanger votre code !
En programmation informatique, la Clean Architecture est une manière d'organiser le code d'une application afin de séparer clairement les responsabilités.
L'objectif est de développer des logiciels plus :
- maintenables ;
- testables ;
- évolutifs ;
- robustes ;
- indépendants des frameworks ;
- indépendants de la base de données ;
- indépendants de l'interface utilisateur.
La Clean Architecture a été popularisée par Robert C. Martin, aussi connu sous le nom de Uncle Bob. Elle repose sur une idée centrale :
Le code métier ne doit pas dépendre des détails techniques.
Autrement dit, le cœur de l'application ne devrait pas savoir si l'application utilise Express, PostgreSQL, MongoDB, Prisma, React, Vue, Angular ou n'importe quelle autre technologie.
Le problème
Dans beaucoup d'applications, on retrouve rapidement ce genre de code :
app.post('/users', async (request, response) => {
const user = await prisma.user.create({
data: {
email: request.body.email,
name: request.body.name
}
})
await sendEmail(user.email)
response.status(201).json(user)
})
Ce code fonctionne.
Mais il mélange plusieurs responsabilités :
- la gestion HTTP ;
- la récupération des données de la requête ;
- la logique de création d'un utilisateur ;
- l'accès à la base de données ;
- l'envoi d'un email ;
- la réponse HTTP.
Cela signifie que si une règle métier change, on risque de modifier du code HTTP. Si la base de données change, on risque de modifier la route. Si le mode d'envoi d'email change, on risque encore de modifier cette même fonction.
Le problème n'est donc pas uniquement technique. Le problème est architectural.
L'idée de la Clean Architecture
La Clean Architecture propose de placer le métier au centre de l'application.
Les détails techniques sont repoussés vers l'extérieur.
On peut représenter cela de manière simplifiée ainsi :
Le sens des dépendances est très important.
Le domaine ne dépend de rien.
Les couches externes peuvent dépendre des couches internes, mais l'inverse est interdit.
Les couches principales
Une application organisée en Clean Architecture peut être découpée en quatre grandes couches :
Chaque couche a une responsabilité précise.
domain
La couche domain contient le cœur métier de l'application.
C'est la couche la plus importante.
Elle contient par exemple :
- les entités ;
- les value objects ;
- les règles métier ;
- les contrats nécessaires au métier ;
- les services métier purs.
Exemple :
Une entité User pourrait ressembler à ceci :
export class User {
private constructor(
public readonly id: string,
public readonly email: string,
public readonly name: string
) {}
static create(email: string, name: string): User {
if (!email.includes('@')) {
throw new Error('Invalid email')
}
return new User(crypto.randomUUID(), email, name)
}
}
La classe User contient une règle métier simple : un utilisateur doit avoir un
email valide.
Cette règle ne dépend pas d'Express, de Prisma, d'une base de données ou d'une API externe.
C'est du métier.
Ce que le domaine ne doit pas faire
Dans la couche domain, on ne devrait pas trouver :
import express from 'express'
import { PrismaClient } from '@prisma/client'
import axios from 'axios'
Le domaine ne doit pas connaître les détails techniques.
Cela signifie que le domaine ne doit pas :
- manipuler une requête HTTP ;
- retourner une réponse HTTP ;
- utiliser un ORM ;
- lire une variable d'environnement ;
- appeler une API externe ;
- écrire dans un fichier ;
- dépendre d'un framework.
Si le domaine fait cela, alors il devient couplé à l'infrastructure.
application
La couche application contient les cas d'utilisation de l'application.
Un cas d'utilisation représente une action que le système peut effectuer.
Par exemple :
- créer un utilisateur ;
- modifier une adresse email ;
- publier un article ;
- payer une commande ;
- supprimer un commentaire.
Exemple :
Un cas d'utilisation pourrait ressembler à ceci :
import { User } from '../../domain/entities/User'
import type { UserRepository } from '../../domain/ports/UserRepository'
import type { CreateUserInput } from '../dto/CreateUserInput'
import type { CreateUserOutput } from '../dto/CreateUserOutput'
export class CreateUserUseCase {
constructor(private readonly userRepository: UserRepository) {}
async execute(input: CreateUserInput): Promise<CreateUserOutput> {
const user = User.create(input.email, input.name)
await this.userRepository.save(user)
return {
id: user.id,
email: user.email,
name: user.name
}
}
}
Ici, le cas d'utilisation orchestre le métier.
Il ne sait pas comment l'utilisateur sera sauvegardé.
Il sait seulement qu'il dispose d'un UserRepository.
ports
Le dossier ports contient les contrats dont le domaine ou l'application ont
besoin.
Par exemple :
import type { User } from '../entities/User'
export interface UserRepository {
save(user: User): Promise<void>
findByEmail(email: string): Promise<User | null>
}
Cette interface ne dit pas si les utilisateurs sont sauvegardés dans PostgreSQL, MongoDB, Redis ou un fichier JSON.
Elle dit simplement :
Pour fonctionner, l'application a besoin de pouvoir sauvegarder et rechercher des utilisateurs.
C'est une application du principe d'inversion des dépendances.
Les couches internes ne dépendent pas des implémentations concrètes. Elles dépendent de contrats.
infrastructure
La couche infrastructure contient les détails techniques.
C'est ici que l'on place :
- les accès base de données ;
- les implémentations de repositories ;
- les appels à des APIs externes ;
- les SDK ;
- les clients HTTP ;
- les services d'email ;
- les systèmes de fichiers ;
- les outils de cache ;
- les adapters techniques.
Exemple :
Une implémentation concrète du repository pourrait ressembler à ceci :
import type { User } from '../../domain/entities/User'
import type { UserRepository } from '../../domain/ports/UserRepository'
import { prisma } from './prisma'
export class PrismaUserRepository implements UserRepository {
async save(user: User): Promise<void> {
await prisma.user.create({
data: {
id: user.id,
email: user.email,
name: user.name
}
})
}
async findByEmail(email: string): Promise<User | null> {
const user = await prisma.user.findUnique({
where: { email }
})
if (!user) {
return null
}
return User.create(user.email, user.name)
}
}
Cette classe connaît Prisma.
C'est normal.
Elle est dans l'infrastructure.
Le domaine, lui, ne connaît pas Prisma.
presentation
La couche presentation contient les points d'entrée de l'application.
Dans une API HTTP, on y trouve généralement :
- les contrôleurs ;
- les routes ;
- les validateurs de requêtes ;
- les middlewares HTTP ;
- les presenters ;
- les serializers.
Exemple :
Un contrôleur pourrait ressembler à ceci :
import type { Request, Response } from 'express'
import type { CreateUserUseCase } from '../../application/use-cases/CreateUserUseCase'
export class CreateUserController {
constructor(private readonly createUserUseCase: CreateUserUseCase) {}
async handle(request: Request, response: Response): Promise<void> {
const output = await this.createUserUseCase.execute({
email: request.body.email,
name: request.body.name
})
response.status(201).json(output)
}
}
Le contrôleur connaît Express.
C'est acceptable, car il se trouve dans la couche presentation.
En revanche, il ne doit pas contenir de logique métier.
Il ne devrait pas faire directement :
S'il fait cela, il court-circuite l'application et viole l'architecture.
Structure par couche
Une première manière d'organiser le projet est de séparer les dossiers par couche :
src/
domain/
entities/
value-objects/
ports/
application/
use-cases/
dto/
infrastructure/
persistence/
email/
http/
presentation/
controllers/
routes/
Cette structure est simple à comprendre.
Elle fonctionne bien pour une petite application ou pour apprendre les concepts.
Cependant, lorsque le projet grossit, tous les modules fonctionnels se retrouvent mélangés dans les mêmes dossiers.
Par exemple, les fichiers liés aux utilisateurs, aux commandes, aux paiements et
aux articles peuvent se retrouver dans les mêmes dossiers entities,
use-cases, controllers, etc.
Structure par module
Dans un projet plus important, on peut préférer une organisation par module fonctionnel :
src/
modules/
users/
domain/
entities/
value-objects/
ports/
application/
use-cases/
dto/
infrastructure/
persistence/
presentation/
controllers/
routes.ts
posts/
domain/
application/
infrastructure/
presentation/
Cette structure rend les frontières métier plus visibles.
Chaque module contient ses propres couches.
Cela permet aussi de limiter le risque de modifier un domaine fonctionnel en touchant à un autre.
Pour un projet pédagogique ou pour un framework qui veut imposer une Clean Architecture, cette structure est souvent plus intéressante.
Les règles de dépendance
La règle la plus importante de la Clean Architecture est la suivante :
Les dépendances doivent toujours aller vers l'intérieur.
Cela signifie :
Mais cela interdit :
domain -> application
domain -> infrastructure
domain -> presentation
application -> infrastructure
application -> presentation
Autrement dit :
- le domaine ne connaît pas l'application ;
- le domaine ne connaît pas l'infrastructure ;
- le domaine ne connaît pas la présentation ;
- l'application ne connaît pas Express ;
- l'application ne connaît pas Prisma ;
- les contrôleurs ne doivent pas accéder directement à la base de données.
Exemple d'une mauvaise dépendance
Prenons un cas d'utilisation qui importe Prisma directement :
import { PrismaClient } from '@prisma/client'
export class CreateUserUseCase {
private readonly prisma = new PrismaClient()
async execute(input: CreateUserInput): Promise<void> {
await this.prisma.user.create({
data: {
email: input.email,
name: input.name
}
})
}
}
Ce code fonctionne.
Mais il pose un problème.
Le cas d'utilisation dépend directement de Prisma.
Cela signifie que si l'on veut remplacer Prisma, écrire un test unitaire, ou changer la manière dont les utilisateurs sont sauvegardés, il faudra modifier le cas d'utilisation.
Nous sommes donc en violation du principe d'inversion des dépendances.
Correction
On peut corriger cela avec un contrat :
Puis le cas d'utilisation dépend de ce contrat :
export class CreateUserUseCase {
constructor(private readonly userRepository: UserRepository) {}
async execute(input: CreateUserInput): Promise<void> {
const user = User.create(input.email, input.name)
await this.userRepository.save(user)
}
}
Prisma devient un détail d'implémentation :
export class PrismaUserRepository implements UserRepository {
async save(user: User): Promise<void> {
await prisma.user.create({
data: {
id: user.id,
email: user.email,
name: user.name
}
})
}
}
Le cœur de l'application ne dépend plus de Prisma.
Il dépend d'un contrat.
Pourquoi utiliser la Clean Architecture ?
La Clean Architecture apporte plusieurs avantages.
Tester plus facilement
Un cas d'utilisation peut être testé sans base de données.
class InMemoryUserRepository implements UserRepository {
private readonly users: User[] = []
async save(user: User): Promise<void> {
this.users.push(user)
}
async findByEmail(email: string): Promise<User | null> {
return this.users.find((user) => user.email === email) ?? null
}
}
Dans un test, on peut utiliser cette version en mémoire au lieu d'utiliser une vraie base de données.
Cela rend les tests plus rapides, plus simples et plus fiables.
Changer une technologie plus facilement
Si l'application dépend directement de Prisma partout, remplacer Prisma devient difficile.
Si Prisma est isolé dans l'infrastructure, il devient plus simple de remplacer :
par :
ou :
Le cas d'utilisation ne change pas.
Limiter le désordre
La Clean Architecture force les développeurs à se demander :
Dans quelle couche ce code doit-il être placé ?
Cette question est très importante.
Elle évite de mettre de la logique métier dans les contrôleurs, de la logique HTTP dans les entités, ou de la logique SQL dans les cas d'utilisation.
Attention au piège du sur-découpage
La Clean Architecture n'est pas une excuse pour créer 40 fichiers pour une fonctionnalité simple.
Il faut rester pragmatique.
Pour une petite application, il n'est pas toujours nécessaire d'avoir :
Le but n'est pas de multiplier les dossiers.
Le but est de protéger le métier des détails techniques.
Une bonne question à se poser est :
Est-ce que cette séparation rend le code plus clair ou est-ce qu'elle ajoute seulement de la complexité ?
Conclusion
La Clean Architecture n'est pas une mode ni une simple structure de dossiers.
C'est une manière de protéger le cœur de l'application.
Elle repose sur une règle simple :
Les détails techniques doivent dépendre du métier, pas l'inverse.
En appliquant cette idée, on obtient un code plus facile à tester, plus facile à faire évoluer et plus résistant aux changements techniques.
Le but n'est pas de créer une architecture compliquée.
Le but est d'éviter que tout finisse mélangé dans les routes, les contrôleurs ou les services.