« Bon attends, il faut qu'on se voie toi et moi, ça colle pas »
Vendredi matin, 9 h 15. Françoise est dans son cockpit — trois écrans, à gauche l'Excel-pointeuse qu'elle tient à jour depuis quinze ans, à droite Sage, et au milieu Rembrandt depuis trois semaines. Sa tasse à la main, celle avec sa tête imprimée dessus que quelqu'un lui a offerte à Noël. Elle pivote sur sa chaise et me lance depuis son bureau :
« Michel, combien on a d'inscrits pour la rentrée, dis-moi ? »
Je tape SELECT COUNT(*) FROM inscriptions WHERE statut='inscrit' et je lui sors un chiffre. Elle le note, pointe sur son Excel, recompte ligne à ligne comme elle fait toujours, et quarante-cinq secondes plus tard je l'entends sortir du bureau d'à côté :
« Bon attends, il faut qu'on se voie toi et moi, ça colle pas. »
Céline s'était inscrite en septembre pour sept cours. Un seul contrat, une seule signature, un seul paiement échelonné. Et pourtant, dans ma table inscriptions, sept lignes. Céline comptait pour sept élèves.
Ce n'est pas une anecdote. C'est le moment où j'ai compris que le nom de ma table et l'objet qu'elle stockait avaient cessé de se correspondre. Et cette divergence m'a appris quelque chose sur ce que c'est, modéliser un métier.
Si tu as 30 secondes. Quand le nom d'une table cesse de correspondre à ce qu'elle stocke (ici
inscriptionsqui stocke en réalité des places), trois options classiques se présentent : statu quo, renommer, scinder. Il en existe une quatrième, plus discrète : garder le schema et tenir la règle comme un invariant documenté. Ce qui suit : le cas métier réel, trois requêtes SQL qui arrêtent de mentir, et une discipline applicable à tes propres schemas ambigus.
Le piège de la modélisation naïve
Quand j'ai commencé Rembrandt, qui est le nom de l'ERP de notre réseau d'ateliers de céramique (six sites, quelques centaines d'élèves), j'ai raisonné comme tout développeur qui ne connaît son métier que de loin. Un élève, un cours, une inscription. Trois tables évidentes, trois clés étrangères propres. La table inscriptions portait logiquement ce nom parce que je pensais qu'une ligne y représentait un acte d'inscription.
Certes, j'avais mis un index UNIQUE sur (contact_id, cours_id). Techniquement, le multi-cours était possible. Je n'y avais pas vraiment pensé. Je l'avais fait par réflexe, parce qu'on met toujours un composite unique pour éviter les doublons.
Mais le vrai métier est arrivé. Céline prend sept cours parce qu'elle aime la céramique et qu'elle a du temps. D'autres élèves en prennent deux ou trois. En pratique, moins de 15 % des inscrits cumulent, mais c'est suffisant pour que chaque requête mal scopée mente. Or j'avais écrit beaucoup de requêtes mal scopées, non par négligence mais parce que le nom de la table m'avait suggéré une fausse identité.
La nuit où j'ai compté les mensonges
Audit du 11 au 12 avril. Je compare le Google Sheet CRM de la rentrée avec ma base Supabase. J'y trouve 81 places multi-cours manquantes dans Rembrandt, et 60 contacts orphelins qui flottaient sans rattachement à un cours. Le sheet, lui, gérait proprement une élève comme Céline dans ses colonnes. Françoise n'avait jamais eu le problème — elle pointait à la main, ligne par ligne, depuis quinze ans. C'est son Excel qui avait raison.
J'aurais pu migrer en silence et continuer à vivre avec un schema qui ment. J'ai fait l'inverse. J'ai écrit la règle que j'aurais dû écrire en novembre.
Une ligne de la table
inscriptionsreprésente une place (un contact × un cours), pas une inscription commerciale.
Ce que le métier appelle « une inscription », à savoir le contrat annuel signé par Céline, n'a plus de ligne dédiée dans la base. Cette inscription est dérivée, reconstruite à la volée depuis la table contacts (colonne statut, colonne code_cours qui agrège les codes des cours occupés). La place, elle, est stockée. Deux objets métier, un seul stocké, l'autre calculé.
Trois options, une seule tenable
J'ai considéré trois alternatives avant de trancher.
| Option | Ce que ça coûte | Ce que ça donne |
|---|---|---|
| Statu quo | Rien à court terme | Des doubles comptes à vie, un piège pour chaque requête future |
Renommer inscriptions en places |
Migration lourde : FKs, RLS, triggers, vues, scripts d'audit, code applicatif | Sémantique explicite, zéro ambiguïté |
Scinder en deux tables (inscriptions commerciales + places cours) |
Chantier multi-semaines, duplique les workflows | Modèle domaine propre et extensible |
J'ai choisi une quatrième option, plus discrète. Garder le schema, clarifier la sémantique, tenir la règle comme un invariant. Le nom de la table continue de mentir, et je l'assume. Ce que je ne peux plus faire, c'est écrire une requête sans me rappeler ce que la table stocke réellement.
Pourquoi ce choix ? Parce qu'un nom de table, une fois qu'il a été utilisé dans des migrations, des RLS, des triggers, des index, des vues, du code métier et des scripts d'audit, cesse d'être un nom et devient une infrastructure. Le renommer pour corriger une erreur sémantique, c'est bouger une colonne porteuse pour repeindre un mur. La règle, elle, tient en une ligne et se rappelle dans tous les skills Claude.
Je ne songe pas à nier que scinder en deux tables serait le modèle le plus propre. Il le deviendra peut-être le jour où nous aurons des tarifs variables par contrat, ou des conventions OPCO à plusieurs stagiaires et plusieurs cours groupés sur une même facture. J'ai documenté ces seuils dans l'ADR qui porte la décision. Au-dessus d'un certain volume, la clarification sémantique ne suffit plus, la structure doit suivre. On n'y est pas.
Ce qu'une table découpe vraiment
Il y a là une leçon qui dépasse mon cas. Une base de données n'enregistre pas le réel, elle le découpe. Et ce découpage porte des décisions implicites sur ce qu'est une chose. Un acte commercial ? Un créneau occupé ? Les deux à la fois ? Quand le métier parle d'inscriptions et que la base stocke des places, ce n'est pas une faute d'orthographe. C'est le signe qu'on a fait un choix, peut-être sans le savoir, sur l'unité pertinente.
Le choix conscient, chez moi, est arrivé tard, après les bugs, après l'audit, après Françoise me regardant un matin avec un chiffre d'inscrits qui n'était pas le bon. C'est rarement dans l'autre sens.
Ce que ça change au quotidien
La règle se décline en cinq gestes concrets.
Compter des élèves distincts passe maintenant par COUNT(DISTINCT contact_id) FROM inscriptions, jamais COUNT(*). Compter des places occupées dans un cours donné utilise WHERE cours_id=X, et la colonne dénormalisée cours.places_inscrits reste maintenue par un trigger. Les UPSERTs multi-cours passent tous par onConflict='contact_id,cours_id' avec un pipe-add sur contacts.code_cours. Les labels UI parlent de « places », pas d'« inscrits », partout où la source est la table inscriptions. Et le skill rembrandt-conventions que Claude charge automatiquement rappelle la règle à chaque session de code sur ce périmètre.
Le lendemain matin, je suis retourné voir Françoise avec le bon chiffre. Elle a pointé, recompté, reposé sa tasse, et lâché un verdict sans relever la tête :
« Bon. Faut pas se prendre la tête, c'est déjà ça. »
Le schema tient sur 91 000 lignes de code, six sites, 29 jours de production. Pas parce qu'il est beau. Parce qu'on a fini par nommer ce qu'il fait — et parce que Françoise, au bureau d'à côté, pointe toujours sur son Excel à côté de Rembrandt.
Ce que tu peux copier dans ton projet
Les trois requêtes ci-dessous, plus un schéma minimal contacts / cours / inscriptions avec l'index composite, vivent dans le repo compagnon de la série, licence MIT : github.com/michelfaure/rembrandt-samples.
Trois requêtes SQL qui traduisent la règle d'invariant, directement utilisables sur tout schema où une table à clé composite stocke des relations plutôt que des entités :
-- Compter des personnes distinctes, jamais des lignes
SELECT COUNT(DISTINCT contact_id) FROM inscriptions;
-- Compter des places dans un cours donné
SELECT COUNT(*) FROM inscriptions WHERE cours_id = $1;
-- Upsert multi-cours sans écraser les autres places du même contact
INSERT INTO inscriptions (contact_id, cours_id, statut) VALUES ($1, $2, $3)
ON CONFLICT (contact_id, cours_id) DO UPDATE SET statut = EXCLUDED.statut;
Et une discipline plus large : dès que le nom d'une table et son contenu divergent, inscris la règle d'invariant dans ton CLAUDE.md ou équivalent, et rappelle-la dans le skill qui charge automatiquement le contexte projet. Tu feras moins d'erreurs que si tu mises sur ta mémoire.
Et vous, où vos noms de tables mentent-ils ? Je lis les commentaires.
Code compagnon : rembrandt-samples/inscriptions-places/ — le schema minimal contact × cours et les trois requêtes SQL qui répondent à « combien d'inscrits » sans mentir, licence MIT.

Top comments (0)