📐 Construire une app en JS
36. Choisir une base de données

Choisir enfin une base de données

Trente-cinq chapitres sans base de données.

Ce n'était pas de la procrastination, c'était une méthode. Le chapitre 12 l'avait formulée en une ligne :

Retarder les choix techniques = imposer une architecture modulaire.

Le moment est venu de choisir. Et la différence, c'est qu'aujourd'hui nous savons exactement ce que nous demandons.

Le cahier des charges est déjà écrit

Il ne se trouve pas dans un document Word. Il se trouve dans nos ports, et il tient en quinze lignes :

users:          findByEmail(email) · findById(id)
accommodations: findById(id) · all()
bookings:       save(booking) · findById(id)
                findOverlapping(accommodationId, stay)
                listBookingsForAccommodationId(id) · listBookingsForTenantId(id)
sessions:       create(user) · findUserId(token) · destroy(token)
queries:        bookingsOfTenant(tenantId) · getAvailableAccommodations(stay, occupancy)

Voilà. C'est tout ce que notre application demande à un système de stockage, après six mois d'usage imaginaire et une quarantaine de tests.

Comparez avec la façon dont ce choix se fait habituellement : au premier jour, avant la première ligne de métier, sur la base d'un article de blog. C'est le "Ah mince, on n'aurait jamais dû choisir MongoDB" du chapitre 20.

Lisons cette liste comme un architecte.

Il y a des relations, et elles comptent. Une réservation pointe vers un logement et vers un utilisateur ; un logement pointe vers son propriétaire. Nos requêtes croisent systématiquement deux ou trois de ces tables (chapitre 32).

Il y a une requête d'intervalle. findOverlapping demande "toutes les réservations de ce logement qui recouvrent cette période". C'est une requête sur une plage de dates, et elle doit rester rapide quand la table grossit.

Il y a un invariant de concurrence non résolu. Le chapitre 29 a laissé un it.fails : deux réservations simultanées passent toutes les deux. Nous savons depuis le chapitre 6 que la réponse est une transaction, ou un verrou.

Il y a une écriture unique et beaucoup de lectures. Le tableau du chapitre 4, à la ligne "Volume".

Il n'y a aucun schéma variable. Une réservation a toujours les mêmes champs. Aucun besoin de stocker des documents de formes différentes.

Le choix

PostgreSQL.

Non pas parce que c'est la mode — c'est même à peu près l'inverse d'une mode — mais parce que chaque ligne du cahier des charges y trouve une réponse directe.

Les relations : des jointures, et des clés étrangères qui refusent une réservation pointant vers un logement inexistant. Nous avons codé ce contrôle à la main au chapitre 28 ; la base le tiendra en plus, et elle le tiendra même contre un script d'import écrit à la va-vite un vendredi soir.

Les intervalles : le type daterange et les index GiST.

La concurrence : les transactions, bien sûr. Mais surtout ceci, qui mérite qu'on s'arrête :

create extension if not exists btree_gist;
 
alter table bookings
  add constraint no_double_booking
  exclude using gist (
    accommodation_id with =,
    daterange(starts_on, ends_on, '[)') with &&
  ) where (status = 'confirmed');

Lisez cette contrainte à voix haute : il ne peut pas exister deux réservations confirmées du même logement dont les plages de dates se recouvrent.

C'est notre invariant du chapitre 29, écrit une fois, garanti par le moteur, y compris entre deux transactions simultanées. Le test it.fails deviendra vert grâce à ces cinq lignes.

Et remarquez le '[)' : intervalle fermé à gauche, ouvert à droite. La rotation du samedi, celle qui valait la moitié du chiffre d'affaires au chapitre 21, est exprimée dans la contrainte elle-même. PostgreSQL a une notation pour ça parce que c'est un problème universel.

Ce n'est pas un détail exotique. C'est le moment où l'on constate qu'un invariant métier bien nommé finit par se traduire, presque mot pour mot, dans la couche la plus basse du système.

Et si nous avions choisi autre chose ?

Faisons l'exercice honnêtement, parce que "Postgres, toujours" n'est pas un raisonnement.

MongoDB. Les transactions multi-documents existent depuis la 4.0 ; ce n'est plus l'argument. Ce qui manque, c'est la contrainte d'exclusion : aucun moyen de dire "pas de recouvrement" au moteur. Il faudrait la garantir dans l'application — donc avec un verrou distribué, donc avec une pièce de plus. Nos requêtes croisent trois collections, ce qui suppose soit des $lookup, soit des documents dupliqués à maintenir. On peut le faire. On travaille juste contre l'outil.

SQLite. Sérieusement envisageable, et souvent injustement écarté. Elle tient très bien une petite plateforme, elle n'a aucune administration, et elle rend les tests d'intégration instantanés. Mais elle n'a ni daterange, ni contrainte d'exclusion, et son verrouillage en écriture est global — ce qui devient gênant le jour où plusieurs processus écrivent. Excellent choix pour démarrer, à condition de savoir ce qu'on troque.

Un service de "backend as a service". Le chapitre 7 a déjà répondu : la concurrence d'accès se gère dans le backend. Une commande de réservation exécutée dans le navigateur de chaque client ne peut rien garantir.

Le raisonnement à retenir n'est pas la conclusion, c'est la méthode : on part de ce que le domaine demande, et on choisit l'outil qui le donne. Pas l'inverse.

Faut-il un ORM ?

Question suivante, et elle divise plus que le choix de la base.

Un ORM apporte trois choses : la traduction objets ↔ tables, un langage de requête portable, et la gestion de l'identité des objets en mémoire.

Regardons ce dont nous avons besoin.

La traduction, nous allons l'écrire : c'est le rôle du repository, et c'est trente lignes.

Le langage portable, nous n'en voulons pas — nous venons de choisir PostgreSQL précisément pour des fonctionnalités qu'aucune couche portable n'exposera jamais. Un ORM qui masque exclude using gist nous coûte l'argument principal du choix.

L'identité en mémoire, nous n'en avons aucun usage : nos entités sont gelées et reconstruites à chaque lecture.

Reste le vrai risque, et il est structurel : un ORM veut être votre modèle de domaine. Il propose des classes avec des annotations, des relations paresseuses, des objets qui se sauvent tout seuls quand une propriété change. C'est exactement le "modèle réduit à une couche d'accès à la base de données" que le chapitre 22 dénonçait — et c'est incompatible avec des entités immutables construites par des fonctions statiques.

Alors le critère est simple :

Un constructeur de requêtes vous aide. Un ORM qui veut définir vos entités vous remplace.

En pratique, du SQL écrit à la main via le pilote pg, ou un constructeur de requêtes typé (Kysely, Drizzle) qui ne prétend rien être d'autre. Le SQL reste visible, et il reste dans un seul répertoire : infra.

Si votre équipe préfère Prisma ou TypeORM, ce n'est pas un drame — à une condition : que les classes générées restent dans infra, et que le repository continue de retourner vos entités. Le jour où un objet de l'ORM remonte dans une commande, la frontière est tombée, et vous ne pourrez plus jamais changer de base.

Le schéma

Il se déduit des ports, table par table :

create table users (
  id              text primary key,
  email           text not null unique,
  hashed_password text not null,
  created_at      timestamptz not null default now()
);
 
create table accommodations (
  id         text primary key,
  host_id    text not null references users(id),
  name       text not null,
  location   text not null,
  capacity   integer not null check (capacity >= 1),
  price_cents integer not null check (price_cents >= 0),
  image_url  text
);
 
create table bookings (
  id               text primary key,
  tenant_id        text not null references users(id),
  accommodation_id text not null references accommodations(id),
  adults           integer not null check (adults >= 1),
  children         integer not null check (children >= 0),
  starts_on        date not null,
  ends_on          date not null,
  status           text not null check (status in ('confirmed', 'cancelled')),
  created_at       timestamptz not null default now(),
  check (starts_on < ends_on)
);
 
create index bookings_accommodation_range
  on bookings using gist (accommodation_id, daterange(starts_on, ends_on, '[)'));

Quatre points méritent un commentaire, et chacun est une décision prise plus tôt dans le cours.

starts_on date, et surtout pas timestamptz.

C'est la traduction directe du chapitre 21. Un timestamptz est un instant : il porte une heure, donc un fuseau, donc l'ambiguïté que nous avons passé un chapitre entier à éliminer. date stocke un jour du calendrier — exactement ce qu'est un CalendarDay.

Choisir timestamptz ici, ce serait rouvrir la porte au niveau le plus bas du système, celui où plus personne ne relira le code. Et ce serait invisible pendant des mois : le bug n'apparaîtrait que pour les utilisateurs à l'étranger.

Le nom des colonnes suit : starts_on / ends_on, pas start_date / end_date, et surtout pas from / tofrom est un mot réservé SQL, et vous passeriez votre vie à le mettre entre guillemets.

price_cents integer.

Jamais de nombre flottant pour de l'argent. 0.1 + 0.2 ne vaut pas 0.3, en SQL comme en JavaScript. On stocke des centimes entiers, et la division n'a lieu qu'à l'affichage. (numeric fait aussi l'affaire ; l'entier a l'avantage d'être trivialement juste.)

Il n'y a pourtant pas de paiement (chapitre 19). C'est justement le bon moment : changer un type de colonne avant qu'il ne contienne des données, c'est gratuit.

Les check répètent nos invariants.

capacity >= 1, adults >= 1, starts_on < ends_on : ce sont les règles d'Occupancy et de Stay, écrites une seconde fois.

Duplication ? Oui, et assumée — la même que celle du chapitre 29. Le domaine refuse pour donner un message clair à l'utilisateur ; la base refuse pour que la donnée reste vraie même quand elle est écrite par un script de migration, un correctif manuel ou un import. Elles ne protègent pas contre les mêmes accidents.

created_at partout.

Nous n'en avons aucun usage aujourd'hui. C'est la seule colonne que je vous invite à ajouter sans besoin, parce qu'elle est impossible à reconstituer après coup. Le jour où quelqu'un demandera "combien de réservations en mars ?", elle sera là — ou elle ne sera pas.

Le schéma est du code

Dernier point, et c'est celui qui fait le plus de dégâts quand il est négligé.

Ce fichier SQL n'est pas un document. C'est du code source, versionné, revu, appliqué de la même façon en développement, en test et en production.

Concrètement : un répertoire de migrations numérotées, jamais modifiées une fois appliquées, et une commande qui les rejoue dans l'ordre.

migrations/
  000_extensions.sql
  001_users.sql
  002_accommodations.sql
  003_bookings.sql
  004_no_double_booking.sql

Ce 000_extensions.sql n'est pas décoratif, et il mérite une histoire.

btree_gist est l'extension qui permet à un index GiST de mélanger un text et un daterange. Écrivez-la dans la migration 004, avec la contrainte qui l'utilise, et tout semble logique — jusqu'à ce que la 003 s'exécute, crée son index, et échoue :

ERROR: data type text has no default operator class for access method "gist"

Une extension se crée avant le premier objet qui en dépend, pas à côté. C'est le genre d'erreur qu'aucune relecture n'attrape et que la première exécution révèle en trois secondes.

Trois règles qui vous éviteront des nuits blanches :

On n'édite jamais une migration déjà appliquée. On en ajoute une nouvelle. La 004 corrige la 003 ; elle ne la remplace pas.

On ne modifie jamais la production à la main. Un alter table tapé en urgence dans une console, c'est un écart entre le schéma réel et le schéma versionné, et personne ne le retrouvera avant le prochain déploiement.

Une migration doit pouvoir tourner pendant que l'application tourne. Ajouter une colonne not null sans valeur par défaut sur une table de dix millions de lignes verrouille la table, et votre site est hors ligne le temps du déploiement. La parade est connue : ajouter la colonne en nullable, remplir par lots, puis contraindre.

Nous avons une base, un schéma, et toujours zéro ligne de SQL dans le domaine.

Écrivons le repository.