Lever veiligere door AI gegenereerde apps door te vertrouwen op PostgreSQL-beperkingen (NOT NULL, CHECK, UNIQUE, FOREIGN KEY) vóór code en tests.

Door AI geschreven code ziet er vaak correct uit omdat het de 'happy path' afdekt. Echte applicaties falen in het rommelige midden: een formulier stuurt een lege string in plaats van null, een background job probeert opnieuw en maakt per ongeluk hetzelfde record twee keer aan, of een delete verwijdert een ouderrij en laat kinderen achter. Dit zijn geen exotische bugs. Ze verschijnen als lege verplichte velden, dubbele "unieke" waarden en verweesde rijen die naar niets verwijzen.
Ze glippen ook door code reviews en basis-tests om een simpele reden: reviewers lezen intentie, niet elk randgeval. Tests dekken meestal een paar typische voorbeelden, niet weken van werkelijk gebruikersgedrag, imports uit CSV's, onbetrouwbare netwerkretries of gelijktijdige verzoeken. Als een assistent de code heeft gegenereerd, kan hij kleine maar kritieke controles missen zoals het trimmen van witruimte, validatie van bereiken, of bescherming tegen race conditions.
"Beperkingen eerst, code daarna" betekent dat je niet-onderhandelbare regels in de database zet zodat slechte data niet opgeslagen kan worden, ongeacht welke codepath probeert te schrijven. Je app moet nog steeds input valideren voor betere foutmeldingen, maar de database handhaaft de waarheid. Daar blinken PostgreSQL-beperkingen uit: ze beschermen je tegen hele categorieën fouten.
Een kort voorbeeld: stel je een kleine CRM voor. Een door AI gegenereerd importscript maakt contacten aan. Eén regel heeft een e-mailadres van "" (leeg), twee regels herhalen hetzelfde e-mailadres met verschillende hoofdletters, en één contact verwijst naar een account_id die niet bestaat omdat het account in een ander proces is verwijderd. Zonder beperkingen kan dit allemaal in productie terechtkomen en later rapporten breken.
Met de juiste databaseregels mislukken die schrijfacties meteen, dicht bij de bron. Verplichte velden kunnen niet ontbreken, duplicaten kunnen niet ongemerkt binnensluipen tijdens retries, relaties kunnen niet naar verwijderde of niet-bestaande records wijzen, en waarden kunnen niet buiten toegestane bereiken vallen.
Constraints voorkomen niet elke bug. Ze lossen geen verwarrende UI, een verkeerde kortingsberekening of een trage query op. Maar ze stoppen wel dat slechte data zich stilletjes opstapelt — vaak de plek waar "AI-geproduceerde randgeval-bugs" duur worden.
Je app is zelden één codebase die met één gebruiker praat. Een typisch product heeft een web-UI, een mobiele app, admin-schermen, background jobs, imports uit CSV, en soms integraties van derden. Elk pad kan data creëren of wijzigen. Als elk pad dezelfde regels moet onthouden, vergeet er vroeg of laat één.
De database is de plek die ze allemaal deelt. Als je die als de laatste poortwachter behandelt, gelden regels automatisch voor alles. PostgreSQL-beperkingen veranderen "we gaan ervan uit dat dit altijd waar is" in "dit moet waar zijn, anders faalt de write."
Door AI gegenereerde code maakt dit nog belangrijker. Een model kan form-validatie aan een React-UI toevoegen maar een hoekgeval in een background job missen. Of het gaat goed met happy-path data en faalt wanneer een echte klant iets onverwachts invoert. Constraints vangen problemen op op het moment dat slechte data probeert binnen te komen, niet weken later wanneer je vreemde rapporten debugged.
Als je constraints overslaat, is slechte data vaak stil. De save slaagt, de app gaat door, en het probleem verschijnt later als een supportticket, een factuurverschil of een dashboard dat niemand vertrouwt. Opschonen is duur omdat je geschiedenis repareert, niet één request.
Slechte data sluipt meestal binnen via alledaagse situaties: een nieuwe clientversie stuurt een veld als leeg in plaats van afwezig, een retry maakt duplicaten, een admin-wijziging omzeilt UI-checks, een importbestand heeft inconsistente opmaak, of twee gebruikers updaten tegelijkertijd gerelateerde records.
Een nuttig mentaal model: accepteer data alleen als het geldig is aan de grens. In de praktijk moet die grens de database omvatten, omdat de database alle writes ziet.
NOT NULL is de eenvoudigste PostgreSQL-constraint en voorkomt een verrassend grote klasse bugs. Als een waarde aanwezig moet zijn voor het record zin heeft, laat de database dat afdwingen.
NOT NULL is meestal terecht voor identifiers, verplichte namen en timestamps. Als je zonder die waarde geen geldig record kunt maken, sta het dan niet toe dat het leeg is. In een kleine CRM is een lead zonder eigenaar of aanmaaktijd geen "deels lead" — het is kapotte data die later vreemd gedrag veroorzaakt.
NULL sluipt vaker binnen bij door AI gegenereerde code omdat het makkelijk is om "optionele" paden te maken zonder het te merken. Een formulierveld kan optioneel zijn in de UI, een API kan een ontbrekende key accepteren, en een tak van een create-functie kan nalaten een waarde toe te wijzen. Alles compileert nog steeds en de happy-path test slaagt. Dan importeren echte gebruikers een CSV met lege cellen, of een mobiele client stuurt een ander payload, en NULL staat in de database.
Een goed patroon is om NOT NULL te combineren met een zinvolle default voor velden die het systeem beheert:
created_at TIMESTAMP NOT NULL DEFAULT now()status TEXT NOT NULL DEFAULT 'new'is_active BOOLEAN NOT NULL DEFAULT trueDefaults zijn niet altijd een zegen. Geef geen standaardwaarden voor door gebruikers aangeleverde velden zoals email of company_name alleen om NOT NULL te bevredigen. Een lege string is niet "meer geldig" dan NULL. Het verbergt alleen het probleem.
Als je twijfelt, bepaal dan of de waarde echt onbekend is, of dat het een andere staat vertegenwoordigt. Als "nog niet opgegeven" betekenis heeft, overweeg dan een afzonderlijke statuskolom in plaats van overal NULL toe te staan. Bijvoorbeeld: houd phone nullable, maar voeg phone_status toe zoals missing, requested of verified. Dat houdt de betekenis consistent door je code heen.
Een CHECK-constraint is een belofte van je tabel: elke rij moet aan een regel voldoen, elke keer. Het is een van de makkelijkste manieren om randgevallen te voorkomen die stilletjes records maken die in code prima lijken maar in het echt geen zin hebben.
CHECK-constraints werken het best voor regels die alleen afhangen van waarden in dezelfde rij: numerieke bereiken, toegestane waarden en eenvoudige relaties tussen kolommen.
-- 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);
Een goede CHECK is in één oogopslag leesbaar. Behandel het als documentatie voor je data. Geef de voorkeur aan korte expressies, duidelijke constraint-namen en voorspelbare patronen.
CHECK is niet geschikt voor alles. Als een regel andere rijen moet opzoeken, aggregaten moet gebruiken of over tabellen heen moet vergelijken (bijvoorbeeld: "een account mag zijn planlimiet niet overschrijden"), houd die logica dan in applicatiecode, triggers of een gecontroleerde background job.
Een UNIQUE-regel is simpel: de database weigert twee rijen met dezelfde waarde in de geconstrueerde kolom (of dezelfde combinatie over meerdere kolommen) op te slaan. Dit schakelt een hele klasse bugs uit waarbij een "create"-pad twee keer draait, een retry plaatsvindt of twee gebruikers hetzelfde tegelijk indienen.
UNIQUE garandeert geen aanwezigheid (NOT NULL), geen format (CHECK) of je idee van gelijkheid (hoofdletters, spaties, interpunctie) tenzij je dat vastlegt.
Veelvoorkomende plekken voor uniekheid zijn email in een user-tabel, external_id van een ander systeem, of een naam die uniek moet zijn binnen een account zoals (account_id, name).
Een aandachtspunt: NULL en UNIQUE. In PostgreSQL wordt NULL behandeld als "onbekend", dus meerdere NULL-waarden zijn toegestaan onder een UNIQUE-constraint. Als je bedoelt "de waarde moet bestaan en uniek zijn", combineer dan UNIQUE met NOT NULL.
Een praktisch patroon voor gebruikersidentificatie is case-insensitive uniekheid. Mensen typen "[email protected]" en later "[email protected]" en verwachten hetzelfde adres.
-- 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);
Definieer wat "duplicaat" voor je gebruikers betekent (hoofdletters, witruimte, per-account vs globaal), en leg het één keer vast zodat elk codepad dezelfde regel volgt.
Een FOREIGN KEY zegt: "deze rij moet naar een echte rij daar wijzen." Zonder die sleutel kan code stilletjes verweesde records maken die op zichzelf geldig lijken maar later de app breken. Bijvoorbeeld: een notitie die naar een klant verwijst die verwijderd is, of een factuur die naar een user_id wijst die nooit bestond.
Foreign keys zijn het belangrijkst wanneer twee acties dichtbij elkaar gebeuren: een delete en een create, een retry na een timeout, of een background job die met verouderde data draait. De database is beter in het afdwingen van consistentie dan dat elk app-pad zich dat moet herinneren.
De ON DELETE-optie moet overeenkomen met de werkelijke betekenis van de relatie. Vraag: "Als de ouder verdwijnt, mag het kind dan nog bestaan?"
RESTRICT (of NO ACTION): blokkeer het verwijderen van de ouder als er kinderen zijn.CASCADE: het verwijderen van de ouder verwijdert ook de kinderen.SET NULL: behoud het kind maar verwijder de koppeling.Wees voorzichtig met CASCADE. Het kan correct zijn, maar het kan ook meer wissen dan je verwacht wanneer een bug of admin-actie een ouderrecord verwijdert.
In multi-tenant apps gaan foreign keys niet alleen over correctheid. Ze voorkomen ook dat data tussen accounts lekt. Een veelgebruikt patroon is om account_id in elke tenant-eigendomstabel op te nemen en relaties daar doorheen te koppelen.
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
);
Dit dwingt in het schema af "wie wat bezit": een notitie kan niet naar een contact in een ander account wijzen, zelfs als de app-code (of een LLM-gegeneerde query) dat probeert.
Begin met een korte lijst invarianten: feiten die altijd waar moeten zijn. Houd ze helder. "Elke contactpersoon heeft een e-mail." "Een status moet één van een paar toegestane waarden zijn." "Een factuur moet bij een echte klant horen." Dit zijn de regels die je door de database wilt laten afdwingen.
Rol wijzigingen uit in kleine migraties zodat productie niet wordt verrast:
NOT NULL, UNIQUE, CHECK, FOREIGN KEY).Het rommelige gedeelte is bestaande slechte data. Plan daarvoor. Voor duplicaten: kies een winnaar-rij, merge de rest en houd een kleine auditnotitie. Voor ontbrekende verplichte velden: kies alleen een veilige default als die echt veilig is; anders quarantaineer. Voor gebroken relaties: wijs de kind-rijen opnieuw toe aan de juiste ouder of verwijder de slechte rijen.
Na elke migratie valideer je met een paar writes die moeten falen: insert een rij met een ontbrekende verplichte waarde, insert een duplicate key, insert een buiten bereik waarde, en verwijs naar een ontbrekende ouderrij. Mislukte writes zijn nuttige signalen. Ze laten zien waar de app stilletjes op "best effort" gedrag vertrouwde.
Stel je een kleine CRM voor: accounts (elke klant van je SaaS), bedrijven waar ze mee werken, contacten bij die bedrijven en deals gekoppeld aan een bedrijf.
Dit is precies het soort app dat mensen snel genereren met een chattool. Het lijkt prima in demo's, maar echte data wordt snel rommelig. Twee bugs die vroeg optreden: dubbele contacten (zelfde e-mail twee keer in net iets andere vorm) en deals die zonder bedrijf worden aangemaakt omdat één codepad company_id vergat te zetten. Een ander klassiek probleem is een negatieve dealwaarde na een refactor of parsingfout.
De oplossing is niet meer if-statements. Het zijn een paar goedgekozen beperkingen die het onmogelijk maken om slechte data op te slaan.
-- 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;
Dit gaat niet over streng zijn om het streng zijn. Je zet vage verwachtingen om in regels die de database elke keer kan afdwingen, ongeacht welk deel van de app data schrijft.
Zodra deze constraints er staan, wordt de app eenvoudiger. Je kunt veel defensieve checks verwijderen die duplicaten achteraf proberen te detecteren. Fouten worden helder en actiegericht (bijvoorbeeld: "email bestaat al voor dit account" in plaats van vreemd downstream gedrag). En wanneer een gegenereerde API-route een veld vergeet of een waarde verkeerd verwerkt, faalt de write meteen in plaats van de database stilletjes te corrumperen.
Constraints werken het best wanneer ze overeenkomen met hoe het bedrijf echt werkt. Het meeste ongemak ontstaat door regels toe te voegen die in het moment "veilig" lijken maar later verrassingen veroorzaken.
Een veelvoorkomende valkuil is ON DELETE CASCADE overal gebruiken. Het ziet er netjes uit totdat iemand een ouder verwijdert en de database de helft van het systeem wist. Cascades kunnen juist zijn voor echt eigendom (zoals conceptregelitems die nooit alleen mogen bestaan), maar ze zijn risicovol voor records die mensen belangrijk vinden (klanten, facturen, tickets). Als je het niet zeker weet, geef de voorkeur aan RESTRICT en behandel deletes bewust.
Een ander probleem is CHECK-regels die te nauw zijn. "Status moet 'new', 'won' of 'lost' zijn" klinkt prima totdat je later "paused" of "archived" nodig hebt. Een goede CHECK-constraint beschrijft een stabiele waarheid, geen tijdelijke UI-keuze. "amount \u003e= 0" veroudert goed. "country in (...)" vaak niet.
Een paar problemen die vaak opduiken wanneer teams constraints toevoegen nadat gegenereerde code al draait:
CASCADE als cleanup-tool gebruiken en daarna meer data verwijderen dan bedoeld.Over performance: PostgreSQL maakt automatisch een index voor UNIQUE, maar foreign keys indexeren de verwijzende kolom niet automatisch. Zonder die index kunnen updates en deletes op de ouder traag worden omdat Postgres de kindtabel moet scannen om referenties te controleren.
Voordat je een regel aanscherpt, zoek bestaande rijen die eraan zouden falen, beslis of je ze fixeert of in quarantaine zet, en rol de verandering stapsgewijs uit.
Voordat je live gaat, neem vijf minuten per tabel en noteer wat altijd waar moet zijn. Als je het in gewone taal kunt zeggen, kun je het meestal afdwingen met een constraint.
Stel jezelf deze vragen voor elke tabel:
Als je een chat-gestuurde bouwtool gebruikt, behandel die invarianten als acceptatiecriteria voor de data, niet als optionele notities. Bijvoorbeeld: "Een dealbedrag moet niet-negatief zijn", "Een contact-e-mail is uniek per workspace", "Een taak moet naar een echt contact verwijzen." Hoe explicieter de regels, hoe minder ruimte er is voor toevallige randgevallen.
Koder.ai (koder.ai) includes features like planning mode, snapshots and rollback, and source code export, which can make it easier to iterate on schema changes safely while you tighten constraints over time.
Een simpel rollout-patroon dat werkt in echte teams: kies één tabel met hoge waarde (users, orders, invoices, contacts), voeg 1–2 constraints toe die de ergste fouten voorkomen (vaak NOT NULL en UNIQUE), los de writes op die falen, en herhaal. Het geleidelijk aanscherpen van regels verslaat één grote risicovolle migratie.