Le logement est-il assez grand ?
Reprenons notre liste d'invariants, celle du chapitre 14. Elle attend depuis longtemps.
la date de fin du séjour doit être postérieure à la date d'arrivée✅ chapitre 21on ne peut pas accepter une réservation dans le passé✅ chapitre 21- le nombre d'occupants ne peut pas dépasser la capacité d'accueil du logement
- on ne peut pas réserver un logement qui n'est pas disponible
- un même vacancier ne peut avoir 2 locations sur la même période
Attaquons le troisième.
Le test
Notre jeu d'essai du chapitre 25 contient un studio pour deux personnes :
{ id: "accommodation-3", name: "Studio vue mer", location: "Cassis", capacity: 2, price: 95, ... }Envoyons-y une famille de cinq.
it("A tenant cannot book an accommodation that is too small", async () => {
const app = new App(testDependencies());
const session = await app.run([
login({ email: "faketenant@mail.com", password: "secret" }),
book({
accommodationId: "accommodation-3", // capacité : 2
adults: 2,
children: 3,
from: "2024-06-02",
to: "2024-06-04",
}),
]);
expect(session.error).toEqual(AccommodationTooSmall("accommodation-3", 2, 5));
});Le test échoue. session.error vaut undefined : nous venons de loger cinq personnes dans un studio, et le système a dit oui.
Un invariant qui a besoin du monde extérieur
Le chapitre 21 nous a laissé une règle pour savoir où loger un invariant :
Posez-vous simplement la question de savoir si la règle a besoin du monde extérieur.
Celle-ci en a besoin. Pour la vérifier, il faut connaître la capacité du logement, et cette capacité vit dans l'état du système.
Donc la règle reste dans la commande. Comme le préavis d'un jour, qui a besoin de la date du jour.
Mais nous butons tout de suite sur un manque : aucune de nos dépendances ne sait retourner un logement à partir de son identifiant.
Nos repositories savent faire trois choses (chapitre 20), et lire un logement n'en fait pas partie.
Où ranger les logements ?
Ouvrons le fichier MemoryBookingRepository.js, et regardons-le en face :
export class MemoryBookingRepository {
_bookings = [];
_accommodations = fakeAccommodations; // <-- que fait cette ligne ici ?
...
}Ce tableau est arrivé au chapitre 20, "faute de mieux", pour que getAvailableAccommodations puisse croiser les deux sources. Nous avions noté que ce n'était pas sa place définitive.
Le moment est venu.
Créons le compartiment qui manque :
// infra/MemoryAccommodationRepository.js
import { fakeAccommodations } from "./fakeAccommodations";
export class MemoryAccommodationRepository {
_accommodations = fakeAccommodations;
async findById(id) {
return this._accommodations.find((accommodation) => accommodation.id === id);
}
async all() {
return this._accommodations;
}
}Et ajoutons-le au container :
export const testDependencies = () => {
const accommodations = new MemoryAccommodationRepository();
return {
users: new MemoryUserRepository(),
accommodations,
bookings: new MemoryBookingRepository(accommodations),
dateProvider: testDateProvider,
};
};Vous avez remarqué la dernière ligne : MemoryBookingRepository reçoit maintenant le repository des logements dans son constructeur, au lieu de posséder le tableau.
C'est de l'injection de dépendances, exactement comme dans App, mais un cran plus bas. Un repository qui a besoin d'un autre le reçoit ; il ne va pas le chercher.
export class MemoryBookingRepository {
_bookings = [];
constructor(accommodations) {
this._accommodations = accommodations;
}
// ...
}Et souvenez-vous du chapitre 12 : testDependencies est une fonction. Chaque test repart d'un container neuf, et donc d'un jeu de logements neuf.
all() : la méthode que nous nous étions interdite
Au chapitre 25, j'ai écrit ceci :
Résistez à l'envie d'ajouter dans la foulée un
getAccommodations()qui retournerait toute la liste. Personne ne l'appelle.
Et voilà que j'écris all().
Ce n'est pas une contradiction, c'est la règle qui s'applique : quelqu'un l'appelle, maintenant. getAvailableAccommodations a besoin de la liste complète pour en retrancher les logements occupés. Elle la lisait dans son propre tableau ; elle la demandera au repository voisin.
La règle n'a jamais été "n'ajoutez jamais de méthode". Elle est : "n'ajoutez pas une méthode avant d'avoir un appelant". La différence entre les deux, c'est cinq ans de dette technique.
Une deuxième erreur, offerte
Écrivons la vérification dans la commande :
const accommodation = await dependencies.accommodations.findById(accommodationId);
if (!accommodation) {
return context.withError(UnknownAccommodation(accommodationId));
}
if (guests.value.total > accommodation.capacity) {
return context.withError(
AccommodationTooSmall(accommodationId, accommodation.capacity, guests.value.total)
);
}Le if (!accommodation) n'était pas dans notre test. Il est apparu en écrivant le code, parce que findById retourne undefined quand rien ne correspond.
Prenons trente secondes pour mesurer ce que nous venons de découvrir : jusqu'à cette ligne, notre application acceptait joyeusement de réserver accommodation-42, un logement qui n'existe pas. La réservation partait en base, le calendrier d'un fantôme se remplissait, et aucun test ne s'en plaignait.
Ce n'est pas un hasard si le bug sort maintenant. Il sort parce que nous avons eu besoin, pour la première fois, d'aller lire le logement. Tant qu'on se contente de recopier un identifiant, on ne vérifie jamais qu'il désigne quelque chose.
Ajoutons son test, il ne coûte rien :
it("A tenant cannot book an accommodation that does not exist", async () => {
const app = new App(testDependencies());
const session = await app.run([
login({ email: "faketenant@mail.com", password: "secret" }),
book({ accommodationId: "accommodation-42", adults: 2, children: 0,
from: "2024-06-02", to: "2024-06-04" }),
]);
expect(session.error).toEqual(UnknownAccommodation("accommodation-42"));
});Et les erreurs, comme toujours depuis le chapitre 10, sont des fonctions nommées, exportées à côté de la commande :
export function UnknownAccommodation(accommodationId) {
return new Error(`Unknown accommodation ${accommodationId}`);
}
export function AccommodationTooSmall(accommodationId, capacity, guests) {
return new Error(
`Accommodation ${accommodationId} hosts ${capacity} guests, not ${guests}`
);
}Notez le message : il contient la capacité et le nombre demandé. Le jour où un utilisateur vous écrira "ça marche pas", c'est cette ligne de log qui vous fera gagner l'après-midi.
capacity mérite-t-elle un Value Object ?
Le chapitre 21 nous a donné un critère : une valeur mérite sa classe quand elle réunit au moins deux de ces trois signes — un invariant à protéger, une primitive ambiguë, deux valeurs qui n'ont de sens qu'ensemble.
capacity : un entier positif. Aucune ambiguïté sur l'unité (des personnes). Elle ne se combine avec rien. Un seul signe, et encore.
Elle reste un nombre.
Et l'invariant "une capacité vaut au moins 1" ? Il existe, mais il n'est pas à nous : nous ne créons pas les logements. Le chapitre 19 nous l'a dit — le catalogue est saisi à la main. Le jour où nous coderons la commande addAccommodation, ce sera son travail de refuser une capacité nulle, et ce jour-là Accommodation deviendra probablement une vraie entité avec ses règles.
Pas avant. Une entité anémique qui n'a aucune règle à protéger n'est qu'un objet littéral avec de la cérémonie autour.
La même règle, deux usages
Notre test passe. Mais le chapitre 27 nous a laissé un problème ouvert : la liste affichée montre encore les studios à une famille de six.
Réflexe naturel : recopier la condition dans getAvailableAccommodations.
Réflexe dangereux. Une règle métier écrite à deux endroits, c'est une règle qui sera corrigée à un seul.
Alors extrayons le prédicat, une fois, dans le domaine :
// domain/rules/canHost.js
export function canHost(accommodation, occupancy) {
return occupancy.total <= accommodation.capacity;
}Trois lignes, aucune classe, aucune dépendance. Le chapitre 14 nous y autorisait explicitement : si une fonction suffit, faites une fonction.
La commande l'utilise pour refuser :
if (!canHost(accommodation, guests.value)) {
return context.withError(AccommodationTooSmall(...));
}Et la requête l'utilise pour filtrer :
async getAvailableAccommodations(stay, occupancy) {
const bookedIds = this._bookings
.filter((booking) => booking.stay.overlaps(stay))
.map((booking) => booking.accommodationId);
const all = await this._accommodations.all();
return all
.filter((accommodation) => !bookedIds.includes(accommodation.id))
.filter((accommodation) => !occupancy || canHost(accommodation, occupancy));
}Arrêtons-nous sur la distinction, parce qu'elle est structurante et qu'elle revient dans tous les projets :
| Commande | Requête | |
|---|---|---|
| ce qu'elle fait de la règle | elle refuse | elle filtre |
| si elle l'oublie | l'état du système devient faux | l'écran affiche du bruit |
| peut-on s'en passer | jamais | temporairement, oui |
Ce ne sont pas deux implémentations d'une même chose. Ce sont deux usages du même prédicat, avec deux niveaux d'exigence très différents — exactement le tableau du chapitre 4.
Le filtre de la requête est un confort d'affichage. Le refus de la commande est une garantie. Si vous ne devez en coder qu'un, codez le second : un utilisateur qui voit un logement trop petit est déçu, un système qui enregistre une réservation impossible est cassé.
Et notez le !occupancy || : le paramètre est optionnel. Un appelant qui ne connaît pas encore le nombre de voyageurs obtient toute la liste. Nous aurions pu l'imposer et corriger les appelants — mais nous n'en avons qu'un, et il est en train d'être écrit. Ne rendez pas obligatoire ce que vous ne savez pas encore fournir partout.
Côté frontend, le loader du chapitre 27 se complète d'une ligne :
async function loader({ from, to, adults, children }) {
const stay = Stay.parse({ from, to });
if (stay.isError()) return { accommodations: [], error: stay.error.message };
const guests = Occupancy.of({ adults, children });
if (guests.isError()) return { accommodations: [], error: guests.error.message };
const accommodations = await app.dependencies.bookings.getAvailableAccommodations(
stay.value,
guests.value
);
return { accommodations, error: null };
}Deux parse, deux erreurs possibles, aucune règle métier réécrite dans l'UI.
Un invariant de moins sur la liste. Passons au suivant, et c'est le plus intéressant de tous.