Une application SaaS sert plusieurs clients avec le même code. La question qui décide de tout le reste tient en une phrase : où vivent les données de chacun, et comment garantir qu'un client ne verra jamais celles d'un autre ? C'est la définition même du multi-tenant — un tenant étant un client isolé, avec ses utilisateurs, ses données et parfois sa configuration — et c'est une décision d'architecture qu'on prend très tôt, souvent avant la première ligne de code métier.
Le problème, c'est qu'on la paie longtemps. Changer de modèle d'isolation une fois la plateforme en production est un chantier lourd, à dérouler progressivement pour ne pas tout casser — exactement le genre de décision structurante qui rigidifie un produit quand elle est prise à l'aveugle. Cet article passe en revue les quatre modèles, du plus mutualisé au plus cloisonné, donne la grille qui permet de trancher, montre leur mise en œuvre concrète avec Symfony, avant de se terminer sur trois cas réels et mon parti pris d'ingénieur.
Le seul axe qui compte vraiment : l'isolation
Toutes les approches multi-tenant se rangent sur un même axe : le degré d'isolation entre les clients. À une extrémité, tout est partagé — une base, des tables, une instance — et seul le code sépare les clients. À l'autre, chaque client a sa propre base, voire sa propre instance applicative, et la séparation est physique. Entre les deux, il y a des paliers.
Plus on isole, plus on est tranquille sur la sécurité, le RGPD et la performance — mais plus l'infrastructure et l'exploitation coûtent cher. Plus on mutualise, moins ça coûte et mieux ça monte en charge sur un très grand nombre de clients — mais plus le cloisonnement repose sur la rigueur du code, et plus une simple erreur devient une fuite de données entre clients. Tout le reste découle de ce curseur.
Modèle 1 — Base partagée, colonne tenant_id (pooled)
Tous les clients vivent dans la même base, dans les mêmes tables. Chaque ligne
porte une colonne tenant_id qui dit à quel client elle appartient, et
chaque requête doit filtrer dessus. C'est le modèle pooled, celui des
grands SaaS B2C qui servent des dizaines de milliers de comptes.
Ses atouts sont réels et il faut les nommer : c'est le moins cher et le plus scalable. Une base à administrer, une sauvegarde, une migration de schéma à passer une seule fois pour tout le monde, un coût marginal quasi nul par nouveau client. À l'échelle de millions de tenants peu sensibles — une app grand public, un outil freemium —, c'est souvent le seul modèle viable économiquement.
Son défaut est tout aussi réel : le cloisonnement repose entièrement sur
le code. Il suffit d'une requête qui oublie le WHERE tenant_id = ?
pour qu'un client lise les données d'un autre. Sur une jointure complexe, un export,
un rapport d'administration, un bout de SQL écrit à la main, l'erreur est facile et
silencieuse — elle ne plante pas, elle fuit. À cela s'ajoutent le noisy
neighbor (un gros client qui sature les ressources pénalise tous les autres) et
un RGPD plus délicat : supprimer ou exporter les données d'un seul client, c'est un
DELETE ciblé au milieu des autres, pas un DROP propre.
Modèle 2 — Un schéma par client
Même instance de base de données, mais chaque client a son propre schéma —
son jeu de tables séparé (les schemas PostgreSQL, ou une base logique
par client sous MySQL). Les données ne se mélangent plus dans les mêmes tables : on
bascule de schéma selon le client, et une requête mal filtrée ne peut plus toucher
qu'un seul tenant.
C'est le compromis intermédiaire. On gagne un vrai cloisonnement logique et une suppression par client nette (on supprime le schéma), tout en gardant une seule instance de base à opérer. Le prix : les migrations de schéma se multiplient par le nombre de clients — il faut les rejouer sur chaque schéma — et, au bout de quelques centaines de tenants, le nombre d'objets en base commence à peser sur l'outillage et les sauvegardes. C'est un modèle confortable jusqu'à une échelle moyenne, moins au-delà.
Modèle 3 — Une base par client (silo)
Chaque client a sa propre base de données, avec ses propres identifiants de connexion. L'application reste partagée — une seule instance sert tout le monde — mais à chaque requête, elle résout le client puis bascule la connexion vers la base qui lui appartient. C'est le modèle silo, et la séparation devient physique : il n'existe aucune requête capable de traverser la frontière entre deux clients, parce qu'elles ne sont pas dans la même base.
C'est le modèle de l'isolation par construction. La sécurité ne dépend plus de la
discipline sur chaque requête. Le RGPD devient trivial : exporter un client, c'est un
mysqldump ; le supprimer, c'est un DROP DATABASE ; restaurer
un seul client n'affecte personne d'autre. On peut même placer la base d'un client sur
une infrastructure dédiée pour répondre à une exigence de
résidence des données.
Le coût se déplace vers l'exploitation : autant de bases à sauvegarder, à surveiller, et surtout des migrations de schéma à propager sur toutes les bases à chaque déploiement. C'est gérable — on y revient avec un cas à plusieurs centaines de bases — mais ça impose un outillage d'industrialisation sérieux. Tant qu'on automatise le déploiement, le silo passe à l'échelle bien plus loin qu'on ne le croit.
Modèle 4 — Une instance par client
Le cran ultime : non seulement la base, mais aussi l'application est
dédiée. Chaque client tourne dans son propre conteneur, derrière son propre
docker-compose ou ses propres manifests — son code, sa base, sa config,
ses ressources, ses fenêtres de déploiement. Ce n'est même plus tout à fait du
multi-tenant : c'est du multi-instance.
On obtient l'isolation maximale, jusqu'au runtime : aucun voisinage, aucune ressource partagée, la possibilité de faire diverger la version d'un client ou de lui livrer une personnalisation lourde sans risque pour les autres. C'est le modèle des clients grands comptes très exigeants, ou des contextes réglementés. Le prix est à la hauteur : l'exploitation est multipliée par le nombre de clients, et sans une forte automatisation de l'infrastructure, ça devient vite ingérable. On le réserve à un nombre limité de clients à forte valeur, pas à un SaaS à mille comptes.
La grille qui tranche
Le bon modèle n'est pas le plus isolé dans l'absolu : c'est celui qui équilibre la sensibilité des données, le nombre de clients visé et la capacité d'exploitation. Voici comment les quatre se comparent sur les critères qui décident vraiment.
| Critère | Base partagée | Schéma / client | Base / client | Instance / client |
|---|---|---|---|---|
| Cloisonnement | Par le code seul | Logique | Physique (données) | Physique (total) |
| Risque de fuite | Élevé (filtre oublié) | Faible | Quasi nul | Nul |
| RGPD (export, suppression) | Délicat (DELETE ciblé) | Net (par schéma) | Trivial (dump / drop) | Trivial |
| Coût d'infrastructure | Minimal | Faible | Modéré | Élevé |
| Migrations de schéma | Une seule fois | × nb schémas | × nb bases | × nb instances |
| Noisy neighbor | Fort | Modéré | Faible | Nul |
| Personnalisation par client | Difficile | Limitée | Possible | Totale |
| Passage à l'échelle (nb clients) | Très élevé | Moyen | Élevé si automatisé | Limité |
La lecture est claire : on descend vers la mutualisation quand on a beaucoup de clients peu sensibles et qu'on veut un coût marginal nul ; on remonte vers l'isolation quand les données sont sensibles, le nombre de clients raisonnable et les exigences de sécurité ou de conformité fortes.
Le concret : résoudre le tenant, puis basculer la connexion
Quel que soit le modèle, deux mécanismes reviennent toujours côté code : identifier le client de la requête en cours, puis aiguiller l'accès aux données vers le bon endroit. Voici à quoi ça ressemble avec Symfony.
La résolution se fait le plus souvent par le host : un sous-domaine
(client.mon-saas.com), un domaine personnalisé mappé vers le client, ou
un en-tête interne pour les appels de service à service. Un listener placé avant le
firewall détermine le tenant une fois pour toutes, au tout début de la requête :
#[AsEventListener(event: KernelEvents::REQUEST, priority: 300)]
final readonly class TenantRequestListener
{
public function __construct(
private TenantRegistry $registry,
private TenantContext $context,
) {
}
public function __invoke(RequestEvent $event): void
{
if (false === $event->isMainRequest()) {
return;
}
// « client.mon-saas.com » → le code client « client »
$tenant = $this->registry->resolveByHost($event->getRequest()->getHost());
$this->context->set($tenant);
}
}
Vient ensuite l'aiguillage, et c'est là que le modèle change tout. En
pooled, on ne change pas de base : on s'assure que toute
requête est filtrée par tenant_id. Le faire à la main partout est
précisément l'erreur à éviter ; Doctrine fournit un filtre automatique qui injecte la
condition sur chaque entité concernée :
final class TenantFilter extends SQLFilter
{
public function addFilterConstraint(ClassMetadata $target, string $alias): string
{
if (false === $target->hasField('tenantId')) {
return '';
}
// injecté automatiquement dans CHAQUE requête Doctrine sur l'entité
return sprintf('%s.tenant_id = %s', $alias, $this->getParameter('tenant_id'));
}
}
Ce filtre est le garde-fou central du modèle pooled — mais il a ses angles morts :
il ne couvre ni le SQL natif (DQL contourné, requêtes Connection écrites
à la main), ni les jointures vers des tables non gérées par l'ORM. La règle « tout
passe par le filtre » doit être tenue avec une discipline sans faille, et vérifiée en
revue de code. C'est tout le risque résumé en une phrase : en pooled, la
sécurité est une propriété du code, pas de l'infrastructure.
En silo, l'aiguillage est ailleurs : on ne filtre pas, on change de connexion. Un middleware DBAL intercepte l'ouverture de connexion et substitue les paramètres de la base du tenant courant — avec un garde-fou qui refuse de se connecter si aucun tenant n'est résolu, pour ne jamais tomber par défaut sur une mauvaise base :
final class TenantDriver extends AbstractDriverMiddleware
{
public function connect(array $params): Connection
{
$tenant = $this->context->current()
?? throw new LogicException('Aucun tenant résolu : connexion refusée.');
// les paramètres de connexion deviennent ceux de la base du client
return parent::connect([...$params, ...$tenant->databaseParams()]);
}
}
La différence de philosophie est nette. En pooled, on ajoute une condition à chaque lecture et on prie pour n'en oublier aucune. En silo, on choisit la bonne base une fois, et il n'existe physiquement pas de chemin vers les données d'un autre client.
L'isolation ne s'arrête pas à la base de données
Tout ce qui précède parle de la base, mais les données d'un client ne vivent pas que là. Elles s'étalent sur le stockage de fichiers, le cache, le moteur de recherche, les files de messages, parfois les journaux. Le modèle d'isolation choisi pour la base doit être appliqué à chacune de ces couches — sinon le maillon le plus faible définit le niveau réel d'isolation. Une base silo impeccable derrière un bucket partagé aux chemins devinables, ou un cache dont les clés ne portent pas le client, n'est plus un silo : la fuite a simplement changé d'étage.
Les fichiers et le stockage objet rejouent exactement le même arbitrage. En silo, on attribue un bucket (ou au minimum un préfixe racine avec des accès limités) dédié à chaque client ; en pooled, on partage un bucket et on préfixe les chemins par l'identifiant du tenant. Et le risque est le miroir du filtre oublié : un chemin construit sans le préfixe client, ou une URL de téléchargement incrémentale et devinable, et un client récupère le fichier d'un autre. Côté Symfony, on fabrique l'adaptateur de stockage dynamiquement selon le tenant courant, comme on bascule la connexion :
final readonly class TenantStorageFactory
{
public function create(): FilesystemOperator
{
$tenant = $this->context->current()
?? throw new LogicException('Aucun tenant résolu : stockage refusé.');
// un bucket (ou un préfixe racine) dédié au client courant
return new Filesystem(new AsyncAwsS3Adapter($this->s3, $tenant->bucket()));
}
}
Le cache et la recherche obéissent à la même logique. Sur un cache partagé type Redis, l'isolation minimale est un préfixe de clé par client — sans lui, deux tenants qui calculent la même clé lisent les données l'un de l'autre ; pour aller plus loin, on dédie une base logique, voire une instance. Sur un moteur de recherche, on retrouve le couple silo/pooled à l'identique : un index par client, ou un index partagé filtré par tenant — avec, là aussi, l'angle mort d'une requête de recherche qui oublie le filtre et remonte les documents de tout le monde.
Les traitements asynchrones ajoutent un piège qui leur est propre : le worker qui consomme un message n'a plus la requête HTTP pour deviner le client. Le contexte de tenant doit donc voyager avec le message — chaque message porte le code client, et un middleware repositionne le contexte avant le traitement. Oublier ce transport, c'est exécuter un job sur la base ou le bucket d'un autre client, en asynchrone et donc loin des yeux.
La leçon de conception est simple : choisir un niveau d'isolation, c'est s'engager à l'appliquer partout où un état est stocké. Le bon réflexe, dès le cadrage, est de lister toutes les ressources à état — base, fichiers, cache, index, files, logs — et de décider pour chacune. C'est souvent là que le pooled coûte plus cher qu'annoncé : il faut réussir le cloisonnement autant de fois qu'il y a de couches, sans jamais en manquer une.
Trois cas terrain
La théorie se range proprement sur un axe ; la réalité, elle, place le curseur selon le contexte. Trois plateformes que j'ai conçues ou opérées illustrent le côté isolation de cet axe, à trois granularités différentes.
Une plateforme SaaS de PIM/DAM — instance par client. Des clients
exigeants, des données catalogue volumineuses et parfois confidentielles, des besoins
de personnalisation forts. Le choix a été l'isolation maximale : un
docker-compose par client, chacun avec sa propre application, sa base et
ses ressources. Aucun voisinage, des déploiements et des versions pilotables client
par client. Le coût d'exploitation est réel, mais assumé : il correspond à un nombre
maîtrisé de clients à forte valeur.
Une grande plateforme SaaS B2B que j'ai opérée — base par client, à grande échelle. Plusieurs centaines d'organisations clientes, chacune avec sa propre base, réparties sur plusieurs grappes de serveurs. L'application est partagée et résout le client par sous-domaine, puis bascule la connexion vers sa base dédiée. Les migrations de schéma sont propagées à toutes les bases par un fan-out automatisé ; les lectures et écritures sont séparées sur des hôtes distincts pour tenir la charge. C'est la preuve par l'usage que le silo passe à l'échelle de plusieurs centaines de tenants, à condition d'industrialiser le déploiement et les migrations.
Une application de métrologie industrielle — base par client, version moderne. Sur un projet en cours, le même modèle silo, mais outillé avec le Symfony d'aujourd'hui : résolution du tenant par le host, bascule de connexion par un middleware DBAL, et une base MySQL plus un bucket de stockage objet dédiés à chaque client — donc une isolation physique qui s'étend aux fichiers, pas seulement aux tables. Une commande de provisionnement crée la base, le bucket et la configuration du client en une fois ; une autre rejoue les migrations sur l'ensemble des tenants.
Le point commun saute aux yeux : aucune de ces trois plateformes n'est en pooled. Ce n'est pas un hasard.
Mon parti pris : l'isolation par construction
Pour des applications métier qui manipulent des données sensibles — et c'est le cas de
la plupart des outils que je conçois —, je privilégie le silo. La raison tient en une
idée : je préfère une sécurité garantie par l'architecture à une sécurité
garantie par la discipline. Un filtre tenant_id bien posé est
efficace tant que personne, jamais, n'écrit une requête qui le contourne. Une base par
client est efficace même le jour où quelqu'un se trompe. Sur des données de santé, de
défense ou simplement des données clients confidentielles, cette différence n'est pas un
détail — c'est tout le sujet.
Le silo simplifie aussi radicalement le RGPD, qui devient une affaire de
dump et de drop plutôt que de requêtes ciblées à travers les
données des autres — un point que je développe dans la
checklist technique RGPD.
Et il sert un principe auquel je tiens : le client reste propriétaire de ses
données et autonome. Quand chaque client a sa base, lui livrer un export
complet, ou détacher sa plateforme de la mienne, n'a rien d'un casse-tête — c'est
l'inverse du lock-in.
Cela ne disqualifie pas le pooled, qui reste le bon choix pour un SaaS à très grand nombre de comptes peu sensibles, où le coût marginal par client doit tendre vers zéro. Le mauvais réflexe serait d'en faire un dogme dans un sens ou dans l'autre. La bonne démarche est celle de la grille : poser la sensibilité des données, le nombre de clients et la capacité d'exploitation, puis choisir le palier d'isolation qui correspond — en sachant qu'en cas d'erreur, changer de modèle plus tard se fait progressivement, mais coûte bien plus cher que de le poser juste au départ.
Conclusion
Le multi-tenant n'est pas un problème de framework : c'est un arbitrage entre
isolation et mutualisation, décidé tôt et payé sur des années. Quatre modèles, un seul
axe — du tenant_id partagé à l'instance dédiée —, et une grille de
critères pour placer le curseur : sensibilité des données, nombre de clients, coût
d'exploitation. Et un palier d'isolation choisi ne vaut que s'il est tenu sur toutes
les couches à état — base, fichiers, cache, recherche, files — pas seulement la base.
Mon penchant d'ingénieur me porte vers l'isolation par construction dès que les données comptent, parce qu'elle transforme une promesse de sécurité en propriété de l'architecture. Mais le meilleur modèle reste celui qu'on choisit les yeux ouverts, en connaissant le prix de chaque option — pas celui qu'on subit faute d'avoir posé la question au bon moment.