Alle artikelen

Van Knex naar Drizzle ORM: wat niemand je over de migratie vertelt

Een eerlijke retrospectieve op de migratie van Knex query builder naar Drizzle ORM in een productie multi-tenant SaaS - inclusief de scherpe randen van TypeScript, ESM en de runtime keuzes die ik onderweg moest maken.

5 min leestijd
drizzletypescriptmigrationesm

Recent heb ik Salonnare's server backend volledig omgezet van Knex query builder (CommonJS, JavaScript) naar Drizzle ORM (ESM, TypeScript). 88 tabellen, ongeveer dertig route bestanden, 104 migraties. De sales-pitch van Drizzle klopt grotendeels, maar er zit een laag aan kleine valkuilen onder die je pas merkt als je de migratie écht doet. Dit is wat ik graag had geweten voor ik begon.

Waarom überhaupt migreren?

Knex doet zijn werk goed, en is twaalf jaar later nog steeds een prima query builder. Twee dingen werden alleen elke maand pijnlijker:

  1. Geen end-to-end types. Knex weet niet wat select('id', 'name') retourneert. Elke route had handmatig getypte interfaces, en bij een schema wijziging stapelden de bugs zich op tot je de server in productie raakte.
  2. CommonJS hield ons tegen. Veel moderne libraries (zoals next-intl, node-fetch v3, recente OpenAI SDK) zijn ESM-only. We hadden steeds meer hoepels nodig om die in ons CJS proces te krijgen.

Drizzle lost beide op: bookings.$inferSelect geeft je het exacte rij-type, en de hele toolchain is ESM-native.

Valkuil 1: Drizzle introspect crasht op grote schema's

Drizzle adverteert drizzle-kit introspect om een bestaande database te scannen en TS schema bestanden te genereren. Op een schema van 88 tabellen met enums, foreign keys en composite indices liep introspect bij ons spectaculair vast - verschillende foutmeldingen op verschillende runs, soms een silent crash.

Wat wel werkte: met de hand schrijven, tabel voor tabel, gegroepeerd in shared/src/schema/<naam>.ts met een barrel export via shared/src/schema/index.ts. Saai, maar elke tabel kreeg meteen de juiste typing en relaties. Achteraf gezien is dit de juiste investering, want een gegenereerd bestand had ik toch overal weer moeten reviewen.

Valkuil 2: .where() is in Drizzle verplicht (en dat is een feature)

In Knex doet db('bookings').delete() een full table delete als je .where() vergeet. In Drizzle weigert TypeScript je code te compileren als je dezelfde call doet zonder where clause. Klein verschil, maar in een multi-tenant SaaS is dit het verschil tussen een rustige dinsdag en een incident report.

// Compileert: TS dwingt je een where clause aan te leveren
await req.db.delete(bookings).where(eq(bookings.id, bookingId));

Valkuil 3: ESM imports vereisen .js extensies in TS source

De grootste irritatie van de eerste week. Met "module": "NodeNext" in tsconfig.json moeten interne imports in TypeScript bron expliciet de .js extensie hebben - ook al heet het bestand foo.ts.

// Werkt niet
import { auth } from '../middleware/auth';

// Werkt wel
import { auth } from '../middleware/auth.js';

Voor een grote codebase betekent dit een paar uur regex-vervangen. Maak één commit van de import-rewrites apart van de Drizzle migratie - anders is je diff onleesbaar tijdens code review.

Valkuil 4: Een tenant-scoped wrapper bouwen om Drizzle heen

Drizzle is laag-niveau: je krijgt een query builder, geen ORM met automatische scopes. Voor multi-tenant SaaS is dat een probleem, want één vergeten tenant_id filter is een datalek. Mijn oplossing was een wrapper die rond de Drizzle client zit en op elke select(), insert(), update() en delete() de tenant filter automatisch injecteert:

const rows = await req.db.select().from(bookings).where(eq(bookings.status, 'scheduled'));
// De wrapper voegt onzichtbaar AND bookings.tenant_id = ? toe

19 unit tests dekken het gedrag van die wrapper af. Dat is de hoeveelheid paranoia die je nodig hebt voordat je deze laag durft te vertrouwen, want hij is letterlijk de muur tussen klanten.

Valkuil 5: Productie runtime - tsc of tsx?

De officiële aanbeveling is tsc om je TypeScript naar JavaScript te compileren en die output te draaien. In de praktijk gaf dit eindeloos kleine ESM-resolutieproblemen rond Drizzle's eigen modules en @salonnare/shared workspace package.

Wat wel werkte: tsx src/index.ts direct draaien in productie. Geen separate build step, type-check blijft onze CI gate (tsc --noEmit). Iets meer geheugen, sneller deploy, dezelfde Docker image structuur. Voor de schaal van Salonnare een prima trade-off.

Valkuil 6: Bestaande tests moesten je herschrijven

Onze test suite gebruikte Knex mocks. Die mocks zijn fundamenteel incompatibel met Drizzle's chained builder API. In plaats van alle 200+ tests in één keer te porten heb ik het volgende gedaan:

  • Nieuwe tests voor de tenant wrapper (19 stuks) - eerste prioriteit, deze beschermt iedereen
  • Bestaande route tests @ts-nocheck + describe.skip(...) - actief, maar niet meedraaiend
  • TODO in commit message: rewrite per route bij de eerstvolgende wijziging op die route

Dit is technisch debt, maar het isoleerde de Drizzle migratie tot een afgebakend project. Tests rewriten parallel met query rewriting maakt de blast radius onhandelbaar.

Wat ik anders zou doen

Als ik morgen weer mocht beginnen:

  • Doe de import-rewrites in een aparte PR. Geen Drizzle code, alleen .js extensies erbij. Reviewers kunnen dan rustig naar de Drizzle PR kijken.
  • Bouw je tenant wrapper voordat je je eerste route migreert. Anders mis je gegarandeerd één route die op de oude pre-tenant patronen leunt.
  • Schrijf eerst de schema files. Niet introspect proberen. Tabel-voor-tabel handmatig, en kies meteen je entity types via $inferSelect / $inferInsert.
  • Migrate de routes per domein, niet per bestand. Bookings → clients → invoices, in plaats van alfabetisch. Domein-coherentie helpt je tests slim opnieuw schrijven.

Drizzle voelt een paar weken later als de juiste keuze. End-to-end types zijn echt waardevol, ESM heeft de deur naar moderne libraries opengezet, en de wrapper-laag heeft het tenant isolatie verhaal eindelijk in code in plaats van in code review opmerkingen. De migratie kostte ongeveer een week effectieve tijd - verspreid over twee weken kalender, omdat ik niet met productie wilde gokken.

Wel beter alle valkuilen vooraf weten dan ze één voor één tegenkomen.

Nick van Iersel

Auteur van deze site

Nick van Iersel

Full-stack Developer & IT Consultant

Nick is een Nederlandse full-stack developer en IT consultant uit Waalwijk met ruim zes jaar ervaring in productie-software. Hij richt zich op Next.js, TypeScript, React en Node.js voor websites, SaaS platformen en mobile apps, en werkt zowel op uurbasis als op projectbasis met vaste prijs.

Ready to make your idea a reality?

Let's build something amazing together