Domain-Driven Design
Chapitre 6 / 10 · 12 min de lecture

Model-Driven Design & architecture

Lier intimement le modèle au code et l'isoler de la technique via une architecture en couches, puis hexagonale.

Un modèle de domaine peut être un superbe diagramme de classes couvrant un mur entier, fruit de mois de recherche, fidèle à la nature du domaine — et pourtant ne donner aucune prise sur le code. Evans raconte avoir vu deux projets opposés — l'un parti d'un modèle papier soigné, l'autre d'un tas de code accrété sans modélisation — aboutir au même résultat : un logiciel fonctionnel mais boursouflé, incompréhensible et, à terme, impossible à maintenir.

La leçon est dérangeante. Un beau modèle sur papier ne vaut rien s'il n'aide pas à produire un logiciel qui tourne ; et du code écrit sans modèle ne capture aucun sens. Ce chapitre traite de la discipline qui réconcilie les deux — la conception pilotée par le modèle (Model-Driven Design) — puis de l'architecture qui la rend possible en isolant le domaine. Les exemples du livre sont en Java ; nous les traduisons en TypeScript idiomatique, dans des univers de logistique, de banque et d'e-commerce.

Le piège du modèle d'analyse séparé

Beaucoup de processus produisent un modèle d'analyse distinct de la conception, souvent élaboré par d'autres personnes. On l'appelle ainsi parce qu'il organise les concepts du domaine métier sans aucune considération pour le rôle qu'ils joueront dans un logiciel : un outil de compréhension pure, qu'on croirait souillé par des préoccupations d'implémentation.

Le problème est que ce modèle n'a pas été pensé avec les contraintes de la conception en tête : il est donc le plus souvent impraticable. Au moment de coder, les développeurs découvrent que l'écheveau d'associations, navigable par un analyste, ne se traduit pas en unités stockables avec intégrité transactionnelle. Le modèle ne guide pas l'implémentation.

Attention

Si une partie centrale de la conception ne correspond pas au modèle conceptuel du domaine, ce modèle est sans valeur et la justesse du logiciel devient suspecte. Une fracture mortelle s'ouvre entre analyse et conception : les éclairages gagnés dans l'une ne nourrissent jamais l'autre.

Les développeurs sont alors forcés de re-conceptualiser le domaine eux-mêmes, sans garantie de retrouver les intuitions des analystes. Pire, le modèle d'analyse pur échoue même dans son but premier — comprendre le domaine — car les découvertes cruciales émergent pendant l'implémentation, face à des problèmes que personne n'avait anticipés.

Model-Driven Design : un seul modèle

La conception pilotée par le modèle (Model-Driven Design) abandonne la dichotomie modèle d'analyse / modèle de conception pour rechercher un modèle unique servant les deux buts. Chaque objet de la conception joue un rôle conceptuel décrit dans le modèle, qui doit donc remplir deux objectifs bien différents.

Pas au prix d'une analyse affaiblie par la technique, ni de conceptions maladroites. C'est possible parce qu'il existe toujours plusieurs manières d'abstraire un domaine. Quand le modèle n'est pas praticable, ou qu'il trahit les concepts clés, cherchez-en un nouveau : modélisation et conception deviennent une seule boucle itérative.

À retenir

La consigne d'Evans est sans détour : concevez une portion du logiciel pour qu'elle reflète le modèle de manière très littérale, afin que la correspondance soit évidente. Revenez ensuite au modèle pour qu'il s'implémente plus naturellement. Exigez un modèle unique, et liez-y l'implémentation servilement.

Du procédural au modèle, en code

Evans illustre la bascule avec un outil de routage de circuits imprimés. Les ingénieurs raisonnent en bus — des groupes de connexions (les « nets ») partageant les mêmes règles —, mais l'outil ne connaît que les nets isolés. La solution mécaniste contournait cela par des scripts triant un fichier, puis y injectant les règles net par net.

// ❌ Avant : le concept de « bus » n'existe que dans des scripts.
// On infère le bus par tri alphabétique et correspondance de chaînes.
function appliquerRegleDeBus(lignes: string[], bus: string, regle: string) {
	lignes.sort();
	for (const ligne of lignes) {
		if (!ligne.startsWith(bus)) continue;
		const net = ligne.split(" ")[0];
		ecrireRegle(net, regle); // règle dupliquée pour chaque net
	}
}

Ce code fonctionne, mais le concept de bus n'y est nulle part : il est déduit par des tris et des comparaisons de chaînes, jamais traité explicitement. Changez de format de fichier et tout est à refaire. La conception pilotée par le modèle organise au contraire les concepts des experts explicitement.

// ✅ Après : le modèle exprime « net », « bus » et « règle ».
abstract class NetAbstrait {
	private regles = new Set<RegleLayout>();

	assignerRegle(regle: RegleLayout): void {
		this.regles.add(regle);
	}

	reglesAssignees(): Set<RegleLayout> {
		return this.regles;
	}
}

class Net extends NetAbstrait {
	constructor(private bus: Bus | null = null) {
		super();
	}

	// Un net hérite de ses propres règles ET de celles de son bus.
	reglesAssignees(): Set<RegleLayout> {
		const cumul = new Set(super.reglesAssignees());
		this.bus?.reglesAssignees().forEach((r) => cumul.add(r));
		return cumul;
	}
}

Avec ces objets, la fonctionnalité centrale devient presque triviale, et surtout testable : on vérifie que l'assignation d'une règle au bus se propage à chaque net, sans comparaison de fichier de bout en bout. Le code exprime les concepts du domaine, qui peut s'enrichir au lieu de croître en complexité aveugle. Et si un vocabulaire nouveau émerge pendant l'implémentation, c'est que le modèle a changé : il faut le réintégrer au langage omniprésent (Ubiquitous Language).

Montrer les os : pourquoi le modèle compte pour l'utilisateur

Tenter de créer dans l'interface l'illusion d'un modèle autre que celui du domaine sème la confusion, à moins que l'illusion ne soit parfaite — ce qui n'arrive jamais. Evans cite les « favoris » d'un navigateur des années 2000 : l'utilisateur les voit comme une liste de noms de sites, mais l'implémentation les stocke comme des fichiers nommés d'après le titre. Tapez un titre contenant : ou ? et surgit un message d'erreur incompréhensible sur les noms de fichiers interdits.

Quand une conception repose sur un modèle reflétant les préoccupations réelles des utilisateurs, on peut révéler les os de la conception : plus d'accès au potentiel du logiciel, un comportement prévisible. Pourquoi forcer l'utilisateur à apprendre un modèle inventé alors que celui des concepteurs suffisait ?

Le modeleur doit toucher le code

La métaphore manufacturière — les ingénieurs conçoivent, les ouvriers assemblent — a saboté quantité de projets, car le développement logiciel est entièrement conception. La sur-séparation entre analyse, modélisation, conception et programmation entrave la conception pilotée par le modèle.

Coupé de l'implémentation, un modeleur perd vite le sens des contraintes techniques, et ses modèles deviennent impraticables. À l'inverse, si ceux qui écrivent le code ne se sentent pas responsables du modèle, celui-ci n'a plus rien à voir avec le logiciel. Et si les développeurs ignorent que changer le code change le modèle, leur remaniement l'affaiblit au lieu de le renforcer.

De là, la consigne des modeleurs au contact (Hands-On Modelers) : toute personne contribuant au modèle doit toucher le code, quel que soit son rôle, et quiconque modifie le code doit apprendre à y exprimer un modèle. Les programmeurs sont des modeleurs, que cela plaise ou non.

Isoler le domaine : l'architecture en couches

Gérer des tâches complexes exige de séparer les préoccupations. En orienté objet, le code d'interface, de base de données et de réseau finit trop souvent écrit directement dans les objets métier — le plus court chemin à court terme. Mais alors un changement superficiel dans l'interface peut modifier une règle métier, et changer une règle exige de fouiller le code d'interface et de persistance. Les objets pilotés par le modèle deviennent impraticables, et les tests pénibles.

La réponse éprouvée par l'industrie est l'architecture en couches (Layered Architecture). Principe essentiel : un élément d'une couche ne dépend que d'éléments de la même couche ou de couches « en dessous » de lui ; la communication vers le haut passe par un mécanisme indirect. La plupart des architectures réussies emploient une variante de ces quatre couches.

CoucheResponsabilitéÉtat métier ?
Interface (Presentation)Afficher l'information, interpréter les commandes de l'acteur (humain ou système).Non
ApplicationDéfinit les tâches du logiciel ; coordonne et délègue au domaine. Fine, sans règles métier.Progression d'une tâche
Domaine (Model)Concepts, information et règles métier. Le cœur du logiciel.Oui (stockage délégué)
InfrastructureCapacités techniques : persistance, messagerie, dessin des widgets.Non

À retenir

C'est la séparation cruciale de la couche domaine qui rend possible la conception pilotée par le modèle. Libérés de s'afficher, de se stocker ou de gérer les tâches applicatives, les objets du domaine se concentrent sur l'expression du modèle.

Une arborescence en couches

En TypeScript, la séparation se traduit par une arborescence de dossiers où les dépendances ne pointent que vers l'intérieur.

src/
├── ui/                 # Interface : contrôleurs HTTP, vues
│   └── transfert.controller.ts
├── application/        # Cas d'usage, orchestration des tâches
│   └── transfert.service.ts
├── domain/             # Le cœur : entités, règles, interfaces
│   ├── compte.ts
│   ├── argent.ts
│   └── compte.repository.ts   # interface SEULEMENT
└── infrastructure/     # Technique : ORM, drivers, adaptateurs
    └── compte.repository.sql.ts

Reprenons l'exemple bancaire d'Evans : le virement de fonds. Point capital — c'est la couche domaine, pas l'application, qui porte la règle fondamentale « tout crédit a un débit correspondant ». L'entité Compte la fait respecter (Argent est un objet-valeur, traité au chapitre suivant) ; l'application ne fait qu'orchestrer.

// domain/compte.ts — l'entité porte la règle métier centrale.
// Pure : aucun import d'ORM, de framework ni de SQL.
export class Compte {
	constructor(
		readonly numero: string,
		private solde: Argent,
	) {}

	// La règle « crédit = débit » vit ICI, pas dans l'application.
	transfererVers(destinataire: Compte, somme: Argent): void {
		if (this.solde.montant < somme.montant)
			throw new Error("Provision insuffisante");
		this.solde = this.solde.moins(somme); // débit
		destinataire.crediter(somme); // crédit correspondant
	}

	private crediter(somme: Argent): void {
		this.solde = this.solde.plus(somme);
	}
}

La couche application reste fine : ni règle ni connaissance métier, elle ne fait que coordonner et ne présume rien de la source de la requête. L'interface graphique pourrait être remplacée par une requête réseau sans affecter aucune couche inférieure.

// application/transfert.service.ts — orchestration uniquement.
export class ServiceDeTransfert {
	constructor(private comptes: CompteRepository) {}

	async executer(deNumero: string, versNumero: string, somme: Argent) {
		const source = await this.comptes.parNumero(deNumero);
		const cible = await this.comptes.parNumero(versNumero);
		source.transfererVers(cible, somme); // la règle est dans le domaine
		await this.comptes.enregistrer(source);
		await this.comptes.enregistrer(cible);
	}
}

L'inversion de dépendance : le repository

L'infrastructure, « en dessous » du domaine, ne doit avoir aucune connaissance spécifique du domaine qu'elle sert. Mais le domaine doit persister ses entités — sans dépendre de l'ORM. La solution est l'inversion de dépendance : l'interface du dépôt (Repository) est déclarée dans le domaine, son implémentation vit dans l'infrastructure.

// domain/compte.repository.ts — l'INTERFACE appartient au domaine.
// Aucun import d'ORM, aucun framework, aucun SQL ici.
export interface CompteRepository {
	parNumero(numero: string): Promise<Compte>;
	enregistrer(compte: Compte): Promise<void>;
}
// infrastructure/compte.repository.sql.ts — l'IMPLÉMENTATION.
// C'est ici, et SEULEMENT ici, que vit la technique.
import type { CompteRepository } from "../domain/compte.repository";
import { Compte } from "../domain/compte";

export class CompteRepositorySql implements CompteRepository {
	constructor(private bdd: PoolPostgres) {}

	async parNumero(numero: string): Promise<Compte> {
		const ligne = await this.bdd.query(
			"SELECT * FROM comptes WHERE numero = $1",
			[numero],
		);
		return Compte.depuisPersistance(ligne); // reconstitution
	}

	async enregistrer(compte: Compte): Promise<void> {
		await this.bdd.query(/* UPSERT ... */);
	}
}

La flèche de dépendance est inversée : ce n'est plus le domaine qui importe l'ORM, mais l'infrastructure qui dépend de l'abstraction définie par le domaine. Le domaine reste pur — aucun import de framework, de driver SQL ni de bibliothèque HTTP. On peut le tester en mémoire et remplacer PostgreSQL sans toucher une seule règle métier.

Astuce

Test décisif de la pureté du domaine : ouvrez n'importe quel fichier de domain/ et regardez ses import. S'il en importe un seul venant de infrastructure/, d'un ORM ou d'un framework web, la couche fuit.

De l'architecture en couches à l'hexagonale

Note

Prolongement moderne. Evans (2003) décrit des couches empilées où le domaine dépend encore implicitement des couches inférieures. Les architectures hexagonale (Alistair Cockburn, ports & adapters) et onion (Jeffrey Palermo, 2008) radicalisent l'idée : domaine au centre, toutes les dépendances dirigées vers l'intérieur.

On imagine alors le domaine comme un noyau entouré de ports — des interfaces qu'il définit — branchés à des adaptateurs côté technique. Le repository ci-dessus est exactement cela : un port (CompteRepository) implémenté par un adaptateur de persistance (CompteRepositorySql). Un contrôleur HTTP est un adaptateur « pilote » qui actionne le domaine ; la base de données, un adaptateur « piloté » actionné par lui via un port.

ConceptCouches (Evans 2003)Hexagonale / Onion (post-2003)
Position du domaineUne couche parmi quatre, vers le basAu centre
Sens des dépendancesVers les couches inférieuresVers l'intérieur
Mécanisme cléCouplage lâche, indirectionPorts & adaptateurs
PersistanceInfrastructure « sous » le domaineAdaptateur sur un port

L'esprit reste identique à celui d'Evans : isoler le domaine pour qu'il exprime le modèle sans être pollué par la technique. L'hexagonale rend simplement ce principe plus systématique.

L'anti-pattern Smart UI

La séparation interface / application / domaine est si souvent tentée et si rarement réussie que sa négation mérite un nom : le Smart UI. Toute la logique métier est placée dans l'interface, l'application découpée en petites fenêtres embarquant chacune ses règles, avec une base relationnelle comme dépôt partagé.

// ❌ Smart UI : la règle métier est noyée dans le gestionnaire de clic.
boutonTransfert.onClick(async () => {
	const montant = lireMontantSaisi(); // lu du formulaire
	const source = await sql.query("SELECT solde FROM comptes ...");
	if (source.solde < montant) {
		alert("Provision insuffisante"); // règle métier dans l'UI !
		return;
	}
	await sql.query("UPDATE comptes SET solde = solde - $1 ...");
	await sql.query("UPDATE comptes SET solde = solde + $1 ...");
});

Evans le qualifie d'« anti-pattern » avec ironie, car ce n'est pas toujours un mauvais choix. Pour un projet simple, dominé par la saisie et l'affichage, avec peu de règles et une équipe peu rompue à la modélisation objet, le Smart UI offre une productivité immédiate. Mais le prix est lourd : aucune réutilisation du comportement, aucune abstraction du métier, et la complexité vous submerge dès qu'on dépasse le trivial — sans chemin gracieux vers un comportement plus riche.

Piège courant

Smart UI et conception pilotée par le modèle sont des branches mutuellement exclusives. L'erreur coûteuse classique est d'entreprendre une conception sophistiquée — infrastructure et outils lourds — sans s'engager à la mener jusqu'au bout. Choisissez en conscience.

À retenir

  • Un seul modèle, lié au code servilement : la conception pilotée par le modèle abandonne la dichotomie analyse / conception. Si modèle et code divergent, le modèle redevient un joli dessin inutile.
  • Une boucle unique : modéliser, concevoir et coder forment une même activité itérative. Du vocabulaire neuf dans le code signale un modèle qui a changé.
  • Les programmeurs sont des modeleurs : qui touche au code touche au modèle ; un modeleur coupé de l'implémentation produit des modèles impraticables.
  • Isolez le domaine : l'architecture en couches concentre la logique métier dans une couche domaine pure ; la couche application reste fine.
  • Inversez les dépendances : déclarez l'interface du repository dans le domaine, implémentez-la dans l'infrastructure. Le domaine n'importe jamais d'ORM ni de framework.
  • Hexagonale comme prolongement : ports & adaptateurs et onion radicalisent l'idée d'Evans (domaine au centre). Fuyez le Smart UI sur les projets ambitieux.