Skip to main content
backend14 settembre 202510 min di lettura

Come strutturare un backend SaaS multi-tenant

Una guida pratica all'architettura SaaS multi-tenant — dalle strategie di isolamento del database all'autenticazione, fatturazione e feature flag.

saasarchitecturenodejs
Come strutturare un backend SaaS multi-tenant

Nel momento in cui decidi che la tua app servirà più di un'organizzazione, ogni decisione architetturale si biforca. Design del database, autenticazione, autorizzazione, fatturazione, persino il logging — tutto porta con sé la domanda: per quale tenant?

Ho rilasciato backend multi-tenant in domini molto diversi. I pattern che sopravvivono alla produzione sono raramente quelli che appaiono più puliti sulla lavagna. Questo articolo copre le decisioni che contano davvero, i trade-off di cui nessuno ti avverte e pattern di implementazione concreti che puoi riutilizzare.

Strategie di isolamento del database

Questa è la prima e più consequente decisione. Sbagliala e ti ritroverai con una migrazione che tocca ogni query nella tua codebase.

Ci sono tre approcci principali:

Strategia Isolamento Complessità Costo Ideale per
DB condiviso, schema condiviso Basso Bassa Più basso SaaS in fase iniziale, < 100 tenant
DB condiviso, schema-per-tenant Medio Media Medio Media scala, settori regolamentati
Database-per-tenant Alto Alta Più alto Clienti enterprise, conformità rigorosa

Database condiviso, schema condiviso

I dati di ogni tenant risiedono nelle stesse tabelle. Distingui le righe tramite una colonna tenant_id. È da qui che la maggior parte dei prodotti SaaS dovrebbe partire.

Il vantaggio è la semplicità. Un unico percorso di migrazione, un unico connection pool, query semplici. Lo svantaggio è che una clausola WHERE tenant_id = ? mancante significa una fuga di dati. Non c'è rete di sicurezza a livello infrastrutturale — l'isolamento è interamente nel codice applicativo.

Questo approccio funziona finché le query di un tenant di grandi dimensioni non iniziano a degradare le performance per tutti gli altri, o finché il team di conformità di un cliente enterprise non chiede come i loro dati siano fisicamente separati da quelli dei concorrenti. A quel punto, fai il salto.

Schema-per-tenant

Ogni tenant ottiene il proprio schema PostgreSQL (o equivalente) all'interno di un'istanza di database condivisa. Le tabelle sono identiche tra gli schemi, ma fisicamente separate. Si cambia il search path al momento della connessione.

Questo ti dà un isolamento reale senza gestire dozzine di istanze database. Le migrazioni sono più complesse — le esegui N volte — ma strumenti come node-pg-migrate o il supporto multi-schema di Prisma gestiscono la cosa ragionevolmente bene.

L'insidia: il connection pooling diventa complicato. Se usi PgBouncer, devi essere attento a come lo switching di schema interagisce con le connessioni in pool. Ho visto questo causare bug sottili dove il Tenant A momentaneamente legge lo schema del Tenant B perché una connessione è stata restituita al pool a metà transazione.

Database-per-tenant

Isolamento massimo. Ogni tenant ha un'istanza database dedicata (o almeno un database logico dedicato). Instradi le connessioni dinamicamente in base al contesto del tenant.

Questo è costoso e operativamente pesante, ma alcuni clienti pagheranno per averlo. Sanità e servizi finanziari spesso lo richiedono. Il beneficio chiave oltre l'isolamento è che puoi scalare, fare backup e ripristinare i tenant indipendentemente. Un vicino rumoroso non può far crollare la tua istanza condivisa.

In pratica, la maggior parte dei team adotta un approccio ibrido: DB condiviso per la maggioranza, istanze dedicate per gli account enterprise che pagano per averle.

Autenticazione e risoluzione del tenant

Ogni richiesta in ingresso deve rispondere a due domande: chi è questo utente? e a quale tenant appartiene?

Ci sono tre strategie comuni per risolvere il contesto del tenant:

Basata su sottodominio: acme.tuaapp.com mappa al tenant Acme. Pulito, intuitivo, funziona benissimo per prodotti B2B dove ogni cliente ottiene il proprio workspace. Estrai lo slug del tenant dall'header Host.

Basata su claim JWT: L'ID del tenant è incorporato nel token di accesso al momento del login. Nessun routing a livello infrastrutturale necessario. Funziona bene per app mobile e SPA dove il routing per sottodominio è impraticabile.

Basata su header: Il client invia un header X-Tenant-ID. Semplice ma pericoloso se non validato rispetto ai permessi dell'utente autenticato. Usalo solo per chiamate interne service-to-service.

Nella maggior parte dei sistemi, combino la risoluzione per sottodominio per il flusso di login iniziale con i claim JWT per le chiamate API successive. Ecco come appare il 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();
}

Monta questo prima dei tuoi route handler e ogni funzione a valle ha accesso a req.tenantId. Il resto della tua applicazione non deve mai pensare a come funziona la risoluzione del tenant — legge semplicemente l'ID dal contesto della richiesta.

Scoping di ogni query al database

Una volta che hai il contesto del tenant su ogni richiesta, devi garantire che sia applicato a ogni query. Uno scope mancante è una violazione dei dati. Non è il tipo di cosa che lasci alla disciplina degli sviluppatori.

Il miglior pattern che ho trovato è un repository layer che applica lo scoping automaticamente:

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();
    },
  };
}

Nei tuoi route handler, non costruisci mai query raw. Passi sempre attraverso l'interfaccia con scope:

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

Se usi un ORM come Prisma, puoi ottenere lo stesso risultato con un middleware che inietta il filtro tenant_id in ogni chiamata findMany, findFirst, update e delete. Il punto è rendere il percorso insicuro più difficile di quello sicuro.

Controllo degli accessi basato sui ruoli all'interno dei tenant

La multi-tenancy introduce un modello di autorizzazione a due livelli. Primo: questo utente appartiene a questo tenant? Secondo: cosa può fare al suo interno?

Mantengo le cose semplici con una tabella 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)
);

I ruoli sono tipicamente owner, admin, member, e a volte viewer o un ruolo specifico del dominio. Evita di costruire una matrice di permessi completa a meno che il tuo prodotto non ne abbia genuinamente bisogno — la maggior parte dei SaaS B2B funziona bene con 3-4 ruoli.

Il controllo dell'autorizzazione avviene dopo la risoluzione del 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
});

Una scelta di design che evita mal di testa in futuro: permetti sempre a un utente di appartenere a più tenant. Anche se il tuo prodotto è single-workspace oggi, nel momento in cui aggiungi account agenzia, rivenditori white-label o una modalità consulente, ne avrai bisogno.

Feature flag per tenant

Non ogni tenant dovrebbe vedere ogni funzionalità. A volte stai limitando per livello di piano. A volte stai facendo un rollout graduale. A volte un cliente enterprise sta pagando per una capacità personalizzata.

Uso una semplice tabella tenant_features piuttosto che integrare un servizio di feature flag di terze parti. Per la maggior parte dei prodotti SaaS, questo è più che sufficiente:

// 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);
}

Nei route handler:

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
});

Quando un tenant aggiorna il proprio piano, attivi le righe delle feature rilevanti e invalidi la cache. Semplice, verificabile e senza dipendenze esterne.

Integrazione della fatturazione

Stripe è lo standard qui per una buona ragione. La mappatura è naturale: un Stripe Customer per tenant, una Subscription per tenant, i livelli di piano mappano a Products e Prices.

La regola di design critica: salva lo Stripe Customer ID sul record del tenant, non sul record dell'utente. La fatturazione appartiene all'organizzazione, non alla persona che ha inserito la carta di credito.

Il flusso dei webhook appare così:

  1. checkout.session.completed — collega lo Stripe Customer ID al tenant, attiva la loro sottoscrizione.
  2. invoice.paid — estendi il periodo di fatturazione, aggiorna tenant.paidUntil.
  3. invoice.payment_failed — segna il tenant come scaduto, invia email di sollecito.
  4. customer.subscription.updated — sincronizza i cambiamenti del livello di piano, aggiorna i feature flag di conseguenza.
  5. customer.subscription.deleted — degrada al piano gratuito o sospendi.

La chiave è l'idempotenza. Stripe può e invierà lo stesso webhook più volte. Ogni handler dovrebbe essere sicuro da eseguire due volte con lo stesso payload dell'evento.

Isolamento dei dati e sicurezza

Oltre allo scoping delle query, ci sono alcune cose non ovvie che dovresti fare bene fin dall'inizio:

Row-Level Security (RLS): Se sei su PostgreSQL, abilita RLS come seconda linea di difesa. Anche se il tuo codice applicativo ha un bug, il database stesso rifiuterà di restituire righe che non corrispondono al contesto del tenant corrente. Imposta app.current_tenant al momento della connessione e scrivi policy basate su di esso.

Contesto di logging: Ogni riga di log dovrebbe includere tenantId. Quando qualcosa si rompe alle 3 di notte, devi sapere quale tenant è interessato senza dover scavare nelle tracce delle richieste. Il logging strutturato con un campo tenant integrato nel contesto del logger rende questo automatico.

Backup e cancellazione dei dati: I tenant abbandoneranno, e alcuni invocheranno il GDPR o il diritto alla cancellazione dei dati. Se tutti i tuoi dati sono in tabelle condivise, eliminare un singolo tenant significa istruzioni DELETE accuratamente costruite su ogni tabella. Con schema- o database-per-tenant, è un DROP SCHEMA o DROP DATABASE. Pianifica questo dal primo giorno.

Rate limiting per tenant: Un rate limiter globale non basta. L'importazione batch di un tenant non dovrebbe consumare il budget di rate di un altro tenant. Applica lo scope del tuo rate limiter per tenantId, non solo per IP o utente.

Crittografia a riposo: Per i settori sensibili, crittografa i dati del tenant a riposo con chiavi per-tenant. Questo aggiunge complessità alla gestione delle chiavi, ma significa che revocare l'accesso di un tenant è semplice come distruggere la loro chiave. Nessun dato rimane leggibile su disco dopo l'offboarding.

Considerazioni finali

La multi-tenancy non è una libreria che installi. È un insieme di decisioni architetturali che toccano ogni livello del tuo stack, dal database al CDN. La buona notizia è che i pattern sono ben compresi. La parte difficile è applicarli con il giusto livello di isolamento per il tuo mercato specifico e la tua fase.

Inizia con tutto condiviso e un disciplinato layer di scoping. Aggiungi confini di isolamento quando i tuoi clienti li richiedono. Non sovra-ingegnerizzare per requisiti di conformità che non hai ancora, ma assicurati che la tua architettura possa evolvere verso un isolamento più rigoroso senza una riscrittura.

Ho costruito sistemi multi-tenant da piattaforme per la ristorazione a SaaS medicali — ognuno con i propri requisiti di isolamento e scalabilità. I pattern in questo articolo sono quelli che hanno retto in tutti.

DU

Danil Ulmashev

Full Stack Developer

Interessato a collaborare?