En bref — si je démarre un SaaS aujourd'hui avec le choix de la stack, je pars sur PostgreSQL avec un schéma par tenant. Je garde la base partagée (tenant_id) quand PostgreSQL n'est pas une option ou que le nombre de tenants explose, et je réserve la base dédiée par tenant aux cas où la réglementation l'impose. Le reste de l'article explique pourquoi, avec ce que j'ai vu en production.
Quand on construit un SaaS, l'une des premières décisions structurantes est celle de l'isolation des données entre clients : l'architecture multi-tenant. Plusieurs clients (les « tenants ») partagent la même application, mais leurs données ne doivent jamais se mélanger. En apparence c'est simple ; en pratique, la bonne réponse dépend d'un faisceau de facteurs techniques, économiques et organisationnels — et c'est une décision qu'on ne défait pas facilement une fois en production.
J'ai travaillé sur des SaaS utilisant chacune des trois grandes approches — parfois par choix, parfois par héritage. Cet article n'est pas un tour d'horizon théorique : c'est la façon dont je raisonne concrètement quand je dois trancher, avec les expériences qui ont forgé cette réflexion.
Les trois approches
Base partagée avec tenant_id — tous les clients cohabitent dans les mêmes tables, distingués par une colonne tenant_id présente sur chaque enregistrement. C'est la plus simple à démarrer et la plus répandue dans les SaaS à fort volume de clients. Son talon d'Achille : l'isolation repose entièrement sur la couche applicative — un oubli de filtre, et les données d'un tenant fuient vers un autre.
Schéma dédié par tenant (PostgreSQL) — une seule instance de base, mais chaque client dispose de son propre schéma logique (tenant_a.commandes, tenant_b.commandes). L'isolation devient structurelle, garantie au niveau de la base et plus seulement du code, tout en mutualisant l'infrastructure et en gardant des migrations centralisées.
Base de données dédiée par tenant — chaque client a sa propre instance. L'isolation est totale et physique : même un bug applicatif grave ne peut pas exposer les données d'un autre tenant. C'est l'approche la plus robuste côté sécurité, mais aussi la plus lourde à opérer dès que le nombre de clients grandit.
En un coup d'œil
| Critère | Base partagée (tenant_id) | Schéma par tenant | Base dédiée |
|---|---|---|---|
| Isolation | Applicative | Structurelle (base) | Physique (totale) |
| Coût infra | Faible | Moyen | Élevé (linéaire) |
| Onboarding | Trivial | Simple (script) | Lourd, à automatiser |
| Migrations | Centralisées | Centralisées | N bases à orchestrer |
| Reporting cross-tenant | Facile | Moyen | Difficile |
| Connexions | Optimal | À surveiller | Risque d'épuisement |
| Terrain de prédilection | SaaS mass-market | Défaut polyvalent | Contraintes réglementaires |
Ce que j'ai vu en production
Le tenant_id : simple à démarrer, exigeant à maintenir
Ma première expérience du multi-tenant, c'est chez Evenmedia. Je suis arrivé sur un projet déjà en production, conçu dès le départ avec un tenant_id : des tables à plusieurs millions d'enregistrements, plusieurs clients actifs, et — c'est le point important — aucun problème de performance. À une condition : que la base ait été pensée sérieusement dès le départ. Bons index (à commencer par le tenant_id en tête des index composites), requêtes optimisées, modélisation rigoureuse. Cette expérience m'a convaincu que l'approche tient parfaitement la charge, pour peu qu'on ne néglige pas les fondamentaux SQL.
J'ai ensuite appliqué cette stratégie sur Coordesk, que j'ai développé de zéro. Le client voulait MySQL, ce qui excluait d'emblée la piste des schémas PostgreSQL. J'ai donc choisi le tenant_id, mais en investissant lourdement dans la sécurité applicative. L'idée directrice : l'isolation doit être le comportement par défaut du système, jamais une responsabilité laissée à chaque développeur au moment d'écrire une requête. Concrètement, un global scope appliqué automatiquement à tous les modèles :
// Laravel — appliqué automatiquement à TOUS les modèles tenant.
class TenantScope implements Scope
{
public function apply(Builder $query, Model $model): void
{
$query->where('tenant_id', Tenant::currentId());
}
}Couplé à un middleware qui résout le tenant courant (par sous-domaine) à l'entrée de chaque requête, et à une règle non négociable : ne jamais contourner l'ORM pour taper la base en direct. Le jour où un développeur écrit une requête brute « juste pour ce rapport », la garantie d'isolation saute. La discipline doit donc être outillée, pas espérée.
La base par tenant : rassurante, jusqu'aux migrations
Sur un SaaS de gestion de livraisons pour une société de transport, j'ai découvert l'approche « une base par tenant » — non par choix initial, mais parce que c'était l'existant sur lequel j'intervenais. Au quotidien, l'architecture était confortable : chaque client proprement isolé, aucun risque de fuite entre tenants, des performances prévisibles par base.
Le revers s'est révélé au moment des évolutions de schéma. Répercuter une seule migration sur N bases distinctes demande une orchestration rigoureuse : appliquer les scripts dans le bon ordre sur chaque instance, vérifier l'état après coup, gérer les échecs partiels (une base migrée, la suivante en erreur). Plus il y a de tenants, plus l'opération devient sensible et lente. Ce qui prend quelques minutes sur une base centralisée devient une procédure à part entière, avec sa fenêtre de maintenance et son plan de rollback. Les sauvegardes et le monitoring suivent la même logique : tout est multiplié par le nombre de clients. C'est là, et pas dans l'usage quotidien, qu'on paie le vrai coût opérationnel de l'approche.
Les schémas PostgreSQL : mon défaut aujourd'hui
Sur mes projets, quand j'ai la main sur la stack, j'utilise PostgreSQL avec un schéma par tenant. C'est le meilleur rapport isolation / simplicité opérationnelle que je connaisse : les migrations restent centralisées comme avec le tenant_id, mais l'isolation est structurelle comme avec la base dédiée. On bascule de schéma à l'entrée de la requête, en fonction du tenant résolu :
-- Une requête, un tenant : on fixe le search_path à l'entrée.
SET search_path TO tenant_a, public;
SELECT * FROM commandes; -- cible tenant_a.commandesL'avantage est double : même une requête maladroite reste cantonnée au schéma courant, et l'on peut faire évoluer le modèle sur tous les tenants d'un coup tant que les schémas partagent la même structure.
Comment je tranche
Quand je dois choisir, je déroule une série de questions dans un ordre assez constant. Rarement une seule suffit à décider — c'est leur combinaison qui oriente.
Quelle est la fréquence d'acquisition de nouveaux clients ? C'est souvent ma première question. Si l'on signe un client par an, créer une base dédiée à la main est une demi-journée acceptable. Si l'objectif est d'onboarder un client par semaine, voire par jour, cette création doit être entièrement automatisée — sinon elle devient un goulot d'étranglement. La base partagée (tenant_id ou schémas) s'y prête naturellement ; la base dédiée exige une vraie ingénierie d'onboarding.
Quel est le coût d'infrastructure associé ? Une base par tenant, c'est une infrastructure par tenant : ressources, connexions, monitoring, sauvegardes. Ce coût croît linéairement avec le nombre de clients. Pour un SaaS mass-market à des centaines ou des milliers de comptes, il devient vite prohibitif. Pour un logiciel vendu à quelques grands comptes à forte valeur, il est absorbable — et souvent justifié par leurs exigences contractuelles.
Quelle est la sensibilité des données ? Des données médicales, financières ou juridiques peuvent imposer des contraintes réglementaires (RGPD, normes sectorielles, clauses contractuelles) qui rendent l'isolation physique non négociable. Sur ces projets, je vérifie la conformité avant de choisir : mieux vaut poser la question tôt que de devoir migrer sous pression six mois après la mise en production.
Quelle est la maturité opérationnelle de l'équipe ? Gérer N bases ou N schémas en production demande de la rigueur : migrations orchestrées, monitoring par tenant, sauvegardes granulaires, restaurations ciblées. Une petite équipe, ou une équipe peu rodée à ces sujets, sera plus sereine sur une base partagée — à condition d'avoir investi dans la couche de sécurité applicative qui va avec.
Quel ORM ou framework est utilisé ? Certains gèrent nativement les schémas multiples ou le multi-connexion, d'autres beaucoup moins. Ce n'est jamais bloquant, mais c'est un coût d'effort réel à anticiper honnêtement : aller à contre-courant de son outillage, c'est s'assurer des frictions à chaque évolution.
Un cas concret : Coordesk
Sur Coordesk, ces questions ont tranché vite. Acquisition de clients régulière mais pas massive, contrainte MySQL imposée par le client (donc pas de schémas PostgreSQL), données métier sensibles mais sans obligation réglementaire d'isolation physique, et une équipe réduite (moi) qui devait pouvoir opérer l'ensemble sans y passer ses nuits. Le tenant_id s'est imposé — non par défaut, mais parce que chaque critère pointait dans cette direction. Le vrai travail a alors été de bâtir le filet de sécurité applicatif pour compenser l'absence d'isolation structurelle.
La sécurité : le talon d'Achille du tenant_id
Un filtre oublié, une jointure mal écrite, un accès direct hors ORM : autant de vecteurs par lesquels les données d'un tenant peuvent être exposées à un autre. Ce n'est pas un risque théorique — c'est une surface d'attaque réelle, qui grandit avec la taille de la codebase et le nombre de développeurs. La protection ne peut pas reposer sur la vigilance individuelle ; elle doit être systématique : scope global sur tous les modèles, middleware qui résout le tenant dès l'entrée de requête, et discipline stricte autour de l'ORM.
PostgreSQL pousse le raisonnement au niveau de la base avec les Row Level Security (RLS) : des politiques de sécurité définies directement sur les tables. Même si une requête applicative oublie son filtre, la base refuse d'exposer les lignes d'un autre tenant.
-- Filet de sécurité au niveau base : défense en profondeur.
ALTER TABLE commandes ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON commandes
USING (tenant_id = current_setting('app.tenant_id')::uuid);C'est une défense en profondeur idéale en complément des schémas, ou comme filet de sécurité sur une architecture tenant_id quand le SGBD le permet.
Les limites que je ne cache pas
Aucune approche n'est gratuite. Le schéma par tenant n'échappe pas à la règle : au-delà de quelques milliers de schémas, pg_dump et les migrations multi-schéma deviennent lents et délicats à orchestrer, et chaque connexion doit résoudre le bon search_path — ce qui se marie mal avec un pooler en mode transaction comme PgBouncer. La base dédiée fait exploser le nombre de connexions et le coût de sauvegarde/monitoring. La base partagée, elle, rend le reporting cross-tenant trivial mais expose au « noisy neighbor » : un gros tenant qui sature les ressources et dégrade les performances des autres. On ne choisit pas une solution sans compromis — on choisit quels compromis on est prêt à assumer.
Mon point d'équilibre
PostgreSQL + schéma par tenant par défaut. Le tenant_id quand PostgreSQL n'est pas disponible ou que le volume de tenants rend la gestion des schémas trop lourde — avec, dans ce cas, un investissement systématique dans la sécurité applicative. La base dédiée uniquement quand la réglementation ou le profil client (peu nombreux, à forte valeur) le justifie.
Un dernier réflexe que je garde toujours en tête : si un tenant grossit au point de poser des problèmes de performance ou d'isolation, la bonne réponse n'est pas seulement d'isoler sa base — c'est d'isoler toute son infrastructure (applicatif, réseau, base de données). La base n'est qu'une pièce du puzzle ; la traiter isolément donne une fausse impression de sécurité.