Skip to main content
backend14 septembre 202510 min de lecture

Comment structurer un backend SaaS multi-tenant

Un guide pratique sur l'architecture SaaS multi-tenant — des stratégies d'isolation de base de données à l'authentification, la facturation et les feature flags.

saasarchitecturenodejs
Comment structurer un backend SaaS multi-tenant

Dès l'instant où vous décidez que votre application servira plus d'une organisation, chaque décision architecturale bifurque. Conception de base de données, authentification, autorisation, facturation, même le logging — tout porte désormais la question : pour quel tenant ?

J'ai livré des backends multi-tenants dans des domaines très différents. Les patterns qui survivent à la production sont rarement ceux qui ont l'air les plus propres sur un tableau blanc. Cet article couvre les décisions qui comptent vraiment, les compromis dont personne ne vous prévient, et des patterns d'implémentation concrets que vous pouvez reprendre.

Stratégies d'isolation de base de données

C'est la première décision et la plus lourde de conséquences. Si vous vous trompez, vous regardez une migration qui touche chaque requête de votre codebase.

Il y a trois approches principales :

Stratégie Isolation Complexité Coût Idéal pour
BD partagée, schéma partagé Faible Faible Le plus bas SaaS en démarrage, < 100 tenants
BD partagée, schéma par tenant Moyenne Moyenne Moyen Échelle moyenne, industries réglementées
Base de données par tenant Élevée Élevée Le plus élevé Clients entreprise, conformité stricte

Base de données partagée, schéma partagé

Les données de chaque tenant vivent dans les mêmes tables. Vous distinguez les lignes par une colonne tenant_id. C'est là que la plupart des produits SaaS devraient commencer.

L'avantage est la simplicité. Un seul chemin de migration, un seul pool de connexions, des requêtes directes. L'inconvénient est qu'une clause WHERE tenant_id = ? manquante signifie une fuite de données. Il n'y a pas de filet de sécurité au niveau de l'infrastructure — l'isolation repose entièrement sur votre code applicatif.

Cette approche fonctionne jusqu'à ce que les requêtes d'un gros tenant commencent à dégrader les performances pour tous les autres, ou jusqu'à ce que l'équipe conformité d'un client entreprise demande comment ses données sont physiquement séparées de celles de la concurrence. À ce moment-là, vous passez à l'étape supérieure.

Schéma par tenant

Chaque tenant obtient son propre schéma PostgreSQL (ou équivalent) au sein d'une instance de base de données partagée. Les tables sont identiques entre les schémas, mais physiquement séparées. Vous changez le search path au moment de la connexion.

Cela vous donne une vraie isolation sans gérer des dizaines d'instances de base de données. Les migrations sont plus complexes — vous les exécutez N fois — mais des outils comme node-pg-migrate ou le support multi-schéma de Prisma gèrent cela raisonnablement bien.

Le piège : le connection pooling devient délicat. Si vous utilisez PgBouncer, vous devez être attentif à la façon dont le changement de schéma interagit avec les connexions poolées. J'ai vu cela causer des bugs subtils où le Tenant A lit momentanément le schéma du Tenant B parce qu'une connexion a été retournée au pool en pleine transaction.

Base de données par tenant

Isolation maximale. Chaque tenant a une instance de base de données dédiée (ou au moins une base de données logique dédiée). Vous routez les connexions dynamiquement en fonction du contexte du tenant.

C'est coûteux et opérationnellement lourd, mais certains clients paieront pour cela. La santé et les services financiers l'exigent souvent. Le bénéfice clé au-delà de l'isolation est que vous pouvez scaler, sauvegarder et restaurer les tenants indépendamment. Un voisin bruyant ne peut pas faire tomber votre instance partagée.

En pratique, la plupart des équipes font un hybride : BD partagée pour la majorité, instances dédiées pour les comptes entreprise qui paient pour cela.

Authentification et résolution du tenant

Chaque requête entrante doit répondre à deux questions : qui est cet utilisateur ? et à quel tenant appartient-il ?

Il y a trois stratégies courantes pour résoudre le contexte du tenant :

Basée sur le sous-domaine : acme.yourapp.com mappe vers le tenant Acme. Propre, intuitif, fonctionne très bien pour les produits B2B où chaque client obtient son propre espace de travail. Vous extrayez le slug du tenant depuis l'en-tête Host.

Basée sur le claim JWT : L'identifiant du tenant est embarqué dans le token d'accès au moment de la connexion. Pas besoin de routage au niveau infrastructure. Fonctionne bien pour les applications mobiles et les SPA où le routage par sous-domaine est impraticable.

Basée sur l'en-tête : Le client envoie un en-tête X-Tenant-ID. Simple mais dangereux si non validé par rapport aux permissions de l'utilisateur authentifié. N'utilisez cela que pour les appels service-à-service internes.

Dans la plupart des systèmes, je combine la résolution par sous-domaine pour le flux de connexion initial avec les claims JWT pour les appels API suivants. Voici à quoi ressemble le middleware :

import { Request, Response, NextFunction } from "express";
import { verifyToken } from "../lib/auth";
import { getTenantBySlug } from "../services/tenant";

export async function tenantMiddleware(
  req: Request,
  res: Response,
  next: NextFunction
) {
  // 1. Try JWT claim first (authenticated requests)
  const token = req.headers.authorization?.replace("Bearer ", "");
  if (token) {
    const payload = verifyToken(token);
    if (payload?.tenantId) {
      req.tenantId = payload.tenantId;
      return next();
    }
  }

  // 2. Fall back to subdomain resolution
  const host = req.hostname;
  const slug = host.split(".")[0];

  if (slug === "www" || slug === "app") {
    return res.status(400).json({ error: "Tenant not identified" });
  }

  const tenant = await getTenantBySlug(slug);
  if (!tenant || !tenant.isActive) {
    return res.status(404).json({ error: "Tenant not found" });
  }

  req.tenantId = tenant.id;
  next();
}

Montez ceci avant vos handlers de route et chaque fonction en aval a accès à req.tenantId. Le reste de votre application n'a jamais à se soucier de comment la résolution du tenant fonctionne — il lit simplement l'identifiant depuis le contexte de la requête.

Scoper chaque requête de base de données

Une fois que vous avez le contexte du tenant sur chaque requête, vous devez garantir qu'il est appliqué à chaque requête. Manquer un scope est une fuite de données. Ce n'est pas le genre de chose que vous laissez à la discipline des développeurs.

Le meilleur pattern que j'ai trouvé est une couche repository qui applique le scope automatiquement :

import { db } from "../lib/database";

export function scopedQuery(tenantId: string) {
  return {
    async findMany<T>(table: string, where: Record<string, unknown> = {}): Promise<T[]> {
      return db(table)
        .where({ ...where, tenant_id: tenantId })
        .select("*");
    },

    async findOne<T>(table: string, where: Record<string, unknown>): Promise<T | null> {
      return db(table)
        .where({ ...where, tenant_id: tenantId })
        .first();
    },

    async insert<T>(table: string, data: Record<string, unknown>): Promise<T> {
      return db(table)
        .insert({ ...data, tenant_id: tenantId })
        .returning("*")
        .then((rows) => rows[0]);
    },

    async update(
      table: string,
      where: Record<string, unknown>,
      data: Record<string, unknown>
    ): Promise<number> {
      return db(table)
        .where({ ...where, tenant_id: tenantId })
        .update(data);
    },

    async remove(table: string, where: Record<string, unknown>): Promise<number> {
      return db(table)
        .where({ ...where, tenant_id: tenantId })
        .del();
    },
  };
}

Dans vos handlers de route, vous ne construisez jamais de requêtes brutes. Vous passez toujours par l'interface scopée :

app.get("/api/projects", async (req, res) => {
  const query = scopedQuery(req.tenantId);
  const projects = await query.findMany("projects", { archived: false });
  res.json(projects);
});

Si vous utilisez un ORM comme Prisma, vous pouvez obtenir la même chose avec un middleware qui injecte le filtre tenant_id dans chaque appel findMany, findFirst, update et delete. Le but est de rendre le chemin non sécurisé plus difficile que le chemin sécurisé.

Contrôle d'accès basé sur les rôles au sein des tenants

Le multi-tenant introduit un modèle d'autorisation à deux couches. Premièrement : cet utilisateur appartient-il à ce tenant ? Deuxièmement : que peut-il faire à l'intérieur ?

Je garde cela simple avec une table tenant_memberships :

CREATE TABLE tenant_memberships (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID NOT NULL REFERENCES users(id),
  tenant_id UUID NOT NULL REFERENCES tenants(id),
  role VARCHAR(50) NOT NULL DEFAULT 'member',
  created_at TIMESTAMPTZ DEFAULT NOW(),
  UNIQUE(user_id, tenant_id)
);

Les rôles sont typiquement owner, admin, member, et parfois viewer ou un rôle spécifique au domaine. Évitez de construire une matrice de permissions complète à moins que votre produit n'en ait réellement besoin — la plupart des SaaS B2B s'en sortent bien avec 3-4 rôles.

La vérification d'autorisation se fait après la résolution du tenant :

export function requireRole(...roles: string[]) {
  return async (req: Request, res: Response, next: NextFunction) => {
    const membership = await db("tenant_memberships")
      .where({ user_id: req.userId, tenant_id: req.tenantId })
      .first();

    if (!membership || !roles.includes(membership.role)) {
      return res.status(403).json({ error: "Insufficient permissions" });
    }

    req.userRole = membership.role;
    next();
  };
}

// Usage
app.delete("/api/projects/:id", requireRole("owner", "admin"), async (req, res) => {
  // only owners and admins reach this handler
});

Un choix de conception qui évite les maux de tête plus tard : autorisez toujours un utilisateur à appartenir à plusieurs tenants. Même si votre produit est mono-workspace aujourd'hui, au moment où vous ajouterez des comptes agence, des revendeurs en marque blanche ou un mode consultant, vous en aurez besoin.

Feature flags par tenant

Tous les tenants ne devraient pas voir toutes les fonctionnalités. Parfois vous filtrez par tier de plan. Parfois vous faites un déploiement progressif. Parfois un client entreprise paie pour une capacité sur mesure.

J'utilise une simple table tenant_features plutôt que d'intégrer un service de feature flags tiers. Pour la plupart des produits SaaS, c'est plus que suffisant :

// Cache-backed feature flag check
const featureCache = new Map<string, Set<string>>();

export async function hasFeature(tenantId: string, feature: string): Promise<boolean> {
  if (!featureCache.has(tenantId)) {
    const rows = await db("tenant_features")
      .where({ tenant_id: tenantId, enabled: true })
      .select("feature_key");

    featureCache.set(tenantId, new Set(rows.map((r) => r.feature_key)));
  }

  return featureCache.get(tenantId)!.has(feature);
}

// Invalidate on plan change or manual toggle
export function invalidateFeatureCache(tenantId: string) {
  featureCache.delete(tenantId);
}

Dans les handlers de route :

app.post("/api/analytics/export", async (req, res) => {
  if (!(await hasFeature(req.tenantId, "analytics_export"))) {
    return res.status(403).json({
      error: "This feature is not available on your current plan",
      upgrade: true,
    });
  }
  // proceed with export
});

Quand un tenant met à niveau son plan, vous activez les lignes de fonctionnalités pertinentes et invalidez le cache. Simple, auditable et sans dépendances externes.

Intégration de facturation

Stripe est le standard ici pour de bonnes raisons. Le mapping est naturel : un Stripe Customer par tenant, un Subscription par tenant, les tiers de plan mappent vers des Products et des Prices.

La règle de conception critique : stockez le Stripe Customer ID sur l'enregistrement du tenant, pas sur celui de l'utilisateur. La facturation appartient à l'organisation, pas à la personne qui a entré la carte bancaire.

Le flux de webhooks ressemble à ceci :

  1. checkout.session.completed — attacher le Stripe Customer ID au tenant, activer son abonnement.
  2. invoice.paid — prolonger la période de facturation, mettre à jour tenant.paidUntil.
  3. invoice.payment_failed — marquer le tenant comme en retard de paiement, envoyer des emails de relance.
  4. customer.subscription.updated — synchroniser les changements de tier de plan, mettre à jour les feature flags en conséquence.
  5. customer.subscription.deleted — rétrograder vers le tier gratuit ou suspendre.

La clé est l'idempotence. Stripe peut et va envoyer le même webhook plusieurs fois. Chaque handler doit être sûr à exécuter deux fois avec le même payload d'événement.

Isolation des données et sécurité

Au-delà du scoping des requêtes, il y a quelques points non évidents que vous devriez bien gérer dès le début :

Row-Level Security (RLS) : Si vous êtes sur PostgreSQL, activez RLS comme seconde ligne de défense. Même si votre code applicatif a un bug, la base de données elle-même refusera de retourner des lignes qui ne correspondent pas au contexte du tenant courant. Définissez app.current_tenant au moment de la connexion et écrivez des politiques contre.

Contexte de logging : Chaque ligne de log devrait inclure tenantId. Quand quelque chose casse à 3h du matin, vous devez savoir quel tenant est affecté sans fouiller les traces de requêtes. Le logging structuré avec un champ tenant intégré dans le contexte du logger rend cela automatique.

Sauvegardes et suppression de données : Les tenants vont partir, et certains invoqueront le RGPD ou des droits de suppression de données. Si toutes vos données sont dans des tables partagées, purger un seul tenant signifie des instructions DELETE soigneusement construites dans chaque table. Avec un schéma ou une base de données par tenant, c'est un DROP SCHEMA ou DROP DATABASE. Planifiez cela dès le premier jour.

Rate limiting par tenant : Un rate limiter global ne suffit pas. L'import en masse d'un tenant ne devrait pas empiéter sur le budget de taux d'un autre tenant. Scopez votre rate limiter par tenantId, pas seulement par IP ou utilisateur.

Chiffrement au repos : Pour les industries sensibles, chiffrez les données du tenant au repos avec des clés par tenant. Cela ajoute de la complexité à la gestion des clés, mais cela signifie que révoquer l'accès d'un tenant est aussi simple que de détruire sa clé. Aucune donnée ne reste lisible sur le disque après le départ du client.

Réflexions finales

Le multi-tenant n'est pas une bibliothèque que vous installez. C'est un ensemble de décisions architecturales qui touchent chaque couche de votre stack, de la base de données au CDN. La bonne nouvelle est que les patterns sont bien compris. La partie délicate est de les appliquer avec le bon niveau d'isolation pour votre marché et votre stade spécifiques.

Commencez avec tout partagé et une couche de scoping disciplinée. Ajoutez des frontières d'isolation à mesure que vos clients les demandent. Ne sur-ingénieriez pas pour des exigences de conformité que vous n'avez pas encore, mais assurez-vous que votre architecture peut évoluer vers une isolation plus stricte sans réécriture.

J'ai construit des systèmes multi-tenants depuis des plateformes de restauration jusqu'au SaaS médical — chacun avec ses propres exigences d'isolation et de montée en charge. Les patterns de cet article sont ceux qui ont résisté dans tous ces contextes.

DU

Danil Ulmashev

Full Stack Developer

Intéressé par une collaboration ?