Patterns multi-tenancy avec Drizzle ORM pour bâtir un SaaS B2B en 2026 (informations vérifiées en avril 2026, susceptibles d’évoluer).
Voir notre guide Drizzle.
3 approches multi-tenant
- Database par tenant : isolation maximum, complexité ops
- Schema par tenant : isolation logique, mêmes ressources DB
- Row-level (tenant_id column) : simple, scaling facile
Approche recommandée : tenant_id + RLS
// schema.ts
export const tenants = pgTable("tenants", {
id: serial("id").primaryKey(),
name: varchar("name", { length: 100 }),
});
export const customers = pgTable("customers", {
id: serial("id").primaryKey(),
tenant_id: integer("tenant_id").references(() => tenants.id).notNull(),
email: varchar("email", { length: 255 }).notNull(),
name: varchar("name", { length: 100 }),
}, (table) => ({
tenantIdx: index("customers_tenant_idx").on(table.tenant_id),
uniqueEmail: uniqueIndex("customers_tenant_email").on(table.tenant_id, table.email),
}));RLS Postgres pour isolation
-- Migration custom (drizzle ne génère pas RLS)
ALTER TABLE customers ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON customers
USING (tenant_id = current_setting('app.tenant_id')::integer);
-- Dans l'app, à chaque requête :
SET app.tenant_id = '42';
SELECT * FROM customers; -- ne voit que les rows du tenant 42Wrapper Drizzle avec tenant context
import { sql } from "drizzle-orm";
async function withTenant<T>(tenantId: number, fn: () => Promise<T>): Promise<T> {
return await db.transaction(async (tx) => {
await tx.execute(sql`SET LOCAL app.tenant_id = ${tenantId}`);
return await fn();
});
}
// Usage
await withTenant(42, async () => {
return await db.select().from(customers);
});Audit log multi-tenant
Pour conformité, ajouter une table audit_logs avec tenant_id et trigger qui enregistre toute modification.
Pour explorer plus loin
Etape 1 : Choisir une strategie de multi-tenancy adaptee au contexte
Avant d’ecrire la moindre ligne, il faut trancher entre trois patterns : schema partage avec colonne tenant_id, schema dedie par tenant (un schema Postgres par client), ou base dedie par tenant. Chaque pattern a un cout d’exploitation different. Pour une SaaS B2B qui vise 50 a 500 PME au Senegal, en Cote d’Ivoire et au Mali, la colonne tenant_id sur schema partage est de loin le meilleur ratio cout-isolation. Au-dela de 1000 tenants ou en cas de contraintes reglementaires fortes (sante, banque), on bascule sur schema dedie.
Drizzle ORM supporte les trois patterns mais c’est avec tenant_id + Row Level Security (RLS) que la magie opere : isolation au niveau base, code applicatif epure, et zero risque de fuite croisee meme si un developpeur oublie un WHERE.
Etape 2 : Initialiser le projet et installer les dependances
Sur Node 22 LTS, demarrez avec un projet TypeScript propre. Drizzle 0.36 et postgres 3.4 forment le couple le plus performant en 2026, devant pg qui reste plus verbeux pour le streaming.
npm init -y
npm install drizzle-orm postgres
npm install -D drizzle-kit typescript tsx @types/node
Verifiez avec npm ls drizzle-orm que la version installee est bien 0.36.x. Les versions anterieures a 0.30 n’ont pas le support natif des policies RLS via la directive .using() — vous devrez ecrire le SQL a la main dans ce cas.
Etape 3 : Definir le schema avec colonne tenant_id sur chaque table
La regle absolue : chaque table metier porte une colonne tenant_id non nullable, indexee, et faisant reference a la table tenants. Aucune exception. Meme une table de logs internes doit avoir tenant_id sinon vous perdrez la possibilite d’isoler les donnees plus tard.
import { pgTable, uuid, text, timestamp, index } from 'drizzle-orm/pg-core';
export const tenants = pgTable('tenants', {
id: uuid('id').primaryKey().defaultRandom(),
slug: text('slug').notNull().unique(),
createdAt: timestamp('created_at').defaultNow(),
});
export const invoices = pgTable('invoices', {
id: uuid('id').primaryKey().defaultRandom(),
tenantId: uuid('tenant_id').notNull().references(() => tenants.id),
amount: text('amount').notNull(),
}, (t) => ({ tenantIdx: index('invoices_tenant_idx').on(t.tenantId) }));
L’index sur tenant_id est crucial : 99% des requetes filtreront par ce champ, un Seq Scan ferait ecrouler la base au-dela de 100k lignes. Notez aussi le ON DELETE CASCADE implicite — quand un tenant resilie son contrat, ses donnees disparaissent en une seule operation.
Etape 4 : Activer Row Level Security au niveau Postgres
RLS est la pierre angulaire de la securite multi-tenant. Postgres applique automatiquement un filtre WHERE invisible base sur une variable de session — meme un SELECT * sans clause WHERE ne ramene que les lignes du tenant courant. Genere une migration SQL custom dans drizzle/custom/.
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
CREATE POLICY invoices_tenant_isolation ON invoices
USING (tenant_id = current_setting('app.current_tenant')::uuid);
La policy lit current_setting(‘app.current_tenant’) — une variable de session que votre code applicatif positionnera au debut de chaque requete HTTP. Si la variable n’est pas positionnee, la policy retourne false et aucune ligne n’est visible — le fail-safe par defaut, exactement ce qu’on veut.
Etape 5 : Positionner la variable de session dans le middleware
Sur Express ou Fastify, ecrivez un middleware qui, apres avoir authentifie l’utilisateur via JWT, extrait le tenant_id et le pousse sur la connexion Postgres en cours pour la duree de la requete.
app.use(async (req, res, next) => {
const tenantId = req.user.tenantId;
await db.execute(sql`SELECT set_config('app.current_tenant', ${tenantId}, true)`);
next();
});
Le troisieme parametre true de set_config rend la valeur locale a la transaction — elle est automatiquement effacee a la fin. Indispensable avec un pool de connexions : sinon une connexion recyclee garderait le tenant_id du precedent appel et provoquerait une fuite massive.
Etape 6 : Tester l’isolation avec un harness de fuite
Ecrivez un test d’integration qui cree deux tenants A et B, insere des donnees dans chacun, puis tente toutes les requetes possibles depuis le contexte de A pour voir si une seule ligne de B fuit. Ce test doit faire partie du CI obligatoire pour merger toute PR qui touche le schema.
await setTenant('tenantA');
const rows = await db.select().from(invoices);
expect(rows.every(r => r.tenantId === tenantA.id)).toBe(true);
Sur un projet Lome capitaliste 2025, ce harness avait detecte qu’une migration mal pensee avait casse une policy — sans le test, la fuite serait passee en prod et aurait expose les factures de 12 PME entre elles. Cout estime du leak en cas de declaration RGPD : 50 millions FCFA. Cout du test : 30 minutes a ecrire.
Etape 7 : Gerer les requetes admin cross-tenant
Le superadmin de la SaaS doit parfois interroger l’ensemble des donnees pour le support ou le billing. Pour ce cas, creez un role Postgres dedie qui BYPASS RLS, et utilisez-le uniquement depuis un binaire CLI separe — jamais depuis l’API publique.
CREATE ROLE saas_admin BYPASSRLS;
GRANT ALL ON ALL TABLES IN SCHEMA public TO saas_admin;
Le binaire CLI lit ses credentials depuis une variable d’environnement chiffree via SOPS ou Vault, jamais en clair dans le repo. Toute requete admin est loguee avec horodatage et identite humaine — piste d’audit obligatoire pour les certifications ISO 27001 demandees par les grands clients comme Sonatel ou Orange Cote d’Ivoire.
Etape 8 : Indexer correctement pour la performance multi-tenant
Les indexes composes (tenant_id, autre_colonne) sont presque toujours plus efficaces que des indexes separes. Postgres choisira l’index compose pour toute requete qui filtre sur tenant_id ET trie par autre_colonne, alors qu’avec deux indexes separes il fera un BitmapAnd plus couteux.
CREATE INDEX invoices_tenant_created_idx
ON invoices (tenant_id, created_at DESC);
Cet index supporte la requete typique « donne-moi les 20 dernieres factures du tenant X » en quelques millisecondes meme avec 10 millions de lignes. Validez avec EXPLAIN ANALYZE que le plan utilise bien Index Scan et non Seq Scan + Sort.
Etape 9 : Migrer un client existant vers son propre schema
Quand un gros client (par exemple une banque qui represente 30% du chiffre d’affaires) demande une isolation renforcee, vous pouvez le migrer du schema partage vers un schema dedie sans interrompre le service. Le pattern : creer le schema, repliquer les tables, migrer les donnees par batch, puis basculer le tenant_id vers le nouveau schema dans le routeur applicatif.
CREATE SCHEMA tenant_acme;
CREATE TABLE tenant_acme.invoices (LIKE public.invoices INCLUDING ALL);
INSERT INTO tenant_acme.invoices SELECT * FROM public.invoices
WHERE tenant_id = 'acme-uuid';
Apres validation, supprimez les lignes correspondantes du schema partage. La bascule cote applicatif se fait via une table de routage qui mappe tenant_id vers schema name — Drizzle accepte un parametre schema dynamique sur chaque pgTable. Pour explorer plus loin sur les aspects deploiement, voyez notre guide migrations zero-downtime.
Etape 10 : Surveiller la sante par tenant en production
Exposer des metriques par tenant est essentiel pour detecter un client qui fait exploser la base. Comptez le nombre de requetes par seconde, la latence p95, et le volume de donnees par tenant. Ces metriques alimentent un dashboard Grafana qui declenche une alerte si un tenant depasse 100 RPS — souvent signe d’un bug ou d’un client qui devrait basculer sur un plan superieur.
SELECT current_setting('app.current_tenant') as tenant,
COUNT(*) as queries
FROM pg_stat_activity GROUP BY 1;
Croisez avec les donnees de facturation pour facturer la surconsommation — un client qui consomme 5x la moyenne paye 5x le forfait, c’est juste et c’est ce qui finance l’infrastructure. Pour la couche alerting voir notre tutoriel Grafana et Discord.
Etape 11 : Gerer le seeding de donnees par tenant
Quand un nouveau client signe, il faut provisionner ses donnees initiales : roles par defaut, parametres de configuration, modeles de documents. Au lieu de coder ce seeding en TypeScript imperatif, definissez un fichier SQL templated par tenant et appliquez-le dans une transaction unique apres l’INSERT dans la table tenants.
BEGIN;
INSERT INTO tenants (id, slug) VALUES ($1, $2);
SELECT set_config('app.current_tenant', $1, true);
INSERT INTO roles (tenant_id, name) VALUES ($1, 'admin'), ($1, 'user');
COMMIT;
La transaction garantit l’atomicite : si une seule etape echoue, tout est rollback et le tenant n’existe pas a moitie. Sur un onboarding qui peut creer 200 lignes initiales, cette transaction prend moins de 100 ms — invisible pour le nouvel utilisateur qui voit juste son dashboard se charger.
Etape 12 : Sauvegarder et restaurer un tenant unique
Avec le schema partage et tenant_id, un export par tenant se fait via COPY filtre. Cette capacite est cruciale pour la portabilite : un client qui resilie a le droit RGPD de recuperer ses donnees, et un export par schema entier serait inacceptable car il exposerait les autres tenants.
COPY (SELECT * FROM invoices WHERE tenant_id = 'acme-uuid')
TO '/backup/acme_invoices.csv' WITH CSV HEADER;
Scripte cette procedure dans un job cron mensuel qui produit un .zip par tenant, chiffre avec age ou gpg, et envoie le lien de telechargement par email au contact admin du tenant. Cout d’infra negligeable, conformite reglementaire assuree, et argument commercial fort pour les appels d’offres B2B au Senegal et en Cote d’Ivoire ou la souverainete des donnees est devenue un sujet majeur en 2025-2026.
Etape 13 : Empecher l’enumeration de tenants via l’URL
Si vos URLs publiques contiennent /api/tenants/{slug}/…, un attaquant peut enumerer les slugs et deduire votre liste de clients. Utilisez des UUID au lieu de slugs lisibles, ou mieux, ne mettez pas le tenant dans l’URL du tout — derivez-le du JWT cote serveur. L’URL devient /api/invoices et le tenant_id est implicite.
const tenantId = jwt.verify(req.headers.authorization).tenantId;
// jamais de tenant dans le path
Ce design ferme une surface d’attaque entiere : meme si un bug expose temporairement une API sans auth, l’attaquant ne peut pas deviner les tenants existants. Combine avec rate-limiting agressif sur /api/auth/login, vous rendez l’enumeration economiquement non viable.
Etape 14 : Auditer les acces avec une table d’evenements
Au-dela de RLS, gardez une trace de qui a fait quoi. Une table audit_log dediee, ecrite par trigger Postgres apres chaque INSERT/UPDATE/DELETE sur les tables sensibles, permet de reconstituer l’historique en cas de litige. Le trigger lit current_setting(‘app.current_tenant’) et current_setting(‘app.current_user’) pour enrichir chaque ligne.
CREATE TRIGGER invoices_audit AFTER INSERT OR UPDATE OR DELETE
ON invoices FOR EACH ROW EXECUTE FUNCTION audit_trigger();
Stockez la table audit_log dans un schema separe avec RLS desactivee (le superadmin la consulte) et une retention reglementaire — 5 ans pour les fintechs sous BCEAO, 10 ans pour les operateurs telecom regules par l’ARTP. La taille reste raisonnable (1 a 5 Go par an pour 200 tenants actifs) et le cout de stockage sur S3 ou Backblaze B2 est negligeable face a la valeur d’audit.
Etape 15 : Faire evoluer le schema sans verrouiller tous les tenants
Quand vous ajoutez une colonne sur une table multi-tenant qui contient 50 millions de lignes, l’ALTER TABLE bloque tous les tenants pendant la duree du verrou. Pour eviter cela, suivez le pattern expand-contract en deux releases : ajout NULLABLE puis backfill par batch, exactement comme decrit dans le tutoriel migrations zero-downtime cite plus haut. La regle d’or : aucune migration en prod un vendredi soir, jamais.
Etape 16 : Documenter le contrat d’isolation pour les nouveaux developpeurs
Un fichier ISOLATION.md a la racine du repo decrit en une page : la regle « tenant_id obligatoire », le mecanisme RLS, le middleware de session, et la procedure de revue de PR qui verifie que toute nouvelle table porte tenant_id. Sans ce document, un nouveau dev finit toujours par creer une table sans tenant_id et provoque une fuite six mois plus tard. Ajoutez aussi une regle eslint-plugin custom qui flagge tout pgTable sans colonne tenantId — la barriere automatique vaut mille relectures humaines.