La plupart des ingénieurs ont déjà vu l'image de la clean architecture : quatre anneaux colorés avec des flèches pointant vers le centre. Bien moins nombreux sont ceux qui savent dire ce que contient chaque anneau pour une vraie fonctionnalité, dans quel sens les imports doivent pointer ou où se trouve le SQL.
L'écart se voit en code review : un dossier domain qui importe l'ORM, un use case qui renvoie un code de statut HTTP, cinq mappers entre un formulaire et une table.
Cet article transforme les cercles de Robert C. Martin en diagramme de composants pour un seul use case, la passation d'une commande : les couches, la règle de dépendance, le code et les dossiers, les franchissements de frontière, clean face à hexagonal et onion, les erreurs, quand c'est excessif et comment le dessiner.
Les quatre couches
Martin a décrit la clean architecture dans un billet de blog de 2012, The Clean Architecture, puis l'a développée dans son livre de 2017 Clean Architecture. Il l'a présentée comme une synthèse d'idées antérieures, dont l'architecture hexagonale et l'architecture onion. Du centre vers l'extérieur :
| Couche | Ce qu'elle contient | Dans l'exemple | Change quand |
|---|---|---|---|
| Entities | Règles métier de l'entreprise, réutilisables entre applications | Order et sa règle selon laquelle une commande exige au moins une ligne |
Une règle métier change |
| Use cases | Règles métier de l'application, un workflow chacun | PlaceOrder, ses deux interfaces, les modèles de requête et de réponse |
Le workflow change |
| Interface adapters | Contrôleurs, presenters, gateways et tout le SQL | OrderController, OrderPresenter, PostgresOrderRepository |
L'API ou le schéma change |
| Frameworks and drivers | Outils et code de liaison : framework web, base de données, drivers | Le framework HTTP, PostgreSQL, pg, main.ts |
Vous remplacez un outil |
Les cercles sont schématiques ; Martin dit qu'il peut en falloir plus de quatre. La règle qui les relie ne change jamais.
La règle de dépendance
Selon les mots de Martin, « les dépendances du code source ne peuvent pointer que vers l'intérieur ». Le code d'un cercle interne ne mentionne jamais une fonction, une classe ou une variable déclarée dans un cercle externe, ni n'utilise ses formats de données. Pour cet exemple :
entitiesn'importe rien des autres couches.use-casesn'importe queentities: ni framework web, ni ORM, ni driver.- Les
adaptersimportent vers l'intérieur, plus la bibliothèque qu'ils adaptent. - Seul
main.tsvoit tout.
La règle porte sur le code source, pas sur les appels à l'exécution : à l'exécution, PlaceOrder appelle vers l'extérieur, le presenter et la base de données. Martin résout cela avec le principe d'inversion des dépendances : le use case appelle une interface qu'il possède, et une classe externe l'implémente. L'appel sort ; la dépendance pointe vers l'intérieur.
Une dépendance pointe bien vers l'extérieur : l'adapter importe le driver de base de données. C'est voulu. Martin place tout le SQL dans la couche des adapters, et aucun code situé plus à l'intérieur ne devrait « rien savoir de la base de données ».
Faites respecter la règle en CI avec le contrôle onionArchitecture() d'ArchUnit sur la JVM, dependency-cruiser pour TypeScript ou le contrat layers d'import-linter pour Python.
Exemple détaillé : passer une commande
Une requête POST /orders suit ce chemin :
- Le framework web l'achemine vers
OrderController. - Le contrôleur analyse le corps en un
PlaceOrderRequest, un objet simple que possède la couche des use cases, crée unOrderPresenteret appellePlaceOrder.execute(). PlaceOrderlaisseOrder.place()appliquer les règles métier, enregistre viaOrderRepositoryet transmet unPlaceOrderResponseàPlaceOrderOutput.OrderPresenterimplémente cette interface et construit un view model : statut 201, un en-têteLocationet un corps JSON.- Le contrôleur renvoie le view model, et le framework l'écrit.
La couche des use cases, avec les deux interfaces qu'elle possède :
// src/orders/use-cases/place-order.ts
import { Order } from '../entities/order';
export interface OrderRepository { // implemented by an adapter
nextId(): Promise<string>;
save(order: Order): Promise<void>;
}
export interface PlaceOrderOutput { // implemented by the presenter
present(response: PlaceOrderResponse): void;
}
export type PlaceOrderRequest = {
customerId: string;
lines: { sku: string; quantity: number }[];
};
export type PlaceOrderResponse = { orderId: string; itemCount: number };
export class PlaceOrder {
constructor(private readonly orders: OrderRepository) {}
async execute(request: PlaceOrderRequest, output: PlaceOrderOutput): Promise<void> {
const order = Order.place(await this.orders.nextId(), request.customerId, request.lines);
await this.orders.save(order);
output.present({ orderId: order.id, itemCount: order.itemCount() });
}
}
Elle n'importe que l'entity. Order.place() lève une exception quand une commande n'a aucune ligne ou une quantité inférieure à un, et le fichier de l'entity n'importe rien. L'adapter importe vers l'intérieur, plus le driver :
// src/orders/adapters/postgres/postgres-order-repository.ts
import { randomUUID } from 'node:crypto';
import type { Pool } from 'pg'; // adapters may import the driver
import type { Order } from '../../entities/order';
import type { OrderRepository } from '../../use-cases/place-order';
export class PostgresOrderRepository implements OrderRepository {
constructor(private readonly pool: Pool) {}
async nextId(): Promise<string> { return randomUUID(); }
async save(order: Order): Promise<void> {
await this.pool.query('INSERT INTO orders (id, customer_id, lines) VALUES ($1, $2, $3)',
[order.id, order.customerId, JSON.stringify(order.lines)]);
}
}
Le composition root est seul à connaître chaque classe concrète :
// src/main.ts
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const controller = new OrderController(new PlaceOrder(new PostgresOrderRepository(pool)));
app.post('/orders', async (req, res) => {
const view = await controller.post(req.body); // the presenter's view model
res.status(view.status).set(view.headers).json(view.body);
});
Un test unitaire de PlaceOrder n'a besoin que d'un repository en mémoire et d'un presenter qui enregistre son entrée : pas de serveur, pas de base de données.
Structure des dossiers
src/
orders/
entities/
order.ts Order and its rules
use-cases/
place-order.ts PlaceOrder, both interfaces, request and response models
adapters/
http/
order-controller.ts
order-presenter.ts
postgres/
postgres-order-repository.ts
main.ts composition root: builds and wires everything
Regroupez par fonctionnalité, puis par couche. L'argument de la screaming architecture de Martin : les dossiers de premier niveau doivent dire ce que fait le système (orders, payments), pas quel framework il utilise. Le chapitre de Simon Brown dans le même livre sépare l'organisation de l'encapsulation : les dossiers organisent le code, tandis que des règles d'accès ou un contrôle de build le font respecter.
Franchir les frontières
Chaque franchissement de frontière transporte un type que possède une couche :
| Franchissement | Ce qui circule | Type possédé par | Mécanisme |
|---|---|---|---|
| Framework → contrôleur | Requête HTTP | Framework | Code de liaison dans main.ts |
| Contrôleur → use case | PlaceOrderRequest |
Use cases | Appel direct |
| Use case → presenter | PlaceOrderResponse |
Use cases | Interface PlaceOrderOutput |
| Use case → base de données | Order |
Entities | Interface OrderRepository |
| Presenter → framework | View model | Adapters | Renvoyé par le contrôleur |
Trois habitudes gardent ces franchissements propres :
- Des modèles de requête et de réponse simples. Martin est direct : « Nous ne voulons pas tricher et passer des entities ou des lignes de base de données. » Le presenter reçoit
PlaceOrderResponse, jamaisOrder, donc renommer un champ de l'entity laisse l'API intacte. Les repositories sont une exception courante : de nombreuses bases de code les laissent accepter des entities, ce qui pointe toujours vers l'intérieur. Les équipes plus strictes font la correspondance avec un enregistrement de persistance dans l'adapter. - Une interface à chaque appel vers l'extérieur, possédée par la couche interne et nommée d'après ce dont le use case a besoin, pas d'après la technologie.
- Un seul composition root. Mark Seemann définit le composition root comme « un emplacement (de préférence) unique dans une application où les modules sont composés ensemble », près du point d'entrée. Martin appelle
main« le détail ultime ». Tout le reste reçoit ses dépendances par les constructeurs.
La figure de Martin comporte aussi une interface Use Case Input Port devant l'interactor. Avec un seul contrôleur et une seule implémentation, appeler directement la classe est une simplification courante.
Clean, hexagonale et onion : comparaison
| Hexagonale (ports et adapters) | Onion | Clean | |
|---|---|---|---|
| Texte fondateur | Alistair Cockburn, article de 2005 | Jeffrey Palermo, série de billets de 2008 | Robert C. Martin, billet de 2012, livre de 2017 |
| Image | Application dans un hexagone, adapters à l'extérieur | Anneaux autour du modèle de domaine | Quatre cercles concentriques |
| Structure interne | Non prescrite | Modèle de domaine, services de domaine, services applicatifs | Entities, use cases |
| Implémentation du repository | Adapter secondaire (piloté) | Infrastructure, anneau externe | Interface adapters |
| Règle centrale | Le code interne ne doit pas fuiter vers l'extérieur ; il passe par des ports | Tout couplage pointe vers le centre | Les dépendances du code source pointent vers l'intérieur |
Les trois placent les règles métier au centre et font dépendre les bords d'elles. Elles diffèrent par la quantité de structure prescrite à l'intérieur : l'article original de Cockburn sépare seulement l'intérieur de l'extérieur, l'onion architecture de Palermo nomme des anneaux autour du modèle de domaine, et la clean architecture ajoute les presenters et les modèles de requête et de réponse. Le diagramme d'architecture hexagonale parcourt un exemple de commande similaire en termes de ports et d'adapters.
Erreurs courantes
- Des types de framework dans les entities. Des décorateurs d'ORM ou des attributs de sérialisation sur
Orderlient la couche la plus interne à la plus externe. Faites la correspondance dans l'adapter. - Un mapper pour tout. Six copies des mêmes champs pour un formulaire qui enregistre une ligne. Ne faites la correspondance que là où les formes diffèrent.
- Des couches sans comportement. Des entities réduites à des getters et des setters, le modèle de domaine anémique décrit par Martin Fowler en 2003, et des use cases qui ne font qu'appeler
save(). - Des interfaces partout. Elles appartiennent aux frontières, pas à chaque classe.
- Du HTTP dans le use case. Les codes de statut appartiennent au presenter.
Quand c'est excessif
Palermo a écrit que l'onion architecture « ne convient pas aux petits sites web », et le chapitre de Martin sur les frontières partielles s'ouvre sur « Les frontières architecturales complètes coûtent cher ».
| Situation | Meilleure option |
|---|---|
| Service CRUD avec peu de règles | Une application en couches légère ; gardez les quelques règles hors des contrôleurs |
| Prototype ou outil éphémère | Aucune couche |
| Une dépendance volatile, comme un prestataire de paiement | Une interface et un adapter pour elle |
| Produit durable avec de vraies règles et plusieurs points d'entrée | Les quatre couches |
Ce chapitre décrit aussi des frontières partielles moins coûteuses ; promouvez-en une quand une seconde implémentation ou un test en a besoin.
Le diagramme de clean architecture
Contenu du diagramme
- Frameworks and drivers
- Interface adapters
- Use cases
- Entities
- Web framework
- PostgreSQL
- OrderPresenter
- OrderController
- PostgresOrderRepository
- PlaceOrderOutput
- PlaceOrder
- OrderRepository
- Order
- POST /orders
- execute(request)
- Order.place()
- reads view model
- present(response)
- save(order)
- implements
- SQL via pg driver
Comment le lire :
- Quatre paquetages UML, le plus externe à gauche. Aller vers la droite, c'est aller vers l'intérieur.
- Les flèches pleines sont des appels et des imports. Trois franchissent une frontière vers les entities. Les trois autres restent dans une seule couche :
present(response)etsave(order)appellent les deux interfaces que possède la couche des use cases. - Les flèches en pointillés avec triangles creux signifient implémente. Le presenter et le repository pointent vers l'intérieur sur ces interfaces alors que le contrôle sort.
- La ligne en points est du SQL via le driver, et elle part d'un adapter.
- Les notes et le bloc Markdown disent où vit
main.ts, ce que l'entity impose et ce que signifie chaque style de ligne.
Dessiner clairement des diagrammes de clean architecture
- Dessinez un use case, pas les anneaux. Suivez une vraie fonctionnalité avec de vrais noms de classes.
- Placez chaque interface dans la couche qui la possède.
OrderRepositoryse trouve à côté dePlaceOrder, pas à côté de son implémentation. - Utilisez deux styles de flèche : pleine pour les appels, en pointillés avec un triangle creux pour implémente, comme dans la réalisation UML. L'exemple de diagramme de classes UML couvre la notation.
- Donnez une seule direction à l'intérieur sur la page. Une flèche qui revient est une violation ou un appel à l'exécution à étiqueter.
- Étiquetez les flèches avec la méthode et le modèle qui franchit la frontière.
- Omettez les champs des DTO, les éléments internes des frameworks et le conteneur d'injection de dépendances.
Pour le système autour de cette vue au niveau du code, consultez l'exemple de diagramme du modèle C4 et le guide pour dessiner des diagrammes d'architecture logicielle.
Le construire dans Archboard
- Connectez-vous avec Google ou GitHub et créez un diagramme.
- Ajoutez quatre nœuds Paquetage du pack UML côte à côte : Frameworks and drivers, Interface adapters, Use cases et Entities, avec des accents bleu, vert, rouge et ambre.
- Dans Frameworks and drivers, ajoutez un Composant du pack UML nommé Web framework et un nœud Postgres du pack Données nommé PostgreSQL.
- Dans Interface adapters, ajoutez deux nœuds Composant, OrderPresenter et OrderController, et une Classe nommée PostgresOrderRepository.
- Dans Use cases, ajoutez une Classe nommée PlaceOrder entre deux nœuds Interface, PlaceOrderOutput et OrderRepository.
- Dans Entities, ajoutez une Classe nommée Order avec ses champs et ses méthodes.
- Tracez six arêtes pleines étiquetées pour les appels, deux arêtes en pointillés avec des pointes en triangle pour implémente, et une arête en points de PostgresOrderRepository vers PostgreSQL.
- Ajoutez un bloc Markdown du pack Base avec la règle et les styles de ligne, et des notes Texte du pack Base pour chaque couche.
Un organigramme Mermaid de vos couches devient un premier brouillon avec l'outil de conversion de Mermaid en diagramme modifiable. L'offre Free autorise 15 nœuds de diagramme par diagramme ; suivez donc un use case par diagramme.
L'exemple utilise 14 nœuds de diagramme et reste dans les limites de l'offre Free.
FAQ
Quelles sont les quatre couches de la clean architecture ? Entities (règles métier de l'entreprise), use cases (règles métier de l'application), interface adapters (contrôleurs, presenters et gateways) et frameworks and drivers (le framework web, la base de données et d'autres outils). Martin dit que les cercles sont schématiques : il peut en falloir plus de quatre.
Qu'est-ce que la règle de dépendance ? Les dépendances du code source pointent uniquement vers l'intérieur. Le code d'une couche interne ne nomme jamais rien de déclaré dans une couche externe, donc les use cases ne savent rien des contrôleurs, du SQL ni du framework web.
Quelle est la différence entre clean architecture et architecture hexagonale ? Toutes deux gardent les règles métier au centre et font dépendre les adapters d'elles. L'architecture hexagonale sépare seulement l'intérieur de l'extérieur, au moyen de ports. La clean architecture divise aussi l'intérieur en entities et use cases, et nomme les presenters ainsi que les modèles de requête et de réponse.
Où place-t-on l'interface du repository en clean architecture ? Dans la couche des use cases, à côté du use case qui en a besoin. L'implémentation, avec le SQL, appartient à la couche des interface adapters, et le composition root la transmet au démarrage.
La clean architecture est-elle excessive pour les petits projets ? Souvent. Pour les services CRUD, les prototypes et les outils éphémères, les interfaces et les conversions supplémentaires coûtent plus qu'elles ne rapportent. Ajoutez des interfaces pour les dépendances que vous pensez voir changer, et faites grandir les couches quand de vraies règles métier apparaissent.