Livrez des applications générées par l'IA plus sûres en vous appuyant sur les contraintes PostgreSQL pour NOT NULL, CHECK, UNIQUE et FOREIGN KEY avant le code et les tests.

Le code généré par l'IA a souvent l'air correct parce qu'il gère le chemin heureux. Les vraies applications cassent au milieu du bazar : un formulaire envoie une chaîne vide au lieu de null, un job en arrière-plan réessaye et crée deux fois le même enregistrement, ou une suppression supprime une ligne parente et laisse des enfants orphelins. Ce ne sont pas des bugs exotiques. Ils se manifestent par des champs requis vides, des valeurs « uniques » en double et des lignes orphelines qui ne pointent sur rien.
Ils passent aussi à travers la revue de code et les tests basiques pour une raison simple : les relecteurs lisent l'intention, pas chaque cas limite. Les tests couvrent généralement quelques exemples typiques, pas des semaines de comportements réels d'utilisateurs, des importations CSV, des réessais réseau instables ou des requêtes concurrentes. Si un assistant a généré le code, il peut manquer de petites vérifications critiques comme retirer les espaces, valider des plages ou se protéger contre des conditions de concurrence.
« Contraintes d'abord, code ensuite » signifie que vous placez des règles non négociables dans la base de données pour empêcher la sauvegarde de mauvaises données, quel que soit le chemin de code qui tente de les écrire. Votre appli doit toujours valider les entrées pour des messages d'erreur plus clairs, mais la base de données fait respecter la vérité. C'est là que les contraintes PostgreSQL excellent : elles vous protègent de catégories entières d'erreurs.
Un exemple rapide : imaginez un petit CRM. Un script d'import généré par l'IA crée des contacts. Une ligne a un email de "" (vide), deux lignes répètent le même email avec des capitalisations différentes, et un contact référence un account_id qui n'existe pas parce que le compte a été supprimé par un autre processus. Sans contraintes, tout ça peut atterrir en production et casser les rapports plus tard.
Avec les bonnes règles en base, ces écritures échouent immédiatement, près de la source. Les champs obligatoires ne peuvent pas manquer, les doublons ne peuvent pas s'infiltrer lors des réessais, les relations ne peuvent pas pointer vers des enregistrements supprimés ou inexistants, et les valeurs ne peuvent pas sortir des plages autorisées.
Les contraintes n'empêchent pas tous les bugs. Elles ne corrigeront pas une interface confuse, un mauvais calcul de remise ou une requête lente. Mais elles empêchent les mauvaises données de s'accumuler silencieusement, ce qui est souvent l'endroit où les « bugs liés aux cas limites générés par l'IA » deviennent coûteux.
Votre appli n'est presque jamais une seule base de code parlant à un seul utilisateur. Un produit typique a une UI web, une appli mobile, des écrans d'administration, des jobs en arrière-plan, des importations CSV et parfois des intégrations tierces. Chaque chemin peut créer ou modifier des données. Si chaque chemin doit se souvenir des mêmes règles, l'un d'eux va oublier.
La base de données est l'unique endroit que tous partagent. Quand vous la traitez comme le gardien final, les règles s'appliquent automatiquement partout. Les contraintes PostgreSQL transforment « on suppose que c'est toujours vrai » en « cela doit être vrai, sinon l'écriture échoue. »
Le code généré par l'IA rend cela encore plus important. Un modèle peut ajouter une validation dans une UI React mais manquer un cas limite dans un job en arrière-plan. Ou il peut bien gérer les données du chemin heureux, puis casser quand un vrai client saisit quelque chose d'inattendu. Les contraintes attrapent les problèmes au moment où de mauvaises données cherchent à entrer, pas des semaines plus tard quand vous déboguez des rapports étranges.
Quand vous sautez les contraintes, les mauvaises données sont souvent silencieuses. La sauvegarde réussit, l'appli continue, et le problème réapparaît plus tard sous forme de ticket support, d'écart de facturation ou d'un tableau de bord en qui on ne fait plus confiance. Le nettoyage coûte cher parce que vous réparez l'histoire, pas une requête unique.
Les mauvaises données glissent généralement par des situations courantes : une nouvelle version cliente envoie un champ vide au lieu d'absent, un réessai crée des doublons, une modification admin contourne les vérifications UI, un fichier d'import a un format incohérent, ou deux utilisateurs mettent à jour des enregistrements liés en même temps.
Un modèle mental utile : n'acceptez des données que si elles sont valides à la frontière. En pratique, cette frontière devrait inclure la base de données, car c'est elle qui voit toutes les écritures.
NOT NULL est la contrainte PostgreSQL la plus simple, et elle empêche une classe étonnamment large de bugs. Si une valeur doit exister pour que la ligne ait du sens, faites-en sorte que la base de données l'impose.
NOT NULL est généralement pertinent pour les identifiants, les noms requis et les timestamps. Si vous ne pouvez pas créer un enregistrement valide sans cette valeur, n'autorisez pas qu'elle soit vide. Dans un petit CRM, un lead sans owner ou sans created_at n'est pas un « lead partiel ». Ce sont des données cassées qui causeront des comportements bizarres plus tard.
NULL s'insinue plus souvent avec du code généré par l'IA parce qu'il est facile de créer des chemins « optionnels » sans s'en rendre compte. Un champ de formulaire peut être optionnel dans la UI, une API peut accepter une clé manquante, et une branche d'une fonction de création peut oublier d'assigner une valeur. Tout compile et le test du chemin heureux passe. Puis des utilisateurs importent un CSV avec des cellules vides, ou un client mobile envoie un payload différent, et NULL atterrit dans la base.
Un bon pattern est de combiner NOT NULL avec une valeur par défaut sensée pour les champs que le système possède :
created_at TIMESTAMP NOT NULL DEFAULT now()status TEXT NOT NULL DEFAULT 'new'is_active BOOLEAN NOT NULL DEFAULT trueLes valeurs par défaut ne sont pas toujours une bonne idée. Ne mettez pas de valeur par défaut sur des champs fournis par l'utilisateur comme email ou company_name juste pour satisfaire NOT NULL. Une chaîne vide n'est pas « plus valide » que NULL. Elle masque simplement le problème.
Quand vous hésitez, décidez si la valeur est véritablement inconnue, ou si elle représente un état différent. Si « pas encore fournie » a du sens, envisagez une colonne d'état séparée plutôt que de permettre NULL partout. Par exemple, laissez phone nullable, mais ajoutez phone_status avec des valeurs comme missing, requested ou verified. Cela conserve la signification de façon cohérente dans votre code.
Une contrainte CHECK est une promesse que fait votre table : chaque ligne doit respecter une règle, à chaque fois. C'est l'un des moyens les plus simples d'empêcher que des cas limites ne créent silencieusement des enregistrements qui semblent corrects en code mais n'ont pas de sens en réalité.
Les CHECK fonctionnent mieux pour des règles qui ne dépendent que des valeurs de la même ligne : plages numériques, valeurs autorisées et relations simples entre colonnes.
-- 1) Totals should never be negative
ALTER TABLE invoices
ADD CONSTRAINT invoices_total_nonnegative
CHECK (total_cents \u003e= 0);
-- 2) Enum-like allowed values without adding a custom type
ALTER TABLE tickets
ADD CONSTRAINT tickets_status_allowed
CHECK (status IN ('new', 'open', 'waiting', 'closed'));
-- 3) Date order rules
ALTER TABLE subscriptions
ADD CONSTRAINT subscriptions_date_order
CHECK (end_date IS NULL OR end_date \u003e= start_date);
Un bon CHECK est lisible d'un coup d'œil. Traitez-le comme de la documentation pour vos données. Préférez des expressions courtes, des noms de contraintes clairs et des patterns prévisibles.
CHECK n'est pas l'outil adapté à tout. Si une règle demande de regarder d'autres lignes, des agrégats ou de comparer entre tables (par exemple « un compte ne peut pas dépasser la limite de son plan »), gardez cette logique dans le code applicatif, des triggers ou un job contrôlé en arrière-plan.
Une règle UNIQUE est simple : la base refuse de stocker deux lignes qui ont la même valeur dans la colonne contrainte (ou la même combinaison de valeurs sur plusieurs colonnes). Cela éradique toute une classe de bugs où un chemin de création s'exécute deux fois, un réessai se produit ou deux utilisateurs soumettent la même chose simultanément.
UNIQUE garantit l'absence de doublons pour les valeurs exactes que vous définissez. Elle ne garantit pas que la valeur soit présente (NOT NULL), qu'elle respecte un format (CHECK) ou qu'elle corresponde à votre idée d'égalité (casse, espaces, ponctuation) à moins que vous ne l'ayez défini.
Les endroits communs où vous voudrez généralement l'unicité incluent l'email dans une table users, external_id d'un autre système, ou un nom qui doit être unique au sein d'un compte comme (account_id, name).
Un piège : NULL et UNIQUE. Dans PostgreSQL, NULL est traité comme « inconnu », donc plusieurs valeurs NULL sont autorisées sous une contrainte UNIQUE. Si vous voulez dire « la valeur doit exister et être unique », combinez UNIQUE avec NOT NULL.
Un pattern pratique pour des identifiants visibles par les utilisateurs est l'unicité insensible à la casse. Les gens taperont « [email protected] » puis plus tard « [email protected] » et s'attendront à ce que ce soit la même chose.
-- Case-insensitive unique email
CREATE UNIQUE INDEX users_email_unique_ci
ON users (lower(email));
-- Unique contact name per account
ALTER TABLE contacts
ADD CONSTRAINT contacts_account_name_unique UNIQUE (account_id, name);
Définissez ce que « doublon » signifie pour vos utilisateurs (casse, espaces, par compte vs global), puis encodez-le une fois pour que tous les chemins applicatifs suivent la même règle.
Une FOREIGN KEY dit « cette ligne doit pointer vers une vraie ligne là-bas ». Sans elle, le code peut créer silencieusement des enregistrements orphelins qui semblent valides isolément mais cassent l'application plus tard. Par exemple : une note qui référence un client supprimé, ou une facture qui pointe vers un user_id qui n'a jamais existé.
Les clés étrangères sont cruciales quand deux actions se produisent proche l'une de l'autre : une suppression et une création, un réessai après timeout, ou un job en arrière-plan fonctionnant sur des données obsolètes. La base est meilleure pour faire respecter la cohérence que chaque chemin applicatif qui devrait se souvenir de la vérifier.
L'option ON DELETE doit correspondre au sens réel de la relation. Demandez-vous : « Si le parent disparaît, l'enfant doit-il exister ? »
RESTRICT (ou NO ACTION) : bloquer la suppression du parent si des enfants existent.CASCADE : supprimer les enfants lors de la suppression du parent.SET NULL : garder l'enfant mais supprimer le lien.Faites attention avec CASCADE. Cela peut être correct, mais aussi effacer plus que prévu lorsqu'un bug ou une action admin supprime un parent.
Dans les applis multi-tenant, les clés étrangères ne concernent pas que la correction. Elles empêchent aussi les fuites inter-comptes. Un pattern courant est d'inclure account_id sur chaque table possédée par un tenant et de lier les relations via ce champ.
CREATE TABLE contacts (
account_id bigint NOT NULL,
id bigint GENERATED ALWAYS AS IDENTITY,
PRIMARY KEY (account_id, id)
);
CREATE TABLE notes (
account_id bigint NOT NULL,
id bigint GENERATED ALWAYS AS IDENTITY,
contact_id bigint NOT NULL,
body text NOT NULL,
PRIMARY KEY (account_id, id),
FOREIGN KEY (account_id, contact_id)
REFERENCES contacts (account_id, id)
ON DELETE RESTRICT
);
Cela impose « qui possède quoi » dans le schéma : une note ne peut pas pointer vers un contact d'un autre compte, même si le code applicatif (ou une requête générée par un LLM) essaie.
Commencez par écrire une courte liste d'invariants : des faits qui doivent toujours être vrais. Gardez-les simples. « Chaque contact a besoin d'un email. » « Un status doit être parmi quelques valeurs autorisées. » « Une facture doit appartenir à un client réel. » Ce sont les règles que vous voulez que la base applique à chaque fois.
Déployez les changements via de petites migrations pour que la production ne soit pas surprise :
NOT NULL, UNIQUE, CHECK, FOREIGN KEY).La partie compliquée est les données existantes erronées. Planifiez-la. Pour les doublons, choisissez une ligne gagnante, fusionnez les autres et conservez une petite note d'audit. Pour les champs requis manquants, choisissez une valeur par défaut seulement si c'est réellement sûr ; sinon mettez-les en quarantaine. Pour les relations cassées, réaffectez les enfants au bon parent ou supprimez les lignes fautives.
Après chaque migration, validez avec quelques écritures qui devraient échouer : insérez une ligne sans valeur requise, insérez une clé dupliquée, insérez une valeur hors plage, et référencez un parent manquant. Les écritures échouées sont des signaux utiles. Elles vous montrent où l'appli comptait silencieusement sur un comportement « best effort ».
Imaginez un petit CRM : accounts (chaque client de votre SaaS), companies avec lesquelles ils travaillent, contacts dans ces companies, et deals liés à une company.
C'est exactement le type d'appli que les gens génèrent rapidement avec un outil de chat. Elle a l'air correcte en démo, mais les vraies données deviennent vite désordonnées. Deux bugs apparaissent souvent tôt : contacts en double (le même email saisi deux fois légèrement différemment), et deals créés sans company parce qu'un chemin de code a oublié de définir company_id. Un autre classique est une valeur de deal négative après une refactorisation ou une erreur de parsing.
La solution n'est pas plus de if-statements. Ce sont quelques contraintes judicieusement choisies qui rendent impossible le stockage de mauvaises données.
-- Contacts: prevent duplicates per account
ALTER TABLE contacts
ADD CONSTRAINT contacts_account_email_uniq UNIQUE (account_id, email);
-- Deals: require a company and keep the relationship valid
ALTER TABLE deals
ALTER COLUMN company_id SET NOT NULL,
ADD CONSTRAINT deals_company_fk
FOREIGN KEY (company_id) REFERENCES companies(id);
-- Deals: deal value cannot be negative
ALTER TABLE deals
ADD CONSTRAINT deals_value_nonneg CHECK (deal_value \u003e= 0);
-- A few obvious required fields
ALTER TABLE companies
ALTER COLUMN name SET NOT NULL;
ALTER TABLE contacts
ALTER COLUMN email SET NOT NULL;
Il ne s'agit pas d'être strict pour le plaisir. Vous transformez des attentes vagues en règles que la base peut appliquer à chaque écriture, peu importe quelle partie de l'application écrit les données.
Une fois ces contraintes en place, l'application devient plus simple. Vous pouvez retirer beaucoup de vérifications défensives qui tentaient de détecter les doublons a posteriori. Les échecs deviennent clairs et exploitables (par exemple « cet email existe déjà pour ce compte » au lieu d'un comportement descendant étrange). Et quand une route API générée oublie un champ ou manipule mal une valeur, l'écriture échoue immédiatement au lieu de corrompre silencieusement la base.
Les contraintes fonctionnent mieux quand elles correspondent à la réalité métier. La plupart des problèmes viennent d'ajouter des règles qui semblent « sûres » sur le moment mais qui deviennent des surprises plus tard.
Un piège courant est d'utiliser ON DELETE CASCADE partout. C'est propre jusqu'à ce que quelqu'un supprime un parent et que la base efface la moitié du système. Les cascades peuvent être appropriées pour des données véritablement « possédées » (comme des lignes de facture brouillon qui ne doivent jamais exister seules), mais elles sont risquées pour des enregistrements jugés importants (clients, factures, tickets). Si vous n'êtes pas sûr, préférez RESTRICT et gérez les suppressions intentionnellement.
Un autre problème est d'écrire des CHECK trop étroits. « Status must be 'new', 'won', or 'lost' » semble bien jusqu'à ce que vous ayez besoin de « paused » ou « archived ». Une bonne contrainte CHECK décrit une vérité stable, pas un choix d'UI temporaire. « amount \u003e= 0 » vieillit bien. « country in (...) » souvent non.
Quelques soucis récurrents quand des équipes ajoutent des contraintes après que du code généré soit déjà en production :
CASCADE comme un outil de nettoyage, puis supprimer plus de données que prévu.Sur la performance : PostgreSQL crée automatiquement un index pour UNIQUE, mais les clés étrangères n'indexent pas automatiquement la colonne référencée. Sans cet index, les updates et deletes sur le parent peuvent devenir lents parce que Postgres doit scanner la table enfant pour vérifier les références.
Avant de durcir une règle, trouvez les lignes existantes qui échoueraient, décidez si vous les corrigez ou les mettez en quarantaine, et déployez le changement par étapes.
Avant de livrer, prenez cinq minutes par table et notez ce qui doit toujours être vrai. Si vous pouvez le dire en anglais simple, vous pouvez généralement l'appliquer avec une contrainte.
Posez ces questions pour chaque table :
Si vous utilisez un outil de construction piloté par chat, traitez ces invariants comme des critères d'acceptation pour les données, pas comme des notes optionnelles. Par exemple : « Le montant d'un deal doit être non négatif », « L'email d'un contact est unique par workspace », « Une tâche doit référencer un contact réel. » Plus les règles sont explicites, moins il y a de place pour des cas limites accidentels.
Koder.ai (koder.ai) inclut des fonctionnalités comme le mode planning, les snapshots et le rollback, et l'export du code source, ce qui peut faciliter l'itération sur des changements de schéma en toute sécurité pendant que vous renforcez progressivement les contraintes.
Un pattern de déploiement simple qui marche en équipe : choisissez une table à fort impact (users, orders, invoices, contacts), ajoutez 1-2 contraintes qui évitent les pires échecs (souvent NOT NULL et UNIQUE), corrigez les écritures qui échouent, puis répétez. Resserer les règles progressivement vaut mieux qu'une grosse migration risquée.