Domain-Driven Design
Chapitre 7 / 10 · 15 min de lecture

Entités, Objets-Valeur & Services

Les briques d'un modèle exprimé en code : identité contre immuabilité, et où loger les comportements sans foyer.

Un modèle ne vaut que s'il vit dans le code. Tout l'enjeu d'une conception pilotée par le modèle (Model-Driven Design) est de relier modèle et implémentation jusqu'au niveau du détail : chaque concept du domaine doit se refléter dans un élément concret du programme. Pour y parvenir, Evans isole quatre patrons de base — l'entité (Entity), l'objet-valeur (Value Object), le service (Service) et le module (Module) — qui constituent l'alphabet du domaine exprimé en logiciel.

Ces patrons paraissent intuitifs : « ça, c'est un objet, ça aussi… ». Mais les pièges se cachent dans les nuances de sens. Un objet représente-t-il quelque chose qui a une continuité, une identité que l'on suit dans le temps et à travers différents états ? Ou n'est-il qu'un attribut décrivant l'état d'autre chose ? Cette distinction, banale en apparence, est celle qui sépare une entité d'un objet-valeur — et la trancher proprement débouche sur des modèles plus clairs et des implémentations plus robustes. Les exemples du livre sont en Java ou conceptuels ; nous les traduisons ici en TypeScript idiomatique, dans des univers e-commerce, bancaire et logistique.

L'entité : définie par son identité

Beaucoup d'objets ne sont pas fondamentalement définis par leurs attributs, mais par un fil de continuité et d'identité.

Evans ouvre le sujet par une anecdote : une propriétaire l'attaque en justice pour des dégâts dans un appartement qu'il n'a jamais visité. Erreur sur la personne. Ce qui l'identifie, lui, n'est aucun de ses attributs — ni son nom (il existe un homonyme), ni son adresse —, mais un fil de continuité qui traverse le temps. Une personne a une identité qui va de la naissance à la mort et au-delà ; aucun de ses attributs n'est immuable, et pourtant l'identité persiste.

Un objet défini avant tout par son identité est une entité (Entity). Une entité a un cycle de vie qui peut transformer radicalement sa forme et son contenu, alors qu'un fil de continuité doit être maintenu. Dans une application de comptabilité client, un même client accumule des statuts, change d'adresse, est extrait vers un autre logiciel, est aplati dans une table de base de données — mais quand un appel arrive avec une commande, la question vitale reste : est-ce le client dont le compte est en souffrance ? Est-ce un nouveau client ?

À retenir

Ne confondez pas l'identité du domaine avec l'identité native du langage. JavaScript compare deux références avec === (même emplacement mémoire). Mais dès qu'un objet est rechargé depuis une base ou transmis sur le réseau, une nouvelle instance est créée et cette identité-là est perdue. L'identité d'une entité doit survivre à ces transformations.

Dans une application bancaire, deux dépôts du même montant sur le même compte le même jour restent deux transactions distinctes : elles ont une identité, ce sont des entités. En revanche, le montant de chacune est un objet-valeur — il n'y a aucun intérêt à distinguer ces deux montants.

Modéliser une entité : la dépouiller jusqu'à l'os

Puisqu'une entité est définie par son identité et non par ses attributs, on la dépouille jusqu'à ses caractéristiques les plus intrinsèques — celles qui l'identifient ou servent couramment à la retrouver — et on déporte le reste dans des objets associés. La classe doit tourner autour de qui est l'objet, pas de ce qu'il porte.

// ❌ Avant : entité « anémique », égalité accidentelle
class Client {
	constructor(
		public nom: string,
		public email: string,
		public chiffreAffaireMoyen: number,
	) {}
}

// Deux objets aux mêmes attributs sont-ils le même client ?
// Et si le nom change, est-ce un autre client ? Ambigu.

La correction consiste à rendre l'identité primaire dans la définition. On attache un identifiant immuable, et l'égalité se fait par cet identifiant, jamais par les attributs.

// ✅ Après : identité explicite et stable
class Client {
	// L'identifiant est readonly : il ne change JAMAIS,
	// même quand l'objet est aplati en base puis reconstruit.
	constructor(
		public readonly id: ClientId,
		private nom: string,
		private email: Email,
	) {}

	renommer(nouveauNom: string): void {
		this.nom = nouveauNom; // les attributs changent…
	}

	changerEmail(email: Email): void {
		this.email = email; // …l'identité, elle, persiste.
	}

	// Égalité par IDENTITÉ, pas par attributs.
	memeIdentiteQue(autre: Client): boolean {
		return this.id.equals(autre.id);
	}
}

Concevoir l'opération d'identité

Chaque entité a besoin d'un moyen opérationnel d'établir son identité, même face à un autre objet aux attributs descriptifs identiques. Evans distingue plusieurs sources d'identifiant :

Source de l'identifiantExempleRemarque
Clé naturelle (attributs garantis uniques)Un quotidien : titre + ville + dateMéfiance : éditions spéciales, changements de nom
Identifiant externe fourni par les utilisateursNuméro de sécurité socialePas infaillible : tout le monde n'en a pas
Identifiant généré par le système, interneClé technique invisible de l'utilisateurLe plus courant ; doit garantir l'unicité même en distribué
Identifiant généré et exposé à l'utilisateurNuméro de suivi d'un colis, n° de réservationL'utilisateur s'en sert pour suivre l'objet

Le vrai défi n'est pas technique mais conceptuel : que signifie « être le même objet » dans ce domaine ? L'identifiant ne sert à rien s'il ne correspond pas à une distinction qui a du sens pour les utilisateurs. C'est pourquoi un identifiant ne doit jamais changer une fois attribué — discipline d'ingénierie que peu de langages savent imposer d'eux-mêmes.

// Un identifiant typé plutôt qu'un string nu :
// le typage empêche de confondre un ClientId et une CommandeId.
class ClientId {
	constructor(private readonly valeur: string) {}

	static nouveau(): ClientId {
		return new ClientId(crypto.randomUUID());
	}

	equals(autre: ClientId): boolean {
		return this.valeur === autre.valeur;
	}

	toString(): string {
		return this.valeur;
	}
}

Note

La même chose du monde réel peut être une entité dans un modèle et pas dans un autre. Dans un stade en placement numéroté, un siège est une entité (son numéro l'identifie). En entrée libre, seul le nombre total de sièges compte : associer un numéro de siège à un billet serait une erreur de modèle. L'identité n'est jamais intrinsèque ; c'est un sens qu'on superpose parce qu'il est utile.

L'objet-valeur : défini par ses attributs

Beaucoup d'objets n'ont aucune identité conceptuelle. Ces objets décrivent une caractéristique d'une chose.

Un enfant qui dessine se soucie de la couleur de son feutre, peut-être de la finesse de la pointe. Mais si deux feutres ont la même couleur et la même forme, il se moque de savoir lequel il utilise. Si l'un est perdu et remplacé par un autre du même rouge, il reprend son travail sans s'en préoccuper. Voilà un objet-valeur (Value Object) : un objet qui décrit un aspect du domaine et n'a pas d'identité conceptuelle. On l'instancie pour ce qu'il est, pas pour qui il est.

L'erreur classique, une fois qu'on a compris l'importance de l'identité des entités, est de vouloir donner une identité à tous les objets. C'est coûteux (le système doit tout pister, ce qui ruine maintes optimisations) et cela embrouille le modèle en faisant ressembler tous les objets au même moule. La conception logicielle est un combat permanent contre la complexité : il faut réserver le traitement spécial — le suivi d'identité — aux seuls endroits qui en ont besoin.

Astuce

Quand vous ne vous souciez que des attributs d'un élément du modèle, classez-le comme objet-valeur. Et privilégiez-les largement : un objet-valeur est plus simple, plus sûr et plus facile à raisonner qu'une entité. La question décisive : « si je remplace cet objet par un autre aux attributs identiques, est-ce que cela change quelque chose ? » Si non, c'est un objet-valeur.

Immuabilité et égalité structurelle

Comme on ne se soucie d'aucune instance en particulier, on gagne une grande liberté de conception : copier, partager, optimiser. La clé qui rend tout cela sûr est l'immuabilité. Si la valeur d'un attribut change, on n'altère pas l'objet existant : on en crée un nouveau, par remplacement complet. Un objet-valeur partagé entre deux entités ne risque alors jamais de corrompre l'une en mutant pour l'autre.

L'autre face de l'objet-valeur est l'égalité structurelle : deux objets-valeur sont égaux si tous leurs attributs sont égaux, et non s'ils occupent le même emplacement mémoire.

// ❌ Avant : objet-valeur mutable et fuyant
class Argent {
	constructor(public montant: number, public devise: string) {}

	ajouter(autre: Argent): void {
		this.montant += autre.montant; // mutation en place : danger !
	}
}

// Si ce prix est partagé par plusieurs lignes de commande,
// le muter ici corrompt toutes les autres lignes.
// Devise est un union de littéraux : `===` compare bien des valeurs.
type Devise = "EUR" | "USD" | "GBP";

// ✅ Après : immuable, égalité structurelle, opérations pures
class Argent {
	constructor(
		public readonly montant: number,
		public readonly devise: Devise,
	) {
		if (montant < 0) {
			throw new Error("Un montant ne peut être négatif");
		}
	}

	// Une opération renvoie un NOUVEL objet, sans effet de bord.
	plus(autre: Argent): Argent {
		this.memeDeviseOuErreur(autre);
		return new Argent(this.montant + autre.montant, this.devise);
	}

	multiplier(facteur: number): Argent {
		return new Argent(this.montant * facteur, this.devise);
	}

	// Égalité STRUCTURELLE : tous les attributs comptent.
	equals(autre: Argent): boolean {
		return (
			this.montant === autre.montant &&
			this.devise === autre.devise
		);
	}

	private memeDeviseOuErreur(autre: Argent): void {
		if (this.devise !== autre.devise) {
			throw new Error("Devises incompatibles");
		}
	}
}

Remarquez que plus et multiplier ne modifient rien : elles retournent un nouvel objet. C'est la signature des opérations sur les objets-valeur, et c'est ce qui les rend si agréables à composer et à tester.

Un tout conceptuel

Les attributs d'un objet-valeur doivent former un tout cohérent. La rue, la ville et le code postal ne devraient pas être trois attributs épars d'un Client : ils composent une seule et même adresse. Extraire ce concept simplifie l'entité et donne un objet-valeur plus expressif.

// ❌ Avant : attributs d'adresse éparpillés dans l'entité
class Client {
	rue!: string;
	ville!: string;
	codePostal!: string;
	pays!: string;
}

// ✅ Après : un concept entier, immuable, réutilisable
class Adresse {
	constructor(
		public readonly rue: string,
		public readonly ville: string,
		public readonly codePostal: string,
		public readonly pays: string,
	) {}

	equals(autre: Adresse): boolean {
		return (
			this.rue === autre.rue &&
			this.ville === autre.ville &&
			this.codePostal === autre.codePostal &&
			this.pays === autre.pays
		);
	}
}

Un objet-valeur n'a rien de simpliste : il peut assembler d'autres objets-valeur (une PlageDeDates faite de deux Date, une Adresse portée par une Livraison) et même référencer des entités. Un itinéraire reliant San Francisco à Los Angeles est une valeur, même si les villes et l'autoroute qu'il référence sont, elles, des entités.

Piège courant

Évitez toute association bidirectionnelle entre deux objets-valeur : sans identité, dire qu'un objet « pointe en retour vers le même » que celui qui le pointe n'a pas de sens. Si une telle relation paraît nécessaire, reconsidérez votre choix : peut-être l'objet a-t-il une identité que vous n'avez pas encore reconnue, et est-ce en réalité une entité.

Attention

L'immuabilité est la règle par défaut, mais Evans admet de rares exceptions pour la performance : valeur changeant très souvent, création/suppression coûteuse, très grand nombre d'instances. Dans ces cas seulement on peut tolérer la mutabilité — et alors l'objet ne doit jamais être partagé. En cas de doute, choisissez l'immuabilité.

Le service : un comportement sans foyer

Parfois, ce n'est tout simplement pas une chose.

Certaines opérations métier importantes ne trouvent leur place naturelle ni dans une entité ni dans un objet-valeur. Ce sont des activités, des actions, pas des choses. La tentation est double, et symétrique. D'un côté, glisser vers la programmation procédurale en abandonnant l'idée de loger le comportement dans un objet. De l'autre — l'erreur plus fréquente —, forcer l'opération dans un objet qui ne lui convient pas : l'objet perd alors sa clarté conceptuelle, se gonfle, se couple à tous les objets que l'opération orchestre.

Quand un processus ou une transformation significative du domaine n'est la responsabilité naturelle d'aucune entité ni d'aucun objet-valeur, on l'exprime comme un service de domaine (Domain Service) : une opération offerte comme une interface autonome, sans état. Là où entités et objets-valeur sont des « noms », un service est un « verbe » — il est nommé pour une activité, et ce nom doit appartenir au langage ubiquitaire (Ubiquitous Language).

Un bon service de domaine présente trois caractéristiques :

  • L'opération concerne un concept du domaine qui n'est pas un attribut naturel d'une entité ou d'un objet-valeur.
  • L'interface est définie en termes d'autres éléments du modèle (ses paramètres et résultats sont des objets du domaine).
  • L'opération est sans état : n'importe quel client peut utiliser n'importe quelle instance, sans se soucier de son historique.

L'exemple canonique d'Evans est le virement bancaire. Le porter sur Compte serait maladroit : il implique deux comptes et des règles globales. C'est donc un service.

// ❌ Avant : le virement squatte une entité, mal à l'aise
class Compte {
	transfererVers(destinataire: Compte, montant: Argent): void {
		// Pourquoi CE compte serait-il responsable de l'autre ?
		// La règle (débit puis crédit) appartient aux deux.
		this.debiter(montant);
		destinataire.crediter(montant);
	}
}
// ✅ Après : un service de domaine, nommé dans le langage métier
class ServiceDeVirement {
	transferer(
		source: Compte,
		destination: Compte,
		montant: Argent,
	): Virement {
		// Le service ORCHESTRE ; les entités font le travail.
		source.debiter(montant);
		destination.crediter(montant);

		// Il peut retourner un objet du domaine représentant
		// les deux écritures, leurs règles et leur historique.
		return new Virement(source.id, destination.id, montant);
	}
}

Le service ne fait pas grand-chose lui-même : il demande aux deux comptes d'effectuer l'essentiel. Mais en déclarant explicitement « virement » dans le modèle plutôt que de bricoler un objet bidon, on n'induit personne en erreur.

Ne pas tout transformer en service

Le danger du patron Service est l'anémie : à force d'extraire les comportements dans des services, on vide les entités et objets-valeur de toute logique, et on retombe dans la programmation procédurale avec des objets réduits à de simples sacs de données. Evans est catégorique : les services doivent être utilisés avec discernement et ne pas dépouiller les entités et objets-valeur de leur comportement.

À retenir

Reconnaître un faux service. S'il porte un nom en …Manager, …Helper, …Processor et n'existe que pour « faire un truc » sur des objets passifs, demandez-vous si le comportement n'appartient pas en réalité à l'une des entités ou à l'un des objets-valeur concernés. Le service n'est légitime que lorsque l'opération n'a vraiment pas de foyer naturel.

Domaine, application, infrastructure

Tous les services ne se valent pas. Evans les répartit en couches, et la frontière entre service applicatif et service de domaine est la plus subtile.

CoucheRôle du serviceExemple (virement bancaire)
ApplicationCoordonne, digère les entrées, déclenche, écoute, décide d'agirReçoit la requête XML, appelle le domaine, déclenche la notification
DomainePorte une règle métier significative, manipule entités et objets-valeurEffectue les débits/crédits, confirme si le virement est autorisé
InfrastructureDétails purement techniques, sans aucun sens métierEnvoie l'e-mail de confirmation

Un service de domaine embarque des règles métier et porte un terme du langage métier (« virement »). Un service applicatif orchestre sans contenir de règle métier (exporter des transactions vers un tableur n'a aucun sens dans le domaine bancaire : c'est de l'application). Un service d'infrastructure, lui, doit être totalement dénué de sens métier.

Les modules : raconter l'histoire du domaine

Les modules (Modules, ou packages) sont un vieil élément de conception, mais peu d'équipes les traitent comme une part à part entière du modèle. Leur motivation première n'est pas technique : c'est la surcharge cognitive. Un module offre deux vues — le détail à l'intérieur, sans être noyé par le tout ; et les relations entre modules, sans le détail intérieur.

On répète qu'il faut un faible couplage entre modules et une forte cohésion à l'intérieur. Mais ce ne sont pas que des métriques techniques : ce sont des concepts que l'on regroupe. Un esprit ne peut penser qu'à un nombre limité de choses à la fois (d'où le faible couplage), et des fragments d'idées incohérents sont plus durs à comprendre qu'une soupe indifférenciée (d'où la forte cohésion).

// ❌ Avant : découpage technique, le modèle est invisible
// /entities, /repositories, /services, /dtos…
// Pour comprendre « la facturation », il faut fouiller 4 dossiers.

// ✅ Après : découpage par concept du domaine
// /facturation   -> Facture, LigneFacture, ServiceDeFacturation
// /expedition    -> Colis, Itineraire, ServiceDExpedition
// /catalogue     -> Produit, Prix, Categorie

Comme tout le reste en conception pilotée par le modèle, un module est un mécanisme de communication. En plaçant des classes ensemble, vous dites au développeur suivant de les penser ensemble. Si le modèle raconte une histoire, les modules en sont les chapitres — et leur nom entre dans le langage ubiquitaire : « parlons du module expédition » plante le décor d'une conversation avec un expert métier.

Astuce

Méfiez-vous du découpage dicté par l'infrastructure. Éclater un même objet conceptuel entre une couche « persistance », une couche « logique » et une couche « interface » — chacune dans son propre package — détruit la cohésion et finit en modèle anémique. Réservez le découpage technique au strict nécessaire (par exemple isoler la couche domaine) et laissez les développeurs regrouper les objets du domaine selon le sens du modèle.

Un dernier point d'Evans : modules et objets doivent co-évoluer. Refactoriser un module est plus coûteux que refactoriser une classe, si bien que les structures de modules reflètent souvent une compréhension périmée du domaine. Résistez à cette inertie : laissez les modules suivre l'approfondissement de votre compréhension, exactement comme les classes.

Un prolongement moderne : les événements de domaine

Evans (2003) décrit quatre briques tactiques : entités, objets-valeur, services et modules. La communauté DDD en a ajouté une cinquième, postérieure au livre : l'événement de domaine (Domain Event) — un objet-valeur immuable qui capture un fait métier passé (« VirementEffectue », « CommandeExpediee »). Popularisé après 2003 (et au cœur de pratiques comme l'Event Storming, le CQRS ou l'Event Sourcing), c'est un complément naturel, pas un remplacement : il s'appuie sur les mêmes principes d'immuabilité et de langage ubiquitaire que les patrons d'Evans.

À retenir

  • Entité = identité. Un objet défini par un fil de continuité dans le temps, pas par ses attributs (Client, Commande, Transaction). Identifiant immuable, égalité par identifiant, classe dépouillée jusqu'à l'essentiel.
  • Objet-valeur = attributs. Sans identité, immuable (readonly), égalité structurelle, opérations qui renvoient de nouveaux objets (Argent, Adresse, PlageDeDates). À privilégier largement : plus simple et plus sûr.
  • La bonne question pour trancher : « si je remplace cet objet par un autre aux attributs identiques, est-ce que cela change quelque chose ? » Si non, c'est un objet-valeur.
  • Service de domaine = un comportement métier important, sans état, sans foyer naturel dans une entité ou un objet-valeur (virement, calcul de tarif). Nommé par un verbe du langage ubiquitaire, paramètres et résultats sont des objets du domaine.
  • Pas d'anémie : les services ne doivent pas vider les entités et objets-valeur de leur comportement. Méfiez-vous des …Manager qui n'existent que pour agir sur des objets passifs.
  • Modules = chapitres de l'histoire du domaine : forte cohésion, faible couplage, nommés dans le langage ubiquitaire. Résistez au découpage purement technique qui produit un modèle anémique.