Prévenir le propriétaire
Relisons le brief du chapitre 1. Il tient en quatre phrases, et nous en avons oublié une :
Lorsque la réservation est faite, le propriétaire et le vacancier reçoivent une notification par mail.
Trente-trois chapitres, et personne n'a jamais été prévenu de rien.
Une question restée en suspens
Le chapitre 1 posait déjà la bonne question :
une "notification par mail" -> est-ce le seul mode de notification ?
Elle est loin d'être théorique, parce qu'elle décide du nom du port — et le nom d'un port décide de ce qu'on pourra y brancher.
Appelez-le mailer.sendMail(...), et le jour où le client voudra un SMS, vous aurez le choix entre un second port et une méthode sendMail qui envoie des SMS. Les deux sont mauvais.
Appelez-le notifications.send(...), et le canal devient un détail d'implémentation — c'est-à-dire un détail d'infrastructure, exactement là où il doit être.
Retenons la règle, elle vaut pour tous vos ports :
Un port se nomme d'après le besoin du domaine, jamais d'après la technologie qui le satisfait.
notifications, pas mailer. dateProvider, pas systemClock. bookings, pas postgres.
Qui est le propriétaire ?
Petit problème : nous ne savons pas à qui écrire.
{ id: "accommodation-1", name: "Villa 6 pièces avec piscine", host: "Claire Vasseur", ... }Une chaîne de caractères. Pas d'email, pas d'identité.
C'était suffisant pour l'afficher sur une carte au chapitre 25. Ça ne l'est plus dès qu'il faut lui parler.
Et le chapitre 1 avait posé cette question-là aussi : des "propriétaires" -> la plateforme concerne-t-elle aussi des hôteliers ? Autrement dit : le propriétaire est-il un utilisateur de la plateforme, ou une simple mention sur une fiche ?
L'expert métier tranche : c'est un utilisateur. Il signe un contrat, il touchera une commission, il devra un jour se connecter pour voir son calendrier.
Donc le logement ne porte pas un nom de propriétaire. Il porte une référence vers un utilisateur :
export const fakeAccommodations = [
{ id: "accommodation-1", hostId: "host-1", name: "Villa 6 pièces avec piscine", ... },
// ...
];export class MemoryUserRepository {
_users = [
{ id: "tenant-1", email: "faketenant@mail.com", hashedPassword: "hashed:secret" },
{ id: "host-1", email: "claire@mail.com", hashedPassword: "hashed:secret" },
];
// ...
}Nous venons de faire, sans y penser, ce qu'une base de données appelle une clé étrangère. Notez que c'est le besoin qui l'a produite, pas un schéma dessiné à l'avance.
Et le nom affiché sur la carte ? Il redevient ce qu'il aurait toujours dû être : une donnée que la vue va chercher, comme elle va chercher le prix. Le chapitre 32 a déjà le service de lecture qu'il faut pour ça.
Le test
Comme pour un repository, la dépendance de test est l'assertion.
// infra/MemoryNotifications.js
export class MemoryNotifications {
sent = [];
async send(notification) {
this.sent.push(notification);
}
}it("Both the tenant and the host are notified of a new booking", async () => {
const app = new App(testDependencies());
await app.run([
login({ email: "faketenant@mail.com", password: "secret" }),
book({ accommodationId: "accommodation-1", adults: 2, children: 0,
from: "2024-06-02", to: "2024-06-04" }),
]);
const { sent } = app.dependencies.notifications;
expect(sent).toHaveLength(2);
expect(sent[0]).toEqual({
type: "BookingConfirmed",
to: "faketenant@mail.com",
payload: { bookingId: "booking-1", accommodationId: "accommodation-1",
from: "2024-06-02", to: "2024-06-04" },
});
expect(sent[1].to).toBe("claire@mail.com");
});Observez ce que ce test ne vérifie pas.
Il ne vérifie pas le contenu du mail. Ni son objet, ni son HTML, ni la couleur du bouton.
Il vérifie qu'une intention a été émise, à destination des bonnes personnes, avec les bonnes données.
Cette distinction est structurante : le domaine décide quoi notifier et qui. L'infrastructure décide comment — le canal, le gabarit, la langue, l'image d'en-tête. Un test de domaine qui vérifie une balise <h1> est un test qui cassera à la première retouche du graphiste, sans qu'aucune règle métier n'ait bougé.
La notification comme valeur
Une notification qui traverse la frontière, c'est un objet plat. Rien d'autre.
// domain/notifications.js
export function BookingConfirmed(to, booking) {
return {
type: "BookingConfirmed",
to,
payload: {
bookingId: booking.id,
accommodationId: booking.accommodationId,
from: booking.stay.from.toString(),
to: booking.stay.to.toString(),
},
};
}Trois propriétés à relever.
Aucun Value Object. from est une chaîne, pas un CalendarDay. Un message destiné à sortir du système ne transporte pas d'objets du domaine — la même règle qu'au chapitre 32 pour les vues. Il partira peut-être dans une file d'attente, sérialisé en JSON, relu par un autre processus une heure plus tard.
Un type en clair. C'est lui qui permettra à l'infrastructure de choisir un gabarit, et à vous de compter les notifications par type dans vos logs.
Aucun texte. Pas de subject: "Votre réservation est confirmée". Le jour où la plateforme sera traduite, ce serait la première chose à arracher.
La version naïve
await dependencies.bookings.save(booking);
const host = await dependencies.users.findById(accommodation.hostId);
await dependencies.notifications.send(BookingConfirmed(user.email, booking));
await dependencies.notifications.send(BookingConfirmed(host.email, booking));
return context;Le test passe. Et cette version est mauvaise.
Prenons-la au sérieux, parce que c'est celle que vous trouverez dans neuf projets sur dix.
Elle fait échouer une réservation valide. Le serveur SMTP tombe, send lève une exception… et l'utilisateur reçoit une erreur alors que sa réservation est enregistrée. Il recommence. Il en crée une deuxième.
Un effet de bord qui rate ne doit pas annuler une décision métier déjà prise. La réservation est confirmée : c'est un fait. Le mail est une conséquence.
Elle envoie des mails à propos de choses qui n'existent pas. Nous n'avons pas encore de transaction, mais nous en aurons une au chapitre 39. Le jour où le save sera annulé par un rollback — un conflit, une contrainte violée, une panne trois lignes plus loin — le mail, lui, sera déjà parti. On ne rejoue pas un mail.
C'est l'asymétrie fondamentale des effets de bord : la base de données sait revenir en arrière, le monde extérieur non.
Elle mélange deux natures de code. La commande fait maintenant deux choses : décider (les invariants) et raconter (les notifications). Chaque nouvelle notification l'allongera : le propriétaire, le vacancier, demain le service comptable, après-demain le webhook du partenaire.
Et surtout, elle mélange deux niveaux de fiabilité. Le refus d'une réservation doit être garanti. L'envoi d'un mail doit être probable, avec des reprises. Ce ne sont pas les mêmes exigences, donc ce n'est pas le même code.
Elle ralentit l'utilisateur. Deux appels SMTP, c'est facilement une seconde. L'utilisateur attend le mail de quelqu'un d'autre avant de voir sa page se rafraîchir.
Ce que nous voulons à la place
La commande devrait pouvoir dire ce qui s'est passé, sans se soucier de qui l'écoute :
await dependencies.bookings.save(booking);
return context.withEvent(BookingConfirmedEvent(booking));Et quelqu'un d'autre, après coup, une fois la commande réussie et la transaction validée, devrait transformer cet événement en notifications.
C'est le sujet du chapitre suivant, et c'est l'un des mécanismes les plus utiles de tout le cours.
Et l'adaptateur, alors ?
Avant d'y aller, terminons le travail commencé : le port a besoin d'une implémentation réelle.
// infra/EmailNotifications.js
import { templates } from "./templates";
export class EmailNotifications {
constructor(mailer, baseUrl) {
this._mailer = mailer;
this._baseUrl = baseUrl;
}
async send({ type, to, payload }) {
const template = templates[type];
if (!template) {
throw new Error(`No email template for notification ${type}`);
}
const { subject, html } = template(payload, this._baseUrl);
await this._mailer.send({ from: "no-reply@booking.example", to, subject, html });
}
}Tout ce que nous avons refusé de laisser entrer dans le domaine se retrouve ici, et s'y trouve bien : l'adresse d'expédition, les gabarits, l'URL publique du site, la bibliothèque d'envoi.
Deux remarques pour finir.
L'EmailNotifications reçoit un mailer dans son constructeur, plutôt que d'importer directement nodemailer ou le SDK d'un service transactionnel. Un adaptateur a le droit d'avoir lui-même des dépendances injectées ; c'est ce qui vous permettra de le tester sans ouvrir de connexion, et de changer de fournisseur en changeant une ligne du container.
Et le throw sur un gabarit manquant est volontaire. Nous avons passé le cours à préférer le résultat à l'exception (chapitre 14) — mais cette règle vaut pour les erreurs métier, celles que l'appelant doit traiter. Un gabarit absent n'est pas un cas métier : c'est un bug de déploiement. Il doit faire du bruit, tout de suite, et pas être poliment retourné dans un Result que personne ne lira.
Distinguez toujours les deux : ce que le système doit gérer, et ce que le développeur doit corriger.
Reste à faire sortir ces notifications de la commande.