Le repository SQL
Nous avons un schéma. Écrivons l'implémentation qui va avec MemoryBookingRepository.
Le chapitre 21 avait annoncé son rôle :
C'est le rôle de la couche
infra: traduire. Le jour où nous écrironsSQLBookingRepository, c'est lui — et lui seul — qui saura questaydevient deux colonnesDATE.
Ce jour est arrivé.
La connexion ne s'invente pas toute seule
Première ligne, première décision.
// infra/SQLBookingRepository.js
export class SQLBookingRepository {
constructor(db) {
this._db = db;
}
// ...
}Le repository reçoit sa connexion. Il ne la crée pas, ne lit pas process.env, n'ouvre rien.
C'est la même règle qu'au chapitre 28 pour MemoryBookingRepository, et elle a trois conséquences très concrètes : on peut lui passer une connexion de test, on peut lui passer une transaction en cours (nous en aurons besoin au chapitre 39), et le nombre de connexions ouvertes est décidé à un seul endroit.
Ce seul endroit, c'est le container, assemblé au démarrage :
// infra/productionDependencies.js
import pg from "pg";
export function productionDependencies(config) {
const db = new pg.Pool({ connectionString: config.databaseUrl, max: 10 });
const accommodations = new SQLAccommodationRepository(db);
return {
users: new SQLUserRepository(db),
accommodations,
bookings: new SQLBookingRepository(db),
sessions: new SQLSessionRepository(db),
queries: new SQLQueries(db),
notifications: new EmailNotifications(mailer(config), config.baseUrl),
dateProvider: systemDateProvider,
idProvider: uuidProvider,
passwords: argonPasswordHasher,
logger: pinoLogger,
};
}Regardez ce fichier : c'est le seul de toute l'application qui connaisse à la fois PostgreSQL, le service de mail, argon2 et l'horloge système. On l'appelle la racine de composition. C'est le seul endroit où le monde réel entre.
Et remarquez sa symétrie parfaite avec testDependencies() du chapitre 12. Même forme, mêmes clés, deux mondes.
Le piège qui attend tout le monde
Avant même la première requête, il faut désamorcer quelque chose.
Ouvrez un client pg et lisez une colonne date :
const { rows } = await db.query("select starts_on from bookings limit 1");
console.log(rows[0].starts_on);
// 2024-06-02T00:00:00.000Z <-- un objet Date !node-postgres convertit gentiment les colonnes date en objets Date JavaScript, à minuit dans le fuseau du serveur.
Autrement dit : nous avons passé le chapitre 21 à expulser les instants du domaine, choisi le type date au chapitre 36 pour ne pas les laisser rentrer, et le pilote les réintroduit à la lecture. Un serveur configuré en UTC+2 relit le 2 juin comme 1er juin 22h UTC, et un formatage distrait affichera le 1er juin.
C'est un bug classique, saisonnier — il apparaît au changement d'heure — et pénible à diagnostiquer.
La parade tient en trois lignes, à écrire une fois, au démarrage :
// infra/pgTypes.js
import pg from "pg";
const DATE_OID = 1082;
pg.types.setTypeParser(DATE_OID, (value) => value); // "2024-06-02", tel quelNous demandons au pilote de nous rendre la chaîne, sans interprétation. C'est exactement ce que CalendarDay.parse attend.
Le principe dépasse pg : méfiez-vous des conversions automatiques aux frontières. Une bibliothèque qui devine ce que vous voulez devine parfois autre chose. Ici, le pilote fait un choix raisonnable pour la plupart des applications — et faux pour la nôtre, parce que nous avons pris la peine de distinguer un jour d'un instant.
(Pendant que vous y êtes : le type bigint est retourné en chaîne par pg, volontairement, parce qu'il dépasse Number.MAX_SAFE_INTEGER. Ne le convertissez pas sans réfléchir.)
Traduire, dans les deux sens
Voici le cœur du travail, et il tient dans deux fonctions privées :
import { Booking } from "../domain/entities/Booking";
import { Stay } from "../domain/values/Stay";
import { Occupancy } from "../domain/values/Occupancy";
function toDomain(row) {
const stay = Stay.parse({ from: row.starts_on, to: row.ends_on });
const guests = Occupancy.of({ adults: row.adults, children: row.children });
if (stay.isError() || guests.isError()) {
// Une ligne illisible n'est pas une erreur métier : c'est une base corrompue.
throw new Error(`Corrupted booking ${row.id}: ${stay.error ?? guests.error}`);
}
return new Booking({
id: row.id,
tenantId: row.tenant_id,
accommodationId: row.accommodation_id,
guests: guests.value,
stay: stay.value,
status: row.status,
});
}
function toRow(booking) {
return {
id: booking.id,
tenant_id: booking.tenantId,
accommodation_id: booking.accommodationId,
adults: booking.guests.adults,
children: booking.guests.children,
starts_on: booking.stay.from.toString(),
ends_on: booking.stay.to.toString(),
status: booking.status,
};
}Trois choses à relever.
Le snake_case s'arrête ici. Les colonnes s'appellent tenant_id, les propriétés tenantId. Chaque monde garde ses conventions, et la traduction se fait à la frontière — dans ces deux fonctions, nulle part ailleurs. Ne cédez jamais à la tentation de nommer vos colonnes en camelCase pour "simplifier" : PostgreSQL les mettrait en minuscules et vous écririez des guillemets partout.
On reconstruit de vraies entités. Pas des objets littéraux ressemblants. Ce qui sort de findById est un Booking gelé, avec ses Stay et son Occupancy, sur lequel isActive() et belongsTo() fonctionnent. Sans cela, la commande du chapitre 33 planterait en production tout en passant tous ses tests en mémoire.
Un throw, et pas un Result. Voilà qui semble contredire le chapitre 14.
Pas du tout, et la distinction est la même qu'au chapitre 34 : un Result sert aux erreurs métier, celles que l'appelant doit traiter. Une chaîne illisible dans notre propre base n'est pas un cas métier — c'est un bug, une migration ratée, une corruption. Personne n'a de plan B à proposer à l'utilisateur.
Formulé autrement, et c'est une bonne règle générale :
Des données venues de l'extérieur peuvent être invalides : on les parse, on retourne un
Result. Des données venues de chez nous ne peuvent pas être invalides : si elles le sont, on s'arrête bruyamment.
Les requêtes
async findById(id) {
const { rows } = await this._db.query(
`select * from bookings where id = $1`, [id]
);
return rows[0] ? toDomain(rows[0]) : null;
}
async findOverlapping(accommodationId, stay) {
const { rows } = await this._db.query(
`select * from bookings
where accommodation_id = $1
and status = 'confirmed'
and daterange(starts_on, ends_on, '[)')
&& daterange($2::date, $3::date, '[)')`,
[accommodationId, stay.from.toString(), stay.to.toString()]
);
return rows.map(toDomain);
}
async save(booking) {
const row = toRow(booking);
await this._db.query(
`insert into bookings
(id, tenant_id, accommodation_id, adults, children, starts_on, ends_on, status)
values ($1, $2, $3, $4, $5, $6, $7, $8)
on conflict (id) do update set status = excluded.status`,
[row.id, row.tenant_id, row.accommodation_id, row.adults,
row.children, row.starts_on, row.ends_on, row.status]
);
}$1, $2 : des paramètres, jamais de concaténation. Le jour où un identifiant vient d'une URL — c'est-à-dire toujours — une concaténation est une injection SQL. C'est la vulnérabilité la plus vieille du web et elle n'a jamais quitté le classement.
L'opérateur && sur deux daterange teste le recouvrement. C'est exactement Stay.overlaps, exprimé dans le langage de la base, bornes semi-ouvertes comprises. Nous y voilà : la règle du chapitre 21 existe désormais en deux exemplaires, comme le chapitre 29 l'avait annoncé. Le chapitre 38 s'occupe de les empêcher de mentir.
Le on conflict (id) do update traduit le upsert que le chapitre 33 avait fait apparaître en mémoire. Et notez qu'il ne met à jour que le statut : c'est la seule chose qu'une réservation existante puisse changer chez nous. Un do update set qui recopie toutes les colonnes autoriserait, silencieusement, de modifier les dates d'une réservation enregistrée.
Traduire aussi les erreurs
Il reste un cas, et c'est celui qui distingue une intégration soignée d'un 500 Internal Server Error.
La contrainte no_double_booking du chapitre 36 va, un jour, refuser une insertion. C'est même son travail : quand deux réservations simultanées passent toutes les deux à travers notre findOverlapping, la base en rejette une.
Sans précaution, pg lève alors une exception opaque, remontée telle quelle jusqu'à l'utilisateur, avec un nom de contrainte dedans.
Or ce n'est pas une panne. C'est un cas métier parfaitement connu, qui porte déjà un nom depuis le chapitre 29 :
import { AccommodationNotAvailable } from "../domain/usecases/book";
const EXCLUSION_VIOLATION = "23P01";
const UNIQUE_VIOLATION = "23505";
async save(booking) {
try {
await this._db.query(/* ... */);
} catch (error) {
if (error.code === EXCLUSION_VIOLATION) {
throw AccommodationNotAvailable(booking.accommodationId);
}
throw error;
}
}C'est une deuxième responsabilité de la frontière, qu'on oublie presque toujours : elle traduit les données et les erreurs. Ce qui entre dans le domaine parle le langage du domaine, y compris quand ça rate.
Et remarquez que nous ne traduisons que les codes que nous connaissons. Tout le reste — connexion perdue, disque plein, table absente — remonte intact. Une couche qui avale les erreurs qu'elle ne comprend pas transforme une panne franche en comportement inexplicable.
Le côté lecture
Le service de lecture du chapitre 32 est celui qui profite le plus du changement :
async bookingsOfTenant(tenantId) {
const { rows } = await this._db.query(
`select b.id, b.status, b.starts_on, b.ends_on,
b.adults + b.children as guests,
(b.ends_on - b.starts_on) as nights,
a.id as accommodation_id, a.name, a.location, a.image_url,
a.price_cents * (b.ends_on - b.starts_on) as price_cents
from bookings b
join accommodations a on a.id = b.accommodation_id
where b.tenant_id = $1
order by b.starts_on desc`,
[tenantId]
);
return rows.map((row) => ({ /* ... la vue du chapitre 32 ... */ }));
}Une seule requête. Le problème N+1 du chapitre 32 n'existe plus, et la Map que nous avions construite à la main s'appelle maintenant join.
Notez b.ends_on - b.starts_on : soustraire deux date en PostgreSQL donne un nombre entier de jours. C'est notre Stay.nights, gratuitement, sans conversion d'instants.
Et notez surtout que cette méthode ne construit aucun objet du domaine. Elle produit directement la vue destinée à l'écran. C'était déjà la règle du chapitre 32 ; elle prend ici tout son sens, puisqu'elle nous épargne de reconstruire douze entités pour en extraire douze noms.
Ce que nous n'avons pas jeté
MemoryBookingRepository reste. Il n'est pas devenu une maquette abandonnée : c'est lui qui fait tourner nos quarante tests de domaine en quelques millisecondes, et c'est lui qui permet à un designer de lancer l'application sans installer PostgreSQL.
Nous avons désormais deux implémentations du même port, et une inquiétude légitime : rien ne garantit qu'elles se comportent pareil.
C'est précisément le sujet du chapitre suivant.