Entités et objets-valeurs
Résumé. Le modèle du domaine est fait de deux sortes d'objets. Une entité a une identité qui
persiste pendant que ses attributs changent : un rendez-vous reste le même rendez-vous quand il
est déplacé. Un objet-valeur n'a pas d'identité : il est entièrement défini par ses attributs, il
est immuable, et deux objets-valeurs aux mêmes attributs sont interchangeables, comme un créneau
horaire ou un motif d'annulation. La plupart des concepts devraient être des objets-valeurs ; les
entités sont les quelques choses dont le métier suit la continuité. Dans ce kit, les deux ont un
constructeur privé et des fabriques, les entités ne changent que par des méthodes nommées d'après
une intention métier, et les objets-valeurs ne changent jamais. Un objet-valeur est toujours une
classe qui étend ValueObject, jamais une fonction qui construit un objet simple
(domain/value-object-class).
Les distinguer
Section intitulée « Les distinguer »Demandez à l'expert du domaine : « si tous les attributs étaient identiques, s'agirait-il encore de deux choses différentes ? »
| Entité | Objet-valeur | |
|---|---|---|
| Identité | en a une, stable dans le temps | aucune |
| Égalité | par identifiant | par attributs (equals) |
| Mutabilité | change d'état par des méthodes d'intention | immuable ; une opération renvoie une nouvelle instance |
| Cycle de vie | créée, modifiée, parfois terminée ; stockée et chargée | créé au besoin, abandonné librement |
| Exemple | Appointment, Calendar, une absence TimeOff dans un calendrier |
TimeSlot, CancellationReason, WorkingHours, une adresse e-mail |
Deux rendez-vous pour le même client, le même praticien et le même créneau resteraient deux
rendez-vous (et un bogue que l'invariant doit empêcher) : entité. Deux TimeSlot de 09:00 à 09:30
le même jour sont le même créneau : objet-valeur.
Le même concept peut être une entité dans un contexte et une valeur dans un autre. Un praticien
est une entité là où l'on gère les profils ; dans appointments, ce n'est qu'un
practitionerId.
Objets-valeurs
Section intitulée « Objets-valeurs »Un objet-valeur rassemble un concept qui serait sinon éparpillé en types primitifs, avec les règles qui le rendent valide et les opérations qui lui appartiennent.
import { ConceptError, ValueObject } from '@ddd-ts-kit/kernel';
export class AppointmentError extends ConceptError { static invalidTimeSlot(startsAt: Date, endsAt: Date) { return AppointmentError.refusal( 'INVALID_TIME_SLOT', `A slot ends after it starts: ${startsAt.toISOString()} to ${endsAt.toISOString()} does not.`, { startsAt, endsAt }, ); }}
// contexts/appointments/domain/value-objects/time-slot.vo.tsexport type TimeSlotProps = { readonly startsAt: Date; readonly endsAt: Date;};
export class TimeSlot extends ValueObject<TimeSlotProps> { private constructor(props: TimeSlotProps) { super(props); }
static create(startsAt: Date, endsAt: Date): TimeSlot { if (endsAt.getTime() <= startsAt.getTime()) { throw AppointmentError.invalidTimeSlot(startsAt, endsAt); }
return new TimeSlot({ startsAt, endsAt }); }
overlaps(other: TimeSlot): boolean { return this.startsAt < other.endsAt && other.startsAt < this.endsAt; }
startsWithin(durationMs: number, now: Date): boolean { return this.startsAt.getTime() - now.getTime() < durationMs; }
get startsAt(): Date { return this.props.startsAt; }
get endsAt(): Date { return this.props.endsAt; }}Ce que cette forme apporte :
- Valide par construction. Le constructeur est privé et
createvérifie les règles, si bien qu'unTimeSlotqui existe est valide. Le code qui reçoit unTimeSlotne le revalide jamais. - Immuable. Les propriétés sont en lecture seule et aucune méthode n'affecte l'objet
(
domain/value-object-immutability). On peut le partager et le faire circuler sans copie défensive. - Le comportement à côté des données.
overlapsest écrit une fois, sur le type qu'il concerne, au lieu d'être répété partout où deux paires de dates se rencontrent. - Aucune horloge cachée.
startsWithinreçoitnow; le domaine ne lit jamais l'heure du système (purity/no-system-clock). - L'égalité offerte.
ValueObjectgèle les propriétés à la construction et les compare dansequals, les dates par leur instant et les tableaux élément par élément : deux créneaux construits à partir des mêmes instants sont égaux,===n'est pas la question. - Les propriétés sont un
type, pas uneinterface.ValueObject<Props>exigeRecord<string, unknown>: un alias de type objet le satisfait, une interface non, parce que TypeScript donne une signature d'index implicite au premier seulement.
Dans une spec, la valeur s'emploie comme le métier en parle :
import { ValueObject } from '@ddd-ts-kit/kernel';import { describe, expect, it } from 'vitest';
// Le TimeSlot ci-dessus, réduit à ce qu'utilise cette spec.type TimeSlotProps = { readonly startsAt: Date; readonly endsAt: Date };
class TimeSlot extends ValueObject<TimeSlotProps> { private constructor(props: TimeSlotProps) { super(props); }
static create(startsAt: Date, endsAt: Date): TimeSlot { return new TimeSlot({ startsAt, endsAt }); }
overlaps(other: TimeSlot): boolean { return this.props.startsAt < other.props.endsAt && other.props.startsAt < this.props.endsAt; }}
const at = (time: string): Date => new Date(`2026-03-03T${time}:00.000Z`);
describe('TimeSlot', () => { it('is equal to another slot with the same start and end', () => { expect(TimeSlot.create(at('09:00'), at('09:30')).equals(TimeSlot.create(at('09:00'), at('09:30')))).toBe(true); });
it('overlaps a slot that starts before it ends', () => { expect(TimeSlot.create(at('09:00'), at('09:30')).overlaps(TimeSlot.create(at('09:15'), at('09:45')))).toBe(true); });
it('does not overlap the slot that starts when it ends', () => { expect(TimeSlot.create(at('09:00'), at('09:30')).overlaps(TimeSlot.create(at('09:30'), at('10:00')))).toBe(false); });});Pourquoi un objet-valeur est une classe
Section intitulée « Pourquoi un objet-valeur est une classe »Une valeur peut s'écrire de deux façons en TypeScript : une fonction qui construit un objet
simple, ou une classe. Le kit écrit, et vérifie, la classe (domain/value-object-class).
La forme fonction paraît plus légère :
// domain/value-objects/money.vo.ts : signaléexport type Money = { readonly amount: number; readonly currency: string };
export const money = (amount: number, currency: string): Money => ({ amount, currency });et coûte davantage à chaque endroit qui l'utilise :
// trois appelants, trois copies des règles, ou aucuneif (!Number.isInteger(price.amount)) { /* ... */ } // un gestionnaire valide encoreconst same = a.amount === b.amount && a.currency === b.currency; // un agrégat compare à la mainconst total = { amount: a.amount + b.amount, currency: a.currency }; // une fonction utilitaire additionne, sans vérifierLa forme classe rassemble tout cela en un seul endroit :
// domain/value-objects/money.vo.ts : npx ddd-ts-kit generate value-object billing Moneyimport { ValueObject } from '@ddd-ts-kit/kernel';import { InvoiceError } from '@/contexts/billing/domain/errors/invoice.error';
export type MoneyProps = { readonly amount: number; readonly currency: string;};
export class Money extends ValueObject<MoneyProps> { private constructor(props: MoneyProps) { super(props); }
static of(amount: number, currency: string): Money { if (!Number.isInteger(amount)) { throw InvoiceError.invalidAmount(amount); }
return new Money({ amount, currency }); }
static zero(currency: string): Money { return Money.of(0, currency); }
add(other: Money): Money { if (other.currency !== this.currency) { throw InvoiceError.currencyMismatch(this.currency, other.currency); }
return Money.of(this.amount + other.amount, this.currency); }
get amount(): number { return this.props.amount; }
get currency(): string { return this.props.currency; }}// les trois mêmes appelantsconst price = Money.of(1250, 'EUR'); // valide, ou refusé avec INVALID_AMOUNTconst same = a.equals(b); // par valeurconst total = a.add(b); // un nouveau Money, vérifié à nouveau par `of`Ce que donne la classe, et que la fonction ne peut pas donner :
Fonction qui renvoie { amount, currency } |
Classe qui étend ValueObject |
|
|---|---|---|
| L'égalité sans identité | chaque appelant compare champ par champ, ou compare des références par erreur | equals compare les props : deux valeurs égales sont interchangeables |
| Les invariants vérifiés une fois | tout objet de la bonne forme passe, { amount: 0.5 } compris ; chaque appelant revérifie ou oublie |
le constructeur est privé, la fabrique statique est la seule entrée : un Money qui existe est valide |
| Le comportement à côté des données | add, convert, isZero deviennent des fonctions utilitaires dans d'autres fichiers, trouvées en cherchant |
des méthodes du type qu'elles concernent, trouvées par la complétion |
| Les refus depuis les fabriques statiques | une entrée invalide passe inaperçue | Money.of lève le refus de son concept, InvoiceError.invalidAmount(..), que le mapping HTTP, le journal d'audit et les tests connaissent par son code ; ou le renvoie dans un Result sous "stack.valueObjects": "result" |
| L'immuabilité | un type readonly n'est qu'une promesse du compilateur : tout détenteur peut encore le décomposer et le modifier |
les props sont figées par ValueObject, une opération renvoie une nouvelle instance (domain/value-object-immutability) |
| Reconnue à la frontière | un mapper ne peut pas distinguer un Money de n'importe quel objet aux deux mêmes champs |
value instanceof Money dans un mapper ou un sérialiseur ; toJSON() donne les props à un modèle de lecture ou à un journal |
Ce que vérifie domain/value-object-class
Section intitulée « Ce que vérifie domain/value-object-class »La règle tient un projet dont la configuration dit "stack.values": "classes", ce qu'écrit
init. Une configuration sans la clé a été écrite avant que la règle n'existe : elle se lit
free et rien n'est signalé ; CONFIG.md donne les étapes pour la faire
évoluer.
- Un fichier d'objet-valeur (
domain/value-objects/*.vo.tsd'un contexte, un*.vo.tsdu noyau partagé hors depresentation/) exporte exactement une classe qui étendValueObject, directement ou par un autre objet-valeur du projet ; son constructeur est privé ou protégé (une classe qui n'en déclare aucun hérite de celui, protégé, deValueObject) ; une classe concrète a une fabrique statique ; et le fichier n'exporte aucune fonction, aucune constante fléchée et aucun objet littéral, en ligne, par une liste d'export ou par défaut. Les types et les interfaces de ses props peuvent être exportés à côté. - Dans les autres fichiers de
domain/et du noyau partagé (hors depresentation/), une fonction exportée qui renvoie un enregistrement de données est une valeur écrite comme une fonction, et elle est signalée avec la commande qui écrit la classe.
Qu'un objet renvoyé soit un enregistrement de données se lit entrée par entrée, sans les types :
| Entrée | Se lit comme | Exemples |
|---|---|---|
| un littéral, un paramètre, une opération sur eux, un appel du langage | donnée | amount, 'EUR', cents * 100, Math.round(cents), new Date(at) |
tout autre appel ou new, ou un nom lié à l'un d'eux dans le module |
construit par le modèle | Money.of(1), total.add(tax), new Slot(..) |
| une fonction, une méthode, ou un nom lié à l'une d'elles dans le module | comportement | isSatisfiedBy: (x) => .., describe() {}, { isSatisfiedBy } |
...state |
dérivé | { ...state, status } |
L'objet est un enregistrement de données quand il contient des données, rien de construit par le
modèle et aucune décomposition (spread). Une fonction qui déclare un type de retour d'un
package (): EventStamp du noyau) n'est pas jugée : la forme appartient à la bibliothèque, et
le projet ne peut pas en faire une classe.
Ce qui reste une fonction, parce que ce n'est pas une valeur :
// domain/state-machines/invoice.state-machine.ts : une déclaration, le résultat d'un appelexport const invoiceLifecycle = defineStateMachine({ states: ['draft', 'issued'], /* ... */ });
// domain/services/invoice-total.service.ts : prend et renvoie des objets-valeursexport function invoiceTotal(lines: readonly Money[], currency: string): Money { return lines.reduce((sum, line) => sum.add(line), Money.zero(currency));}
// une spécification : renvoie un booléen, ou seulement des fonctions, littérales ou fermetures localesexport const isOverdue = (invoice: Invoice, now: Date): boolean => invoice.dueAt < now;export const dueBefore = (day: Date) => { const isSatisfiedBy = (invoice: Invoice): boolean => invoice.dueAt < day;
return { isSatisfiedBy };};
// une opération du modèle : un enregistrement d'objets-valeurs qu'elle construitexport const split = (total: Money) => ({ first: Money.of(1), rest: total.subtract(Money.of(1)) });
// un reducer : dérive un enregistrement qui existe, par une décompositionexport const evolve = (state: InvoiceState, status: string): InvoiceState => ({ ...state, status });Ce qui est signalé, par décision, avec ce qu'il faut écrire à la place :
// une décision renvoyée en enregistrement : le domaine refuse en levant le refus de son concept,// `throw InvoiceError.refused(reason)`, ou nomme la décision comme un objet-valeur,// `Decision.refused(reason)`, dont la classe dit quelles décisions existentexport const decide = (reason: string) => ({ kind: 'refused' as const, reason });
// des données avec des fermetures : une valeur qui a un comportement, donc une classe avec des méthodesexport const money = (cents: number) => ({ cents, doubled: (): number => cents * 2 });
// un enregistrement qui ne fait que rassembler ses paramètres, objets-valeurs compris : une paire// qui a un sens est une valeur du glossaire, `Period.between(start, end)`export const period = (start: Instant, end: Instant) => ({ start, end });Tout type exporté de domain/ n'est pas non plus une valeur. Un objet de paramètres, qui ne
fait que regrouper les arguments d'une méthode ou d'une fabrique et n'est ni gardé, ni comparé,
ni utilisé dans un calcul, reste un type : CalendarDesk (le cabinet, le praticien et le fuseau
horaire donnés à Calendar.create, puis décomposés dans l'état) et FreedSlot (le créneau, le
calendrier et les créneaux encore retenus, donnés à Waitlist.offer) dans l'exemple. Une valeur
ne contient aucun agrégat : FreedSlot porte un Calendar, une entité dotée d'une identité,
qu'aucune égalité par valeur ne peut comparer. Une valeur du glossaire avec laquelle le modèle
calcule est un objet-valeur : MeasuredOutcome et OccupancyFigures du contexte analytics, que
la mesure d'occupation lit et renvoie. Chacun vérifie ses règles une fois, dans sa fabrique :
MeasuredOutcome.of refuse une fin avant le début, OccupancyFigures.of un chiffre inférieur à
zéro et moins de rendez-vous qui occupent leur temps que de rendez-vous honorés et manqués, tous
deux par une fabrique de OccupancyError :
static of(state: OutcomeState, startsAt: Date, endsAt: Date): MeasuredOutcome { if (endsAt.getTime() < startsAt.getTime()) { throw OccupancyError.outcomeEndsBeforeStart(startsAt.toISOString(), endsAt.toISOString()); }
return new MeasuredOutcome({ state, startsAt: new Date(startsAt), endsAt: new Date(endsAt) });}L'obsession des types primitifs
Section intitulée « L'obsession des types primitifs »La référence d'architecture en fait un défaut : un e-mail, un montant, une période sont des types
(ARCHITECTURE.md, section 4). Le symptôme est une signature telle que
book(practitionerId: string, clientId: string, start: Date, end: Date) : deux chaînes que l'on
peut intervertir sans s'en apercevoir, deux dates dont rien ne garantit l'ordre. Les candidats à
un objet-valeur :
- tout ce qui a un format ou un intervalle de valeurs (e-mail, numéro de téléphone, durée, pourcentage) ;
- tout ce qui est deux valeurs qui voyagent ensemble (début et fin, montant et devise) ;
- tout ce qui a un ensemble fermé de valeurs (un statut, un canal) ;
- les identifiants d'autres agrégats, quand les confondre coûterait cher.
Au lieu de :
book(practitionerId: string, clientId: string, startsAt: Date, endsAt: Date): void; // fin avant le début ? identifiants intervertis ?écrivez :
book(practitionerId: string, clientId: string, slot: TimeSlot): void; // un TimeSlot qui existe est valideLes templates du générateur le rappellent eux-mêmes : l'état se déclare « with value objects, not primitives » (avec des objets-valeurs, pas des types primitifs).
Une entité est définie par un fil de continuité. Sa classe protège ce fil :
import { ConceptError, Entity, ValueObject } from '@ddd-ts-kit/kernel';
export class CalendarError extends ConceptError { static timeOffCanOnlyShrink(timeOffId: string) { return CalendarError.refusal('TIME_OFF_CAN_ONLY_SHRINK', `Time off ${timeOffId} can only be shortened.`, { timeOffId, }); }}
// contexts/calendars/domain/value-objects/period.vo.ts, réduit à ce qu'utilise l'entitétype PeriodProps = { readonly from: Date; readonly to: Date };
export class Period extends ValueObject<PeriodProps> { private constructor(props: PeriodProps) { super(props); }
static create(from: Date, to: Date): Period { return new Period({ from, to }); }
overlaps(other: Period): boolean { return this.props.from < other.props.to && other.props.from < this.props.to; }
contains(other: Period): boolean { return this.props.from <= other.props.from && other.props.to <= this.props.to; }}
// contexts/calendars/domain/entities/time-off.entity.tsexport interface TimeOffState { readonly period: Period; readonly reason: string;}
export class TimeOff extends Entity<string> { private constructor( id: string, private state: TimeOffState, ) { super(id); }
static create(id: string, state: TimeOffState): TimeOff { return new TimeOff(id, state); }
static rehydrate(id: string, state: TimeOffState): TimeOff { return new TimeOff(id, state); }
shortenTo(period: Period): void { if (!this.state.period.contains(period)) { throw CalendarError.timeOffCanOnlyShrink(this.id); }
this.state = { ...this.state, period }; }
covers(period: Period): boolean { return this.state.period.overlaps(period); }
toState(): TimeOffState { return { ...this.state }; }}- Constructeur privé, deux fabriques.
createest la naissance et vérifie les invariants que l'entité garde à elle seule ;rehydrateest le chargement depuis le stockage et ne vérifie rien, parce que l'état stocké était valide quand il a été enregistré. - Aucun setter, aucune propriété publique modifiable (
domain/aggregate-encapsulation). L'état est remplacé par une méthode dont le métier reconnaîtrait le nom :shortenTo, passetPeriod. - L'identité est donnée, pas générée à l'intérieur. L'identifiant vient du port
IdGeneratoren passant par la couche application (purity/no-ambient-randomness), ce qui garde les tests déterministes. - La méthode d'intention garde sa règle.
shortenTorefuse une période qui n'est pas incluse dans la période actuelle : on ne peut pas allonger l'entité par une méthode nommée « raccourcir ». - L'état est fait d'objets-valeurs. L'entité porte l'identité et le cycle de vie ; les valeurs portent la validité.
- Égale par identité.
Entity.equalscompare la classe et l'identifiant, jamais l'état : unTimeOffchargé avant et aprèsshortenToest la même absence.
Une entité est soit la racine d'un agrégat (Appointment, Calendar), soit
elle vit à l'intérieur de l'un d'eux (TimeOff dans Calendar). Une entité interne n'est
atteinte et modifiée qu'à travers sa racine, n'a pas de repository à elle, et son identité n'a
besoin d'être unique qu'à l'intérieur de l'agrégat.
npx ddd-ts-kit generate value-object appointments TimeSlotnpx ddd-ts-kit generate entity calendars TimeOffChaque commande écrit le fichier et sa spec, avec des marqueurs TODO(domain) là où une
décision métier est attendue.
Choisir : préférer les valeurs
Section intitulée « Choisir : préférer les valeurs »Modélisez un concept comme un objet-valeur, sauf si le métier a besoin de le suivre dans le temps. Les valeurs coûtent moins cher à tous points de vue : pas d'identité à gérer, pas de cycle de vie, pas de concurrence, triviales à tester. Une erreur fréquente consiste à donner une identité à quelque chose parce qu'il a une ligne en base de données. Le stockage est l'affaire du mapper ; un objet-valeur peut être stocké dans des colonnes de la table de son propriétaire, ou dans une table à lui avec une clé technique que le domaine ne voit jamais.
| Question | Si oui |
|---|---|
| Le métier parle-t-il de « celui-là » dans le temps, même après qu'il a changé ? | entité |
| Pourrait-on le remplacer par un égal sans que personne s'en aperçoive ? | objet-valeur |
| A-t-il un cycle de vie avec des états ? | entité |
| Est-ce une mesure, une description, une quantité, un intervalle ? | objet-valeur |
Limites et cas où s'en passer
Section intitulée « Limites et cas où s'en passer »- Toute chaîne ne mérite pas un type. Une note en texte libre, sans règle ni opération, ne gagne rien à être enveloppée dans une classe. Enveloppez ce qui a des règles de validité, un comportement, ou un vrai risque de confusion.
- Lever une erreur, ou
Result. L'objet-valeur généré lève une erreur depuiscreate, comme le fait une méthode d'agrégat :throw PersonError.invalidEmail(raw), et l'appelant prend la valeur sansunwrap. Un projet qui préfère traiter les échecs attendus comme des valeurs déclare"stack.valueObjects": "result":createrenvoieerr(PersonError.invalidEmail(raw))et l'appelant le déballe ; une configuration sans la clé se lit de cette façon, etddd-ts-kit value-objectsfait passer un tel projet aux fabriques qui lèvent une erreur (CONFIG.md). Dans les deux cas, le refus est construit par une fabrique de la classe d'erreur du concept que décrit la valeur, pas par une classe par objet-valeur. - L'immuabilité est vérifiée syntaxiquement.
domain/value-object-immutabilityvoit les propriétés en lecture seule et les écritures dansthis; il ne voit pas un objet modifiable tenu dans un champ en lecture seule (uneDatepeut encore être modifiée par ses méthodes, on peut ajouter des éléments à un tableau). Tenez des valeurs elles-mêmes immuables, ou copiez-les à l'entrée et à la sortie. - Les règles d'encapsulation relèvent du plugin ESLint, qui lit l'arbre syntaxique ; le
checker de la CLI, qui travaille au niveau du texte, n'a pas d'équivalent pour
domain/aggregate-encapsulationetdomain/value-object-immutability(README de la CLI).domain/value-object-classest lue par les deux. - La forme fonction est détectée par ce qu'elle renvoie, sans les types. La revue suit ce
que la règle ne suit pas : un objet d'abord construit dans une variable, puis renvoyé
(
const value = { .. }; return value;) ; un objet renvoyé à travers une condition (cond ? { .. } : { .. }), dans un tableau ou comme argument d'un appel ; un nom lu par sa première déclaration dans le module, si bien qu'un paramètre nommé comme une fonction du module se lit comme cette fonction ; un appel lu par le nom dont il part, si bien quename.trim()sur un paramètre se lit comme construit par le modèle ; et un type de retour d'un package lu par son seul nom déclaré. - Une règle assouplie est une dérogation enregistrée. La règle vaut
errorpar défaut ; la passer àwarnouoffsousruless'enregistre sousdeviationsavec son ADR (governance/recorded-deviation). Un projet qui n'est pas prêt garde"stack.values": "free". - Un contexte CRUD mince peut avoir des entités avec peu de règles. C'est acceptable là où le sous-domaine est simple ; voir Modèle anémique.
- Eric Evans, Domain-Driven Design, Addison-Wesley, 2003, chapitre 5 « A Model Expressed in Software » (entités, objets-valeurs, services).
- Vaughn Vernon, Implementing Domain-Driven Design, Addison-Wesley, 2013, chapitre 5 « Entities » et chapitre 6 « Value Objects ».
- Vlad Khononov, Learning Domain-Driven Design, O'Reilly, 2021, chapitre 6 « Tackling Complex Business Logic ».
- Martin Fowler, Patterns of Enterprise Application Architecture, Addison-Wesley, 2002, « Value Object » ; et « ValueObject », martinfowler.com/bliki/ValueObject.html.