Elke succesvolle SaaS-applicatie draait om data. Hoe je die data opslaat, structureert en laat meegroeien met je product, bepaalt of je platform soepel schaalt of vastloopt op het slechtst mogelijke moment. In deze gids duiken we diep in databaseontwerp en migratiestrategieën die specifiek relevant zijn voor SaaS-platformen.
Waarom databasekeuzes er toe doen in SaaS
Bij een traditionele webapp maak je één keer een databasekeuze en leef je ermee. Bij SaaS ligt dat anders: je data groeit exponentieel met elke nieuwe klant, je schema moet evolueren zonder downtime, en je moet nadenken over data-isolatie tussen tenants.
Een verkeerde keuze in het begin kan je maanden kosten om te herstellen. Laten we de belangrijkste beslissingen doorlopen.
SQL vs. NoSQL: de genuanceerde werkelijkheid
Wanneer SQL (PostgreSQL, MySQL)
SQL-databases zijn de standaardkeuze voor de meeste SaaS-applicaties, en daar is een goede reden voor:
- ACID-compliance: transacties zijn betrouwbaar, cruciaal voor facturatie en gebruikersbeheer
- Complexe queries: joins, aggregaties en rapportages zijn eersteklas features
- Schema-validatie: de database dwingt datastructuur af
- Ecosysteem: ORM's zoals Prisma, Drizzle en TypeORM werken uitstekend met SQL
-- Voorbeeld: multi-tenant tabelstructuur met Row-Level Security
CREATE TABLE organizations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
plan TEXT NOT NULL DEFAULT 'free',
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL REFERENCES organizations(id),
name TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Row-Level Security voor tenant-isolatie
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON projects
USING (org_id = current_setting('app.current_org')::UUID);
Wanneer NoSQL (MongoDB, DynamoDB)
NoSQL is geen vervanging voor SQL, maar een aanvulling voor specifieke use-cases:
- Ongestructureerde data: logs, events, gebruikersactiviteit
- Hoge schrijfvolumes: IoT-data, analytics events
- Flexibele schema's: wanneer elke tenant andere velden nodig heeft
- Key-value lookups: caching, sessies, feature flags
De pragmatische aanpak: gebruik PostgreSQL als primaire database en voeg Redis of DynamoDB toe voor specifieke workloads.
Schema-ontwerp voor multi-tenancy
Er zijn drie hoofdpatronen voor multi-tenant databases. Elk heeft trade-offs:
1. Gedeelde database, gedeeld schema (meest voorkomend)
Alle tenants delen dezelfde tabellen, onderscheiden door een org_id kolom.
// Prisma schema voorbeeld
model Project {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id])
name String
data Json?
createdAt DateTime @default(now())
@@index([orgId])
}
Voordelen: eenvoudig, kostenefficiënt, makkelijk te onderhouden
Nadelen: risico op data-lekkage zonder goede filtering, "noisy neighbor"-probleem
2. Gedeelde database, gescheiden schema's
Elke tenant krijgt een eigen PostgreSQL-schema binnen dezelfde database.
-- Tenant-specifiek schema aanmaken
CREATE SCHEMA tenant_acme;
CREATE TABLE tenant_acme.projects (LIKE public.projects INCLUDING ALL);
Voordelen: betere isolatie, makkelijker om per-tenant backups te maken
Nadelen: complexer migratiebeheer, hogere operationele last
3. Database per tenant
Elke tenant krijgt een eigen database-instantie.
Voordelen: maximale isolatie, onafhankelijke scaling
Nadelen: hoge kosten, complex beheer, niet realistisch voor 1000+ tenants
Onze aanbeveling: start met optie 1 (gedeeld schema met org_id) en implementeer Row-Level Security in PostgreSQL. Dit geeft je de eenvoud van een gedeeld schema met de veiligheid van isolatie.
Database-migraties zonder downtime
Migraties zijn het spannendste onderdeel van databasebeheer in productie. Eén verkeerde migratie kan je hele platform platleggen. Hier zijn bewezen strategieën:
De expand-contract pattern
In plaats van destructieve wijzigingen in één stap, splits je ze op:
Stap 1 — Expand: voeg de nieuwe structuur toe naast de oude
-- Voeg nieuwe kolom toe (non-blocking)
ALTER TABLE users ADD COLUMN email_verified BOOLEAN DEFAULT false;
Stap 2 — Migreer data: vul de nieuwe structuur met bestaande data
-- Backfill in batches om de database niet te overbelasten
UPDATE users SET email_verified = true
WHERE verified_at IS NOT NULL
AND id IN (SELECT id FROM users WHERE email_verified IS NULL LIMIT 1000);
Stap 3 — Contract: verwijder de oude structuur na validatie
-- Pas na volledige validatie
ALTER TABLE users DROP COLUMN verified_at;
Tooling voor veilige migraties
// prisma/migrations gebruiken met een CI/CD check
// In je deployment pipeline:
// 1. Draai migratie tegen een schaduw-database
// 2. Controleer of de migratie reversibel is
// 3. Meet de duur op een kopie van productiedata
// 4. Pas dan deployen naar productie
// Voorbeeld GitHub Actions stap:
// - name: Validate migration
// run: |
// npx prisma migrate diff \
// --from-schema-datasource prisma/schema.prisma \
// --to-migrations prisma/migrations \
// --shadow-database-url $SHADOW_DB_URL
Grote tabellen migreren
Bij tabellen met miljoenen rijen moet je slim zijn:
-- SLECHT: dit lockt de hele tabel
ALTER TABLE events ALTER COLUMN payload TYPE JSONB;
-- GOED: maak een nieuwe kolom en migreer geleidelijk
ALTER TABLE events ADD COLUMN payload_v2 JSONB;
-- Trigger voor nieuwe data
CREATE OR REPLACE FUNCTION sync_payload() RETURNS TRIGGER AS $$
BEGIN
NEW.payload_v2 := NEW.payload::JSONB;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER sync_payload_trigger
BEFORE INSERT OR UPDATE ON events
FOR EACH ROW EXECUTE FUNCTION sync_payload();
-- Backfill oude data in batches van 10.000
DO $$
DECLARE
batch_size INT := 10000;
affected INT;
BEGIN
LOOP
UPDATE events
SET payload_v2 = payload::JSONB
WHERE payload_v2 IS NULL
AND id IN (
SELECT id FROM events WHERE payload_v2 IS NULL LIMIT batch_size
);
GET DIAGNOSTICS affected = ROW_COUNT;
EXIT WHEN affected = 0;
PERFORM pg_sleep(0.1); -- Even ademen
END LOOP;
END $$;
Indexering: de 80/20 regel
De meeste performance-problemen in SaaS-databases komen neer op ontbrekende of verkeerde indexen.
Essentiële indexen voor SaaS
-- 1. Altijd indexeren op tenant-ID
CREATE INDEX idx_projects_org ON projects(org_id);
-- 2. Composite indexen voor veelgebruikte queries
CREATE INDEX idx_projects_org_created ON projects(org_id, created_at DESC);
-- 3. Partial indexen voor actieve records
CREATE INDEX idx_users_active ON users(email)
WHERE deleted_at IS NULL;
-- 4. GIN-index voor JSONB-zoeken
CREATE INDEX idx_settings_data ON settings USING GIN(data);
Indexen monitoren
-- Vind ongebruikte indexen
SELECT schemaname, relname, indexrelname, idx_scan
FROM pg_stat_user_indexes
WHERE idx_scan = 0 AND indexrelname NOT LIKE '%pkey%'
ORDER BY pg_relation_size(indexrelid) DESC;
-- Vind ontbrekende indexen (langzame queries)
SELECT query, calls, mean_exec_time, rows
FROM pg_stat_statements
ORDER BY mean_exec_time DESC
LIMIT 20;
Connection pooling: een must voor SaaS
Elke tenant genereert database-connecties. Zonder pooling loop je snel tegen limieten aan.
// PgBouncer configuratie (pgbouncer.ini)
// [databases]
// myapp = host=localhost dbname=myapp
//
// [pgbouncer]
// pool_mode = transaction
// max_client_conn = 1000
// default_pool_size = 20
// min_pool_size = 5
// In je Next.js app met Prisma:
// Gebruik een connection string via PgBouncer
// DATABASE_URL="postgresql://user:pass@localhost:6432/myapp?pgbouncer=true"
Voor serverless omgevingen (Vercel, AWS Lambda) is een managed pooler zoals Neon's connection pooler of Supabase's Supavisor essentieel. Zonder pooling maakt elke serverless invocatie een nieuwe connectie — en dat schaalt niet.
Backup- en disaster recovery-strategie
Een goed backup-plan is niet optioneel. Het is onderdeel van je product.
De 3-2-1 regel
- 3 kopieën van je data
- 2 verschillende opslagmedia
- 1 off-site backup
# Geautomatiseerde dagelijkse backup met pg_dump
#!/bin/bash
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_FILE="backup_${TIMESTAMP}.sql.gz"
pg_dump $DATABASE_URL | gzip > /backups/$BACKUP_FILE
# Upload naar S3
aws s3 cp /backups/$BACKUP_FILE s3://my-saas-backups/daily/
# Verwijder lokale backups ouder dan 7 dagen
find /backups -name "*.sql.gz" -mtime +7 -delete
Point-in-time recovery
Managed databases zoals Neon, Supabase en AWS RDS bieden point-in-time recovery (PITR). Dit betekent dat je je database kunt terugzetten naar elk moment in de afgelopen X dagen. Zorg dat dit is ingeschakeld.
Checklist: database-ready voor productie
Voordat je live gaat, loop deze checklist door:
- Tenant-isolatie: elke query filtert op
org_id(of RLS is actief) - Indexen: alle foreign keys en veelgebruikte WHERE-clausules zijn geïndexeerd
- Connection pooling: PgBouncer of managed pooler is geconfigureerd
- Migratiestrategie: expand-contract pattern is gedocumenteerd
- Backups: dagelijkse backups + PITR ingeschakeld
- Monitoring: slow query log en
pg_stat_statementsactief - Data-encryptie: at-rest en in-transit encryptie ingeschakeld
- Soft deletes: belangrijke data wordt nooit hard verwijderd
Conclusie
Databaseontwerp voor SaaS is geen eenmalige keuze — het is een doorlopend proces dat meegroeit met je product. Start eenvoudig met een gedeeld schema en PostgreSQL, implementeer Row-Level Security voor tenant-isolatie, en gebruik de expand-contract pattern voor veilige migraties.
Het belangrijkste advies: meet alles. Gebruik pg_stat_statements, monitor je query-tijden, en optimaliseer pas wanneer de data je vertelt waar het knelpunt zit. Premature optimization is de wortel van alle kwaad — maar negeren van database-performance is de wortel van alle churn.
Wil je hulp bij het opzetten van een schaalbare database-architectuur voor je SaaS? Neem contact met ons op voor een vrijblijvend adviesgesprek.