Le front parle au back
Le domaine est derrière une API. Le frontend doit maintenant l'appeler.
C'est le moment de vérité de tout le cours : combien de code faut-il changer dans l'interface ?
Le monorepo se réorganise
Avant d'écrire une ligne, un problème de rangement.
Notre package @booking/core contient le domaine et l'infrastructure. Le chapitre 37 y a mis SQLBookingRepository, qui importe pg.
Si le frontend continue d'importer @booking/core, le paquet pg part dans le navigateur. Au mieux la compilation échoue ; au pire elle réussit, et vous expédiez du code serveur — avec ses chaînes de connexion — dans un fichier JavaScript public.
Séparons :
packages
|- core le domaine. Zéro dépendance.
|- infra repositories SQL, mailer, argon2, pg. Dépend de core.
|- api le serveur HTTP du chapitre 40. Dépend de core et infra.
|- webapp React. Dépend de core, uniquement.Ce découpage n'est pas cosmétique. Il rend l'erreur impossible : webapp ne déclare pas infra dans ses dépendances, donc aucun import ne peut y remonter, même par accident, même dans six mois, même par un développeur pressé.
Et remarquez pourquoi c'est possible : notre domaine n'a aucune dépendance. Pas de pg, pas de framework, pas de bibliothèque de dates — nous avons écrit CalendarDay nous-mêmes au chapitre 21. Ce que nous avions présenté comme une discipline de conception se révèle être une propriété très concrète : un domaine pur peut s'exécuter partout.
C'est ce qui va nous permettre de garder les Value Objects côté navigateur.
Le client d'API
Un fichier, une fonction par route, aucune logique :
// webapp/src/api.js
const request = async (path, options = {}) => {
const response = await fetch(`/api${path}`, {
credentials: "include", // le cookie de session voyage
headers: { "Content-Type": "application/json" },
...options,
});
const body = await response.json().catch(() => ({}));
if (!response.ok) {
throw new ApiError(response.status, body.error ?? "Erreur inattendue");
}
return body;
};
export const api = {
logIn: (credentials) => request("/sessions", { method: "POST", body: JSON.stringify(credentials) }),
logOut: () => request("/sessions", { method: "DELETE" }),
availableAccommodations: (criteria, signal) =>
request(`/accommodations?${new URLSearchParams(criteria)}`, { signal }),
book: (payload) => request("/bookings", { method: "POST", body: JSON.stringify(payload) }),
myBookings: (signal) => request("/bookings", { signal }),
cancel: (bookingId) => request(`/bookings/${bookingId}/cancellation`, { method: "POST" }),
};Quatre pièges désamorcés dans ces quinze lignes.
credentials: "include". Sans lui, fetch n'envoie pas les cookies dès que l'origine diffère, et vous obtenez un 401 incompréhensible alors que la session existe. C'est l'erreur numéro un du passage au cookie.
fetch ne lève pas d'exception sur un 404 ou un 500. Il ne rejette que sur une panne réseau. Une réponse d'erreur est une réponse : response.ok est le seul test qui vaille. Oublier cette ligne, c'est traiter une erreur serveur comme un succès et afficher une liste vide.
.catch(() => ({})) sur le json(). Un 502 renvoyé par un proxy est du HTML, pas du JSON. Sans ce garde-fou, votre gestion d'erreur plante en essayant de lire l'erreur.
Le signal. C'est l'AbortController promis au chapitre 27 : il remplace notre drapeau obsolete, et il a le bon goût d'annuler aussi la requête réseau au lieu de la laisser courir.
Ce qui change dans les pages
Voici le loader du chapitre 28 :
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 };
}Et le même, à travers le réseau :
async function loader({ from, to, adults, children }, signal) {
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 { data } = await api.availableAccommodations({ from, to, adults, children }, signal);
return { accommodations: data, error: null };
}Une ligne.
Et l'action de réservation du chapitre 31 :
async function action(accommodationId, criteria) {
await api.book({ accommodationId, ...criteria });
}authenticate(session.token) a disparu : le cookie est joint automatiquement, et c'est le contrôleur qui pose le péage.
Les composants, eux, ne changent pas du tout. Ni HomePage, ni Layout, ni Accommodation, ni MyBookingsPage. Pas une ligne.
C'est le dividende du modèle en trois fonctions du chapitre 22 :
LOADER --- alimente ---> RENDER
| |
re-déclenche déclenche
^ |
| |
----------- ACTION <------Loader et action sont les deux seuls points de contact avec le monde extérieur. Que ce monde soit un objet en mémoire ou un serveur à Francfort ne regarde pas l'affichage. Un composant qui appelle fetch directement aurait rendu ce chapitre impossible.
Pourquoi garder Stay.parse dans le navigateur ?
Objection légitime : le serveur valide déjà. Ces quatre lignes ne sont-elles pas du gaspillage ?
Non, et la distinction est importante.
La validation côté serveur est une garantie. Elle est obligatoire, elle ne peut pas être contournée, elle protège l'intégrité du système.
La validation côté client est un confort. Elle dit à l'utilisateur, immédiatement, que sa date de départ précède sa date d'arrivée — sans aller-retour réseau, sans écran de chargement, sans erreur cryptique.
Ce sont deux besoins différents. Ce qui serait coupable, c'est de les écrire deux fois : une règle recopiée est une règle qui divergera. Ici, c'est le même code, le même Stay.parse, avec les mêmes tests. Le navigateur et le serveur ne peuvent pas être d'accords différents.
C'est le seul avantage sérieux de JavaScript des deux côtés du réseau, et il ne s'obtient qu'à une condition : que le domaine soit pur.
Ce qui traverse le réseau
Regardez ce que le serveur envoie :
{
"data": [
{ "id": "accommodation-1", "name": "Villa 6 pièces avec piscine",
"location": "Saint-Rémy-de-Provence", "capacity": 8, "price": 230 }
],
"currentUser": { "id": "tenant-1", "email": "faketenant@mail.com" }
}Des chaînes, des nombres, des booléens.
Aucune conversion à écrire. Aucun reviver dans JSON.parse. Aucun new Stay(...) à la réception.
Ce n'est pas de la chance. C'est le chapitre 30 (session() ne retourne que trois champs choisis), le chapitre 32 (le service de lecture ne retourne aucun objet du domaine) et le chapitre 35 (les événements sont plats). À chaque fois, la même règle :
Ce qui sort du domaine n'est pas ce qui y vit.
Nous l'avons appliquée par prudence, chapitre après chapitre, sans le réseau. Elle se rembourse aujourd'hui d'un coup.
Faites l'expérience inverse mentalement : si getAvailableAccommodations avait retourné des entités avec des CalendarDay, il faudrait aujourd'hui écrire une couche de sérialisation, une couche de désérialisation, et les tester. Puis découvrir que JSON.stringify transforme silencieusement les champs privés #iso en rien du tout.
Un détail de développement qui fait perdre une journée
Vite sert le frontend sur le port 5173, l'API écoute sur le 3000. Deux origines différentes : le navigateur bloque, et vous découvrez CORS.
La bonne réponse n'est pas d'ouvrir CORS en grand. C'est de faire croire au navigateur qu'il n'y a qu'une seule origine :
// packages/webapp/vite.config.js
export default defineConfig({
plugins: [react()],
server: {
proxy: { "/api": { target: "http://localhost:3000", changeOrigin: true } },
},
});Toute requête vers /api est relayée. Plus de CORS, plus de sameSite: "none", et surtout : le développement ressemble à la production, où l'API et le site seront servis sous le même domaine.
Une configuration de développement qui diverge de la production est une source de bugs qu'aucun test n'attrape.
Une troisième famille d'erreurs
Jusqu'ici, une action pouvait échouer pour une seule raison : le domaine refusait.
Il y en a maintenant trois.
| origine | exemple | ce que l'utilisateur doit lire |
|---|---|---|
| métier | logement indisponible | "Ce logement vient d'être réservé" |
| protocole | 401, 403, 404, 409 | dépend — reconnexion, page introuvable… |
| réseau | serveur injoignable, Wi-Fi coupé | "Connexion perdue. Réessayez." |
Et une quatrième, la pire : aucune réponse. La requête part, le serveur traite, la connexion tombe avant la réponse. Le client ne sait pas si la réservation a été faite.
C'est la difficulté propre au réseau, et elle n'a pas de solution locale : le client ne peut pas deviner. Il peut seulement soit demander (recharger la liste), soit rendre le rejeu inoffensif — ce qu'on appelle une clé d'idempotence.
Nous avons donc gagné un réseau, et perdu la certitude.
C'est le sujet du chapitre suivant.