Refactor : faire émerger les valeurs
Reprenons notre commande de réservation telle que nous l'avons laissée au chapitre 18.
function book(payload) {
const { accommodationId, adults, children, from, to } = payload;
return async function (dependencies, context) {
const user = context.loggedUser;
if (!user) {
return context.withError(shouldBeLogged());
}
const { toDate, isBeforeOrEqual, now } = dependencies.dateProvider;
if (isBeforeOrEqual(toDate(from), now())) {
return context.withError(InvalidInterval());
}
if (isBeforeOrEqual(toDate(to), toDate(from))) {
return context.withError(InvalidInterval());
}
const booking = {
tenantId: user.id,
accommodationId,
hosts: { adults, children },
interval: { from, to },
};
await dependencies.bookings.save(booking);
return context;
};
}Elle marche. Tous les tests sont verts.
Et pourtant quelque chose ne va pas.
L'étape que nous avons sautée
Au chapitre 18, j'ai résumé notre démarche en trois étapes :
- Réfléchir à ce que le code doit faire, en impliquant l'expert métier
- Coder un test qui contrôle l'attendu, et s'assurer qu'il échoue
- Apporter les modifications minimales pour faire passer le test au vert
C'était incomplet. Le cycle du TDD en comporte une quatrième, et c'est la plus importante :
4. Refactorer, une fois le test vert.
On l'appelle rouge - vert - refactor. La plupart des développeurs qui se disent adeptes du TDD s'arrêtent à rouge - vert. Ils obtiennent alors le pire des deux mondes : la lenteur des tests, sans le bénéfice de la conception.
Parce que c'est là, et seulement là, que se prennent les décisions de conception. Tant qu'on est au rouge, on cherche à faire marcher. Une fois au vert, on a un filet : on peut déplacer, renommer, extraire, sans rien casser.
Alors profitons-en. Notre commande accumule les if. Chaque nouvel invariant en ajoutera un.
Ce qui manque : les valeurs
Souvenez-vous du chapitre 3. Nous avions distingué deux choses :
- les entités : elles ont une identité, elles persistent, elles sont mutables (l'hébergement, le locataire)
- les valeurs : elles n'ont pas d'identité, elles sont éphémères, elles sont immutables (le séjour)
Nous avons codé les entités. Nous n'avons jamais codé les valeurs.
Regardez ce qui est enregistré en base :
interval: { from: 1717286400000, to: 1717459200000 }Deux nombres nus. Des millisecondes ? Des secondes ? Depuis quand ? Dans quel fuseau ? Rien dans le code ne répond.
Au chapitre 14, nous avions pourtant écrit la bonne intuition :
Regroupons les paramètres
fromettodans un objetintervalqui adresse la question "à quelle période ?". Car ça n'aurait pas de sens d'avoir une valeurfromsans valeurto.
C'est exactement la définition d'un Value Object. Nous l'avons énoncée, puis nous avons livré un objet littéral anonyme.
Réparons cela.
Une date n'est pas un instant
Le réflexe serait d'emballer Date dans une classe. Ce serait passer à côté du problème.
Ouvrez une console et tapez ceci :
new Date("2024-06-02") // Sun Jun 02 2024 02:00:00 GMT+0200
new Date("2024-06-02T00:00") // Sun Jun 02 2024 00:00:00 GMT+0200Deux littéraux qui se ressemblent, deux instants différents. Le premier est minuit UTC — c'est-à-dire 2h du matin à Paris, mais aussi le 1er juin à 18h à Mexico.
Retournons voir l'expert métier avec cette question. Sa réponse est immédiate :
"Quand le client dit qu'il arrive le 2 juin, il arrive le 2 juin. Il n'y a pas d'heure. Le 2 juin, c'est le 2 juin pour lui comme pour le propriétaire."
Voilà. Une date d'arrivée n'est pas un point sur l'axe du temps. C'est un jour du calendrier. Elle n'a ni heure, ni fuseau.
Et c'est pour ça que toute notre discussion du chapitre 17 sur les fuseaux horaires tournait en rond : nous cherchions à gérer correctement une information que le domaine n'aurait jamais dû manipuler. Le fuseau ne réapparaîtra qu'au moment d'afficher "votre séjour commence dans 3 jours" — c'est-à-dire dans l'UI, pas dans le domaine.
Donc la valeur à coder, ce n'est pas une date. C'est un jour.
Le Value Object CalendarDay
Trois propriétés définissent un Value Object :
- il est immutable : on le crée, on l'utilise, on le jette
- il n'a pas d'identité : deux 2 juin sont le même 2 juin
- il ne peut pas exister dans un état invalide
Le troisième point est le plus important, et c'est celui qui décide de la forme du code. Un constructeur JavaScript ne peut pas échouer proprement : soit il retourne un objet, soit il jette une exception. Et nous avons vu au chapitre 14 pourquoi l'exception est un mauvais choix — rien dans la signature ne prévient l'appelant.
Alors la construction passe par une fonction statique, qui retourne notre résultat.
const ISO_DAY = /^\d{4}-\d{2}-\d{2}$/;
export class CalendarDay {
#iso;
constructor(iso) {
this.#iso = iso;
Object.freeze(this);
}
static parse(value) {
if (value instanceof CalendarDay) return Result.ok(value);
if (typeof value !== "string" || !ISO_DAY.test(value)) {
return Result.error(NotACalendarDay(value));
}
// Attrape les jours qui n'existent pas : le 31 février bascule au 2 mars.
const asUtc = new Date(`${value}T00:00:00Z`);
if (Number.isNaN(asUtc.valueOf()) || asUtc.toISOString().slice(0, 10) !== value) {
return Result.error(NotACalendarDay(value));
}
return Result.ok(new CalendarDay(value));
}
isBefore(other) {
return this.#iso < other.#iso;
}
equals(other) {
return other instanceof CalendarDay && this.#iso === other.#iso;
}
toString() {
return this.#iso;
}
}Trois choses méritent qu'on s'y arrête.
Object.freeze(this) rend l'immutabilité réelle, pas seulement documentée. Une valeur qu'on peut modifier n'est pas une valeur.
isBefore compare deux chaînes. Aucune conversion, aucun fuseau, aucun Date. C'est la propriété la plus élégante de l'ISO-8601 : "2024-06-02" < "2024-06-04" est vrai, parce que le format est ordonné du plus significatif au moins significatif. C'était l'intention derrière la norme.
Le champ est privé (#iso). Personne ne peut aller le tripoter. Et other.#iso fonctionne : à l'intérieur d'une classe, on a accès aux champs privés des autres instances de la même classe.
Le Result, enfin utilisé
Au chapitre 14, nous avions écrit une classe Maybe pour retourner "peut-être une réservation, ou alors une erreur". Puis nous ne nous en sommes jamais servis.
Deux corrections s'imposent.
D'abord le nom. Un Maybe répond "il y a quelque chose, ou il n'y a rien" — sans dire pourquoi. Ce que nous voulons, c'est "voici la valeur, ou voici la raison de l'échec". Cela s'appelle un Result (ou un Either). Appelons les choses par leur nom : c'est tout l'objet du chapitre 1.
Ensuite, il lui manquait ce qui en fait l'intérêt : pouvoir enchaîner.
export class Result {
#value;
#error;
constructor(value, error) {
this.#value = value;
this.#error = error;
Object.freeze(this);
}
static ok(value) { return new Result(value, null); }
static error(error) { return new Result(null, error); }
get value() { return this.#value; }
get error() { return this.#error; }
isError() { return this.#error !== null; }
isOk() { return this.#error === null; }
/** Transforme la valeur si elle existe, propage l'erreur sinon. */
map(fn) {
return this.isError() ? this : Result.ok(fn(this.#value));
}
/** Enchaîne une opération qui retourne elle-même un Result. */
flatMap(fn) {
return this.isError() ? this : fn(this.#value);
}
}map et flatMap, ce sont elles qui font la différence. Sans elles, chaque appel oblige à écrire un if (isError()) return. Avec elles, l'erreur se propage toute seule.
Petite note de vocabulaire, puisqu'on va vous poser la question : c'est maintenant, avec flatMap, que l'on peut parler de monade. Une classe qui se contente de stocker une valeur et une erreur n'en est pas une. Le mot ne désigne pas un conteneur, il désigne la capacité à enchaîner des opérations qui retournent elles-mêmes ce conteneur. Cela dit, savoir le nom ne sert à rien : ce qui compte, c'est que flatMap vous évite trente if.
Le Value Object Stay
Passons au séjour. Et là, une question anodine va nous occuper un moment.
export class Stay {
#from;
#to;
constructor(from, to) {
this.#from = from;
this.#to = to;
Object.freeze(this);
}
static of(from, to) {
if (!from.isBefore(to)) {
return Result.error(StayMustLastAtLeastOneNight(from, to));
}
return Result.ok(new Stay(from, to));
}
static parse({ from, to }) {
return CalendarDay.parse(from).flatMap((parsedFrom) =>
CalendarDay.parse(to).flatMap((parsedTo) => Stay.of(parsedFrom, parsedTo))
);
}
get from() { return this.#from; }
get to() { return this.#to; }
startsAfter(day) {
return day.isBefore(this.#from);
}
overlaps(other) {
return this.#from.isBefore(other.to) && other.from.isBefore(this.#to);
}
}Remarquez Stay.parse : trois erreurs possibles (le jour d'arrivée illisible, le jour de départ illisible, l'ordre incohérent), et pas un seul if. C'est flatMap qui fait le travail.
Remarquez surtout que l'invariant est dans le constructeur. Il n'existe pas, nulle part dans le système, de Stay qui dure zéro nuit. Ce n'est plus une règle qu'il faut penser à vérifier : c'est une règle qu'il est impossible d'enfreindre.
C'est ce qu'on appelle rendre les états invalides non représentables. Et c'est infiniment plus solide qu'un if dans une commande, parce qu'un if on peut oublier de le recopier dans la commande suivante.
La question à 10 000 €
Écrivons le test de overlaps. Et posons-nous la question que le code ne nous avait jamais forcés à poser :
Si un vacancier part le 4 juin et qu'un autre arrive le 4 juin, est-ce que ça se chevauche ?
Reprenons la fonction que nous avions écrite au chapitre 20 :
export function isOverlapped(interval1, interval2) {
const distinct =
isBefore(interval1.to, interval2.from) ||
isBefore(interval2.to, interval1.from);
return !distinct;
}Déroulons-la avec [2 juin → 4 juin] et [4 juin → 6 juin] :
isBefore(4 juin, 4 juin)→falseisBefore(6 juin, 2 juin)→false- donc
distinctvautfalse, donc les deux séjours se chevauchent.
Notre plateforme interdit d'arriver le jour où le précédent locataire s'en va.
Appelons l'expert métier. Sa réaction ne se fait pas attendre :
"Comment ça ? Mais tout le monde tourne le samedi ! Les gens partent le matin, la femme de ménage passe, les suivants arrivent en fin d'après-midi. Si vous me bloquez ça, vous me faites perdre la moitié de mes semaines."
Nous venions de coder, sans nous en rendre compte, une règle métier qui divisait le chiffre d'affaires par deux.
La correction tient dans la façon de concevoir l'intervalle. Un séjour ne va pas "du 2 au 4 inclus" : il va du 2 inclus au 4 exclu. On réserve des nuits, pas des jours. La nuit du 2, la nuit du 3. Le 4, on est parti.
C'est un intervalle semi-ouvert, [from, to), et il se teste ainsi :
overlaps(other) {
return this.#from.isBefore(other.to) && other.from.isBefore(this.#to);
}Vérifions :
| cas | ancienne version | semi-ouvert |
|---|---|---|
| départ le 4 / arrivée le 4 | true ❌ | false ✅ |
| chevauchement partiel | true ✅ | true ✅ |
| séjour inclus dans l'autre | true ✅ | true ✅ |
| séjours disjoints | false ✅ | false ✅ |
La version correcte est plus courte que la fausse.
Et voici la leçon, qui vaut bien au-delà de ce cas : ce bug est entré dans le code au chapitre précédent, il avait deux tests qui passaient au vert, et personne ne l'avait vu. Ce n'est pas le refactoring qui l'a trouvé — c'est le fait d'avoir dû nommer l'opération. isOverlapped(interval1, interval2) sur deux objets anonymes ne pose aucune question. stay.overlaps(other) en pose une, immédiatement : et si les bornes se touchent ?
Un bon nom est une question qu'on ne peut plus éviter.
Au passage : hosts n'était pas le bon mot
Tant qu'on y est, regardons l'autre moitié du payload.
hosts: { adults, children }Dans ce métier, un host est le propriétaire qui met son logement en location. Ceux qui l'occupent sont des guests. Notre champ dit exactement l'inverse de ce qu'il contient.
Encore une fois : chapitre 1. Le langage ubiquitaire n'est pas un vœu pieux qu'on formule au début du projet, c'est une discipline qu'on tient à chaque commit.
Corrigeons, et donnons-lui son invariant :
export class Occupancy {
#adults;
#children;
constructor(adults, children) {
this.#adults = adults;
this.#children = children;
Object.freeze(this);
}
static of({ adults, children = 0 }) {
if (!Number.isInteger(adults) || adults < 1) {
return Result.error(NeedsAtLeastOneAdult(adults));
}
if (!Number.isInteger(children) || children < 0) {
return Result.error(InvalidChildrenCount(children));
}
return Result.ok(new Occupancy(adults, children));
}
get total() { return this.#adults + this.#children; }
}Une réservation pour zéro adulte et trois enfants n'existe plus. Elle ne peut plus se construire.
Ce que devient la commande
export function book(payload) {
const { accommodationId } = payload;
return async function (dependencies, context) {
const user = context.loggedUser;
if (!user) {
return context.withError(shouldBeLogged());
}
// Les valeurs se construisent, ou refusent de se construire.
const guests = Occupancy.of(payload);
if (guests.isError()) {
return context.withError(guests.error);
}
const stay = Stay.parse(payload);
if (stay.isError()) {
return context.withError(stay.error);
}
// Seul invariant qui a besoin du monde extérieur : la date du jour.
const today = dependencies.dateProvider.today();
if (!stay.value.startsAfter(today)) {
return context.withError(StayMustStartInTheFuture(today));
}
await dependencies.bookings.save({
tenantId: user.id,
accommodationId,
guests: guests.value,
stay: stay.value,
});
return context;
};
}Elle n'est pas plus courte. Elle est mieux rangée.
La commande ne vérifie plus aucune règle sur les dates ou les occupants. Elle se contente de trois choses : autoriser, construire, enregistrer. Tout ce qui pouvait être décidé sans connaître le monde extérieur a migré dans les valeurs, où c'est testable en une milliseconde et sans injection de dépendances.
Il ne reste dans la commande que l'invariant qui a besoin de l'extérieur : la date du jour. C'est la bonne ligne de partage.
Trois bénéfices arrivent gratuitement.
Les erreurs sont enfin distinctes. Nous avions un seul InvalidInterval() pour deux problèmes qui n'ont rien à voir : "votre séjour est dans le passé" et "votre séjour dure zéro nuit". L'utilisateur recevait le même message dans les deux cas. Nous avons maintenant StayMustStartInTheFuture et StayMustLastAtLeastOneNight.
L'invariant du chapitre 18 est implémenté. Rappelez-vous : l'expert métier nous avait parlé d'un préavis d'au moins un jour, et nous étions passés à autre chose. startsAfter(today) est exactement cette règle — le séjour doit commencer strictement après aujourd'hui — et elle a maintenant son test.
La commande n'accepte plus d'objet Date. Le payload arrive avec des chaînes "2024-06-02". C'est délibéré : accepter un Date, ce serait laisser rentrer un instant, et rouvrir la porte que nous venons de fermer. CalendarDay.parse refuse explicitement les Date, et un test le vérifie.
Le provider de date maigrit
export const systemDateProvider = {
today: (timeZone = "Europe/Paris") =>
CalendarDay.parse(
new Intl.DateTimeFormat("en-CA", { timeZone }).format(new Date())
).value,
};Une seule fonction. Où sont passées toDate, isBefore, isBeforeOrEqual, sameDay ?
Elles n'avaient rien à faire là. Voici la règle qui tranche, et elle vaut pour toutes vos dépendances :
L'horloge est une dépendance : elle donne un résultat différent à chaque appel, il faut pouvoir la figer pour tester. L'arithmétique des dates est pure : elle donne toujours le même résultat, elle appartient au Value Object.
Injecter une fonction pure, c'est se donner la possibilité de la remplacer par une version fausse. Pourquoi voudriez-vous que isBefore mente ?
Le fichier domain/app/dates.js disparaît entièrement. Un refactoring qui supprime un module est presque toujours un bon refactoring.
Un mot sur le en-CA : il n'est pas là par goût du Canada, c'est la locale qui formate en YYYY-MM-DD. Et timeZone attend un identifiant IANA — Europe/Paris — jamais un code pays.
Remarquez surtout ce que ce provider ne fait plus : il ne compare plus, il ne convertit plus, il ne sait plus ce qu'est « le même jour ». Il répond à une seule question, celle qu'il est le seul à pouvoir traiter : quel jour sommes-nous ?
Et le provider de test devient d'une simplicité désarmante :
const testDateProvider = {
today: () => CalendarDay.parse("2023-06-12").value,
};Fini l'incohérence où systemDateProvider.now() retournait un nombre pendant que testDateProvider.now() retournait un objet Date. Les deux retournent un CalendarDay. C'est le type qui tient la promesse.
La frontière
Un Value Object vit dans le domaine. La base de données, elle, ne connaît que des primitives.
C'est le rôle de la couche infra : traduire. Notre MemoryBookingRepository stocke les objets tels quels, ce qui est confortable. Le jour où nous écrirons SQLBookingRepository, c'est lui — et lui seul — qui saura que stay devient deux colonnes DATE, et qui reconstruira les CalendarDay en relecture.
C'est précisément ce que la clean architecture appelle une frontière, et c'est la première fois du cours qu'elle sert vraiment à quelque chose. Notez que rien de tout cela ne remonte dans le domaine : Stay ignore qu'une base de données existe.
Au passage, le problème repéré au chapitre 20 se dissout. Notre repository continue de distribuer ses objets internes, mais ces objets sont maintenant gelés : un appelant qui tenterait d'écrire booking.stay.from = ... se ferait jeter. Une valeur immutable peut être partagée sans risque — c'est même tout son intérêt.
En attendant, la requête du chapitre 20 se lit nettement mieux :
async getAvailableAccommodations(stay) {
const bookedAccommodationsIds = this._bookings
.filter((booking) => booking.stay.overlaps(stay))
.map((booking) => booking.accommodationId);
return this._accommodations.filter(
(accommodation) => !bookedAccommodationsIds.includes(accommodation.id)
);
}Comparez avec la version précédente, qui reconvertissait les bornes à chaque appel parce qu'elle ne savait jamais ce qu'elle recevait.
Quand ne PAS faire un Value Object
Nous venons de passer un chapitre entier à défendre les Value Objects. Terminons en refroidissant l'enthousiasme, parce que le zèle en la matière produit des codebases illisibles.
Rappelez-vous la loi des 80/20 du chapitre 14. Un Value Object par primitive, c'est de la cérémonie pure.
Voici le critère. Une valeur mérite sa classe quand elle réunit au moins deux de ces trois signes :
- Il y a un invariant à protéger. Un séjour dure au moins une nuit. Une occupation compte au moins un adulte.
- La primitive est ambiguë.
1717286400000: des millisecondes ? des secondes ? depuis quelle époque ? dans quel fuseau ? - Deux valeurs n'ont de sens qu'ensemble. Un
fromsanstone veut rien dire.
Le séjour coche les trois. L'occupation en coche deux. C'est pour ça qu'ils existent.
accommodationId n'en coche aucun : c'est une chaîne opaque, sans règle, qui ne se combine avec rien. Elle reste une chaîne. (En TypeScript, un type marqué coûte une ligne et interdit de passer un tenantId à la place — mais c'est une autre discussion.)
Et un mot sur les bibliothèques, puisque vous vous posez la question : Temporal.PlainDate est très exactement le CalendarDay que nous venons d'écrire, au niveau de la plateforme. Il arrive dans les moteurs, avec un polyfill en attendant ; js-joda et Luxon rendent le même service aujourd'hui.
Faut-il alors jeter notre code ? Non — il faut le faire reposer dessus. CalendarDay deviendra une enveloppe de trois lignes autour de Temporal.PlainDate. Mais Stay restera, parce que "au moins une nuitée" et "la rotation le jour du départ est autorisée" sont des règles de votre métier. Aucune bibliothèque au monde ne les connaît.
Le Value Object de domaine enveloppe la bibliothèque. Il ne la remplace pas.
Où nous en sommes
projet-booking
|- domain
|- app
|- App.js
|- Context.js
|- values <-- nouveau
|- Result.js
|- CalendarDay.js
|- Stay.js
|- Occupancy.js
|- tests
|- book.test.js
|- values.test.js <-- nouveau
|- usecases
|- book.js
|- login.js
|- infra
|- MemoryBookingRepository.js
|- MemoryUserRepository.js
|- systemDateProvider.js
|- testDependencies.jsLe fichier domain/app/dates.js a disparu.
Et les tests :
✓ src/booking/domain/tests/values.test.js (15 tests)
✓ src/booking/domain/tests/book.test.js (7 tests)
Test Files 2 passed (2)
Tests 22 passed (22)Nous sommes passés de 5 tests à 22, et pourtant la suite s'exécute en quelques millisecondes : les tests de valeurs ne touchent ni App, ni les dépendances, ni rien d'asynchrone. C'est le rendement typique d'un domaine bien découpé — le gros des règles se teste sans rien monter.
Vous avez maintenant tout ce qu'il faut pour ajouter les invariants suivants sans grossir la commande :
- la capacité du logement (
accommodation.capacityface àoccupancy.total) - un vacancier ne peut pas avoir deux séjours qui se chevauchent — et
stay.overlaps()est déjà écrit - la durée maximale d'un séjour
Chacun trouvera sa place dans un Value Object ou dans la commande, et vous saurez lequel : posez-vous simplement la question de savoir si la règle a besoin du monde extérieur.
Le rangement est fait. Nous pouvons maintenant construire l'interface par-dessus un domaine qui tient debout.