Costruire una piattaforma SaaS è fondamentalmente diverso dal costruire un'applicazione su misura per un singolo cliente. Nel momento in cui servi più tenant da una codebase e infrastruttura condivise, erediti una classe di problemi — isolamento dei dati, consumo equo delle risorse, accuratezza del billing, personalizzazione per tenant — che lo sviluppo su commessa non deve mai affrontare. Questa guida è una sintesi delle decisioni architetturali che abbiamo preso e degli errori che abbiamo osservato in più progetti SaaS.
Scegliere il modello di multi-tenancy
Il modello di multi-tenancy definisce come i tenant condividono (o non condividono) la tua infrastruttura. È la decisione architetturale più determinante che prenderai, perché influenza l'isolamento dei dati, la complessità operativa, la struttura dei costi e la capacità di servire clienti enterprise.
Modelli di multi-tenancy a confronto
La nostra raccomandazione predefinita per il SaaS nelle fasi iniziali è il modello database condiviso con schema separato usando PostgreSQL. Offre un forte isolamento logico senza il costo operativo di gestire N database, e la gestione degli schemi di PostgreSQL è abbastanza matura da supportare il provisioning automatico dei tenant.
Implementare la multi-tenancy basata su schema con PostgreSQL
@Injectable()
export class TenantProvisioningService {
constructor(private readonly db: DatabaseService) {}
async provisionaTenant(tenantId: string, piano: TipoPiano): Promise<void> {
const nomeSchema = `tenant_${tenantId.replace(/-/g, "_")}`;
await this.db.transaction(async (trx) => {
// 1. Crea lo schema
await trx.raw(`CREATE SCHEMA IF NOT EXISTS ??`, [nomeSchema]);
// 2. Imposta il search path ed esegui le migration
await trx.raw(`SET search_path TO ??`, [nomeSchema]);
await this.eseguiMigration(trx, nomeSchema);
// 3. Registra il tenant nel registro master pubblico
await trx("public.tenant").insert({
id: tenantId,
nome_schema: nomeSchema,
piano,
creato_il: new Date(),
stato: "attivo",
});
});
}
// Risolve lo schema tenant dal contesto della richiesta
async risolviSchemaTenant(tenantId: string): Promise<string> {
const tenant = await this.db("public.tenant")
.where("id", tenantId)
.where("stato", "attivo")
.first();
if (!tenant) throw new TenantNonTrovatoException(tenantId);
return tenant.nome_schema;
}
}Middleware per il contesto tenant
@Injectable()
export class TenantMiddleware implements NestMiddleware {
constructor(
private readonly tenantService: TenantProvisioningService,
private readonly cls: ClsService, // wrapper AsyncLocalStorage
) {}
async use(req: Request, _res: Response, next: NextFunction): Promise<void> {
// Estrae il tenant dal sottodominio, header o claim JWT
const tenantId = this.estraiTenantId(req);
if (!tenantId) {
throw new UnauthorizedException("Contesto tenant mancante");
}
const nomeSchema = await this.tenantService.risolviSchemaTenant(tenantId);
// Memorizza in AsyncLocalStorage — accessibile per tutta la durata della richiesta
this.cls.set("tenantId", tenantId);
this.cls.set("nomeSchema", nomeSchema);
next();
}
private estraiTenantId(req: Request): string | null {
// Opzione 1: sottodominio (acme.tuosaas.com)
const sottodominio = req.hostname.split(".")[0];
if (sottodominio && sottodominio !== "www" && sottodominio !== "app") return sottodominio;
// Opzione 2: header personalizzato (per accesso API)
return req.headers["x-tenant-id"] as string ?? null;
}
}Architettura dei dati per la scalabilità
Il layer database è dove la maggior parte delle piattaforme SaaS incontra il suo primo muro di scalabilità. Row-level security, performance delle query alla scala dei tenant e la complessità operativa delle migration degli schemi su N tenant devono essere progettati sin dall'inizio.
Migration multi-tenant automatizzate
Eseguire migration degli schemi su N schemi tenant è una delle sfide operative più sottovalutate. Una migration sequenziale su 500 schemi può richiedere ore e bloccare i deployment. Il nostro approccio è eseguire le migration in batch paralleli con locking esplicito per prevenire stati parziali.
async function eseguiMigrationSuTuttiITenant(fileMigration: string): Promise<void> {
const tenant = await db("public.tenant").select("nome_schema").where("stato", "attivo");
// Elabora in batch da 20 per limitare la pressione sulle connessioni DB
const DIMENSIONE_BATCH = 20;
const batch = chunk(tenant, DIMENSIONE_BATCH);
for (const gruppo of batch) {
await Promise.all(
gruppo.map(async ({ nome_schema }) => {
try {
await db.raw(`SET search_path TO ??`, [nome_schema]);
await eseguiMigration(db, fileMigration);
console.log(`✓ Schema ${nome_schema} migrato`);
} catch (err) {
// Registra e continua — gli schemi falliti vengono segnalati per revisione manuale
console.error(`✗ Migration fallita per ${nome_schema}:`, err.message);
await db("public.errori_migration").insert({
nome_schema,
migration: fileMigration,
errore: err.message,
timestamp: new Date(),
});
}
})
);
}
}Integrazione del billing
Il billing è il nucleo finanziario di un prodotto SaaS e la fonte più comune di dispute con i clienti. Usiamo Stripe come provider di billing predefinito e seguiamo un pattern rigoroso: Stripe è il sistema di riferimento per tutti i dati di abbonamento e pagamento; il nostro database memorizza solo gli ID Stripe e informazioni del piano in cache locale per le performance.
@Injectable()
export class BillingService {
constructor(private readonly stripe: Stripe) {}
async creaAbbonamento(
tenantId: string,
idPiano: string,
idMetodoPagamento: string,
): Promise<Stripe.Subscription> {
// 1. Recupera o crea il cliente Stripe
let cliente = await this.getClienteStripe(tenantId);
if (!cliente) {
cliente = await this.stripe.customers.create({
metadata: { tenantId },
});
await this.salvaTenantStripeId(tenantId, cliente.id);
}
// 2. Collega il metodo di pagamento
await this.stripe.paymentMethods.attach(idMetodoPagamento, {
customer: cliente.id,
});
// 3. Crea l'abbonamento
return this.stripe.subscriptions.create({
customer: cliente.id,
items: [{ price: idPiano }],
payment_behavior: "default_incomplete",
expand: ["latest_invoice.payment_intent"],
});
}
// Gestione webhook — mantiene lo stato locale sincronizzato con Stripe
async gestisciWebhook(evento: Stripe.Event): Promise<void> {
switch (evento.type) {
case "customer.subscription.updated":
await this.sincronizzaStatoAbbonamento(evento.data.object as Stripe.Subscription);
break;
case "invoice.payment_failed":
await this.gestisciPagamentoFallito(evento.data.object as Stripe.Invoice);
break;
}
}
}Pattern di scalabilità
Scaling orizzontale e statelessness
Ogni server applicativo deve essere stateless. I dati di sessione risiedono in Redis, i file caricati in object storage compatibile S3, i job in background sono accodati in una coda persistente. Questo permette di aggiungere istanze senza coordinamento. Usa un load balancer con health check e una strategia di deployment (blue-green o rolling) che mantiene zero downtime.
Strategia di caching
Fai caching al livello giusto. La configurazione del tenant e i limiti del piano cambiano raramente — mettili in cache su Redis per 5 minuti. I dati di sessione utente sono già su Redis. Le query di lettura pesante (analytics, report) devono essere servite da una read replica o da un database analitico dedicato. Non mettere in cache dati che devono essere in tempo reale (livelli di inventario, stato dei pagamenti).
Job in background e architettura delle code
Invio email, generazione PDF, sincronizzazione con API di terze parti, esportazioni di dati in bulk — tutto questo deve avvenire fuori dal ciclo di richiesta HTTP. Usiamo BullMQ supportato da Redis per le code di job. Ogni tipo di job ha la propria coda con impostazioni di concorrenza indipendenti, policy di retry e livelli di priorità.
Feature flag e personalizzazione per tenant
I clienti enterprise SaaS vogliono inevitabilmente personalizzazioni: branding personalizzato, toggle di funzionalità, diverse configurazioni di workflow. Lo implementiamo tramite un sistema di configurazione a layer: default globali → override a livello di piano → override a livello di tenant. I feature flag sono memorizzati nello schema del tenant e valutati a runtime, mai hardcoded.
Infrastruttura: lo stack production-ready
Architettura di riferimento SaaS Nexora
Considerazioni di sicurezza
- Row-level security: oltre all'isolamento degli schemi, abilita PostgreSQL RLS come misura di difesa in profondità contro le errate configurazioni dell'ORM.
- Audit log: ogni mutazione dei dati deve scrivere un audit log tamper-evident in una tabella separata append-only.
- Rate limiting per tenant: impedisci a un singolo tenant di consumare risorse sproporzionate e influenzare gli altri.
- Gestione dei segreti: usa AWS Secrets Manager o HashiCorp Vault — mai variabili d'ambiente in chiaro per credenziali di produzione.
- SOC2 readiness: progetta controlli di accesso, logging e gestione delle modifiche per essere SOC2-compatibili fin dall'inizio se le vendite enterprise sono nel roadmap.
Conclusioni
Una piattaforma SaaS moderna non è solo un'applicazione web con una pagina di abbonamento. È una composizione attenta di isolamento dei dati, accuratezza del billing, equità delle risorse, automazione del deployment e garanzie di sicurezza. Le decisioni che prendi nei primi tre mesi di architettura sono quelle con cui conviverai quando avrai 500 tenant. Investi nell'ottenere il modello di multi-tenancy, la strategia di migration e l'integrazione del billing corretti prima di investire nelle funzionalità.
Abbiamo costruito piattaforme SaaS da zero e modernizzato monoliti legacy in architetture multi-tenant. Se stai progettando un nuovo prodotto SaaS o scalando uno esistente, offriamo architecture review e delivery hands-on.
Tag
Prossimo passo
Hai bisogno di implementare questa architettura?
Il nostro team di ingegneria costruisce e scala i sistemi descritti in questo articolo. Dalla discovery alla produzione — con risultati misurabili.
Articoli correlati