Multi-Tenant SaaS
This example shows how to build a multi-tenant SaaS application with:
- Three isolation strategies (shared tables, schema per tenant, database per tenant)
- Tenant-aware queries that automatically filter data
- Tenant provisioning and lifecycle management
- Cross-tenant analytics for platform operators
- Tenant migration between isolation levels
Choosing an Isolation Strategy
Section titled “Choosing an Isolation Strategy”| Strategy | Isolation | Complexity | Cost | Best For |
|---|---|---|---|---|
| Shared tables | Low | Low | Lowest | Many small tenants, B2C SaaS |
| Schema per tenant | Medium | Medium | Low | SMB customers, PostgreSQL only |
| Database per tenant | High | High | Highest | Enterprise, compliance requirements |
Strategy 1: Shared Tables with Row-Level Isolation
Section titled “Strategy 1: Shared Tables with Row-Level Isolation”All tenants share the same database tables, filtered by tenantId.
Schema Definition
Section titled “Schema Definition”import { z } from "zod";import { defineNode, defineEdge, defineGraph, searchable } from "@nicia-ai/typegraph";
// Tenant metadataconst Tenant = defineNode("Tenant", { schema: z.object({ slug: z.string(), name: z.string(), plan: z.enum(["free", "starter", "pro", "enterprise"]), status: z.enum(["active", "suspended", "cancelled"]).default("active"), createdAt: z.string().datetime(), settings: z.record(z.unknown()).optional(), }),});
// All entities include tenantIdconst Project = defineNode("Project", { schema: z.object({ tenantId: z.string(), // Searchable fields so tenants can run BM25 search against their // own projects without paying for an external search service. // Fulltext filtering composes with the tenantId predicate — every // query is authoritatively tenant-scoped. name: searchable({ language: "english" }), description: searchable({ language: "english" }).optional(), status: z.enum(["active", "archived"]).default("active"), }),});
const Task = defineNode("Task", { schema: z.object({ tenantId: z.string(), title: searchable({ language: "english" }), status: z.enum(["todo", "in_progress", "done"]).default("todo"), priority: z.enum(["low", "medium", "high"]).default("medium"), }),});
const User = defineNode("User", { schema: z.object({ tenantId: z.string(), email: z.string().email(), name: z.string(), role: z.enum(["owner", "admin", "member", "guest"]).default("member"), }),});
// Edgesconst hasProject = defineEdge("hasProject");const hasTask = defineEdge("hasTask");const assignedTo = defineEdge("assignedTo");const memberOf = defineEdge("memberOf");
const graph = defineGraph({ id: "multi_tenant", nodes: { Tenant: { type: Tenant, unique: [ { name: "tenant_slug", fields: ["slug"], scope: "kind", collation: "binary", }, ], }, Project: { type: Project }, Task: { type: Task }, User: { type: User, unique: [ // Emails are scoped per tenant in the shared-tables strategy: the // same address can be a member of more than one tenant, but not // twice in the same one. { name: "user_tenant_email", fields: ["tenantId", "email"], scope: "kind", collation: "caseInsensitive", }, ], }, }, edges: { hasProject: { type: hasProject, from: [Tenant], to: [Project] }, hasTask: { type: hasTask, from: [Project], to: [Task] }, assignedTo: { type: assignedTo, from: [Task], to: [User] }, memberOf: { type: memberOf, from: [User], to: [Tenant] }, },});Tenant-Scoped Store
Section titled “Tenant-Scoped Store”Create a wrapper that automatically filters by tenant:
interface TenantContext { tenantId: string; userId: string; role: "owner" | "admin" | "member" | "guest";}
function createTenantStore(store: Store, ctx: TenantContext) { const projects = { async list(options: { status?: string } = {}) { let query = store .query() .from("Project", "p") .whereNode("p", (p) => p.tenantId.eq(ctx.tenantId));
if (options.status) { query = query.whereNode("p", (p) => p.status.eq(options.status)); }
return query.select((q) => q.p).execute(); },
async create(data: { name: string; description?: string }) { const project = await store.nodes.Project.create({ ...data, tenantId: ctx.tenantId, });
const tenant = await store.nodes.Tenant.getById(ctx.tenantId); if (!tenant) throw new Error(`Tenant not found: ${ctx.tenantId}`); await store.edges.hasProject.create(tenant, project, {});
return project; },
async get(projectId: string) { const project = await store.nodes.Project.getById(projectId); if (!project || project.tenantId !== ctx.tenantId) { throw new Error("Not found"); } return project; },
async update(projectId: string, updates: Partial<ProjectProps>) { await projects.get(projectId); // Verify access return store.nodes.Project.update(projectId, updates); },
async delete(projectId: string) { await projects.get(projectId); // Verify access await store.nodes.Project.delete(projectId); }, };
const tasks = { async list(projectId: string) { await projects.get(projectId); // Verify access
return store .query() .from("Project", "p") .whereNode("p", (p) => p.id.eq(projectId)) .traverse("hasTask", "e") .to("Task", "t") .select((q) => q.t) .execute(); },
async create(projectId: string, data: { title: string; priority?: string }) { const project = await projects.get(projectId); // Verify access
const task = await store.nodes.Task.create({ ...data, tenantId: ctx.tenantId, });
await store.edges.hasTask.create(project, task, {}); return task; }, };
const users = { async list() { return store .query() .from("User", "u") .whereNode("u", (u) => u.tenantId.eq(ctx.tenantId)) .select((q) => q.u) .execute(); },
async invite(email: string, name: string, role: string) { if (ctx.role !== "owner" && ctx.role !== "admin") { throw new Error("Insufficient permissions"); }
const user = await store.nodes.User.create({ tenantId: ctx.tenantId, email, name, role, });
const tenant = await store.nodes.Tenant.getById(ctx.tenantId); if (!tenant) throw new Error(`Tenant not found: ${ctx.tenantId}`); await store.edges.memberOf.create(user, tenant, {});
return user; }, };
return { projects, tasks, users };}
// Usage in API handlerasync function handleRequest(req: Request) { const session = await getSession(req); const tenantStore = createTenantStore(store, { tenantId: session.tenantId, userId: session.userId, role: session.role, });
// All queries are automatically tenant-scoped const projects = await tenantStore.projects.list();}Tenant Provisioning
Section titled “Tenant Provisioning”async function provisionTenant( slug: string, name: string, ownerEmail: string, ownerName: string, plan: "free" | "starter" | "pro" | "enterprise" = "free"): Promise<{ tenant: Node<typeof Tenant>; owner: Node<typeof User> }> { return store.transaction(async (tx) => { // Atomic uniqueness check — the `tenant_slug` constraint guarantees // concurrent callers can't both succeed. const tenantResult = await tx.nodes.Tenant.getOrCreateByConstraint( "tenant_slug", { slug, name, plan, status: "active", createdAt: new Date().toISOString(), }, );
if (tenantResult.action !== "created") { throw new Error("Tenant slug already exists"); }
const owner = await tx.nodes.User.create({ tenantId: tenantResult.node.id, email: ownerEmail, name: ownerName, role: "owner", });
await tx.edges.memberOf.create(owner, tenantResult.node, {});
return { tenant: tenantResult.node, owner }; });}Strategy 2: Schema Per Tenant (PostgreSQL)
Section titled “Strategy 2: Schema Per Tenant (PostgreSQL)”Each tenant gets their own PostgreSQL schema within the same database.
import { Pool } from "pg";import { drizzle } from "drizzle-orm/node-postgres";import { sql } from "drizzle-orm";import { createPostgresBackend, generatePostgresMigrationSQL } from "@nicia-ai/typegraph/postgres";
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
async function createTenantSchema(tenantId: string): Promise<void> { const schemaName = `tenant_${tenantId}`;
// Create schema await pool.query(`CREATE SCHEMA IF NOT EXISTS ${schemaName}`);
// Run TypeGraph migrations in the tenant schema await pool.query(`SET search_path TO ${schemaName}`); await pool.query(generatePostgresMigrationSQL()); await pool.query(`SET search_path TO public`);}
async function getTenantStore(tenantId: string): Promise<Store> { const schemaName = `tenant_${tenantId}`;
// Create connection with schema const client = await pool.connect(); await client.query(`SET search_path TO ${schemaName}`);
const db = drizzle(client); const backend = createPostgresBackend(db);
// `searchable()` fields require the durable fulltext-materialization // step `createStoreWithSchema` performs at boot; bare `createStore()` // would throw `StoreNotInitializedError` on the first fulltext op. const [store] = await createStoreWithSchema(graph, backend); return store;}Tenant Store Cache
Section titled “Tenant Store Cache”class TenantStoreManager { private stores = new Map<string, { store: Store; lastUsed: Date }>(); private maxCached = 100;
async getStore(tenantId: string): Promise<Store> { const cached = this.stores.get(tenantId);
if (cached) { cached.lastUsed = new Date(); return cached.store; }
// Evict oldest if at capacity if (this.stores.size >= this.maxCached) { this.evictOldest(); }
const store = await getTenantStore(tenantId); this.stores.set(tenantId, { store, lastUsed: new Date() });
return store; }
private evictOldest(): void { let oldest: { id: string; date: Date } | undefined;
for (const [id, { lastUsed }] of this.stores) { if (!oldest || lastUsed < oldest.date) { oldest = { id, date: lastUsed }; } }
if (oldest) { this.stores.delete(oldest.id); } }}
const tenantManager = new TenantStoreManager();Provisioning with Schema
Section titled “Provisioning with Schema”async function provisionTenantWithSchema( slug: string, name: string, ownerEmail: string): Promise<{ tenantId: string }> { const tenantId = generateUUID();
// Create schema and tables await createTenantSchema(tenantId);
// Get tenant-specific store const tenantStore = await tenantManager.getStore(tenantId);
// Create initial data await tenantStore.nodes.User.create({ email: ownerEmail, name: name, role: "owner", });
// Store tenant metadata in public schema const publicDb = drizzle(pool); await publicDb.insert(tenants).values({ id: tenantId, slug, name, createdAt: new Date(), });
return { tenantId };}Strategy 3: Database Per Tenant
Section titled “Strategy 3: Database Per Tenant”Each tenant gets their own database for maximum isolation.
Tenant Database Manager
Section titled “Tenant Database Manager”interface TenantConfig { id: string; slug: string; databaseUrl: string; status: "active" | "suspended";}
class TenantDatabaseManager { private connections = new Map<string, { pool: Pool; store: Store }>(); private maxConnections = 50;
async getStore(tenantId: string): Promise<Store> { const cached = this.connections.get(tenantId); if (cached) return cached.store;
// Get tenant config from central registry const config = await this.getTenantConfig(tenantId);
if (config.status !== "active") { throw new Error("Tenant is not active"); }
// Evict if at capacity if (this.connections.size >= this.maxConnections) { await this.evictLeastUsed(); }
// Create new connection const pool = new Pool({ connectionString: config.databaseUrl, max: 5 }); const db = drizzle(pool); const backend = createPostgresBackend(db); const [store] = await createStoreWithSchema(graph, backend);
this.connections.set(tenantId, { pool, store });
return store; }
async closeConnection(tenantId: string): Promise<void> { const conn = this.connections.get(tenantId); if (conn) { await conn.pool.end(); this.connections.delete(tenantId); } }
private async getTenantConfig(tenantId: string): Promise<TenantConfig> { // Fetch from central tenant registry const result = await centralDb .select() .from(tenantConfigs) .where(eq(tenantConfigs.id, tenantId)) .get();
if (!result) throw new Error("Tenant not found");
return result; }
private async evictLeastUsed(): Promise<void> { // Simple LRU eviction const first = this.connections.keys().next().value; if (first) { await this.closeConnection(first); } }}
const dbManager = new TenantDatabaseManager();Provisioning New Database
Section titled “Provisioning New Database”async function provisionTenantDatabase( slug: string, name: string, ownerEmail: string): Promise<{ tenantId: string; databaseUrl: string }> { const tenantId = generateUUID(); const dbName = `tenant_${tenantId.replace(/-/g, "_")}`;
// Create database (using admin connection) const adminPool = new Pool({ connectionString: process.env.ADMIN_DATABASE_URL }); await adminPool.query(`CREATE DATABASE ${dbName}`); await adminPool.end();
// Build connection URL const baseUrl = new URL(process.env.DATABASE_BASE_URL!); baseUrl.pathname = `/${dbName}`; const databaseUrl = baseUrl.toString();
// Initialize TypeGraph tables const tenantPool = new Pool({ connectionString: databaseUrl }); await tenantPool.query(generatePostgresMigrationSQL());
// Create initial data const db = drizzle(tenantPool); const backend = createPostgresBackend(db); const [store] = await createStoreWithSchema(graph, backend);
await store.nodes.User.create({ email: ownerEmail, name: name, role: "owner", });
await tenantPool.end();
// Register in central tenant registry await centralDb.insert(tenantConfigs).values({ id: tenantId, slug, name, databaseUrl, status: "active", createdAt: new Date(), });
return { tenantId, databaseUrl };}Cross-Tenant Operations
Section titled “Cross-Tenant Operations”For platform administrators who need to query across tenants.
Aggregated Metrics (Shared Tables)
Section titled “Aggregated Metrics (Shared Tables)”import { count, field } from "@nicia-ai/typegraph";
async function getTenantMetrics(): Promise< Array<{ tenantId: string; projectCount: number; taskCount: number; userCount: number }>> { // Projects by tenant const projectCounts = await store .query() .from("Project", "p") .groupBy("p", "tenantId") .aggregate({ tenantId: field("p", "tenantId"), projectCount: count("p"), }) .execute();
// Tasks by tenant const taskCounts = await store .query() .from("Task", "t") .groupBy("t", "tenantId") .aggregate({ tenantId: field("t", "tenantId"), taskCount: count("t"), }) .execute();
// Users by tenant const userCounts = await store .query() .from("User", "u") .groupBy("u", "tenantId") .aggregate({ tenantId: field("u", "tenantId"), userCount: count("u"), }) .execute();
// Merge results const metrics = new Map<string, { projectCount: number; taskCount: number; userCount: number }>();
for (const p of projectCounts) { metrics.set(p.tenantId, { projectCount: p.projectCount, taskCount: 0, userCount: 0 }); }
for (const t of taskCounts) { const existing = metrics.get(t.tenantId) || { projectCount: 0, taskCount: 0, userCount: 0 }; existing.taskCount = t.taskCount; metrics.set(t.tenantId, existing); }
for (const u of userCounts) { const existing = metrics.get(u.tenantId) || { projectCount: 0, taskCount: 0, userCount: 0 }; existing.userCount = u.userCount; metrics.set(u.tenantId, existing); }
return Array.from(metrics.entries()).map(([tenantId, counts]) => ({ tenantId, ...counts, }));}Cross-Tenant Search (Database Per Tenant)
Section titled “Cross-Tenant Search (Database Per Tenant)”Fulltext search composes with tenant scope: the fulltext predicate narrows the candidate pool by relevance, and the per-tenant store supplies the isolation. Nothing from tenant A can ever appear in tenant B’s results because each store wraps a different database:
async function searchAcrossTenants( query: string, tenantIds: string[]): Promise<Array<{ tenantId: string; results: Array<ProjectProps & { score: number; snippet?: string }> }>> { const results = await Promise.all( tenantIds.map(async (tenantId) => { try { const store = await dbManager.getStore(tenantId);
const hits = await store.search.fulltext("Project", { query, limit: 10, includeSnippets: true, });
return { tenantId, results: hits.map((hit) => ({ ...hit.node, score: hit.score, snippet: hit.snippet, })), }; } catch (error) { console.error(`Failed to search tenant ${tenantId}:`, error); return { tenantId, results: [] }; } }) );
return results;}Tenant-Scoped Fulltext (Shared Tables)
Section titled “Tenant-Scoped Fulltext (Shared Tables)”The composition most multi-tenant teams need: BM25 ranking plus a tenantId filter in a single query. The fulltext predicate scales with the tenant’s data, not the whole table:
async function searchTenantProjects( tenantId: string, query: string, limit = 10,) { return store .query() .from("Project", "p") .whereNode("p", (p) => // Tenant filter first so the fulltext match is always scoped. p.tenantId.eq(tenantId).and(p.$fulltext.matches(query, limit)), ) .select((ctx) => ctx.p) .execute();}The tenant filter and fulltext match compose in one SQL statement; no post-filter in JS and no risk of leaking another tenant’s data even if the caller forgets to verify.
Tenant Lifecycle
Section titled “Tenant Lifecycle”Suspend Tenant
Section titled “Suspend Tenant”async function suspendTenant(tenantId: string, reason: string): Promise<void> { const current = await store.nodes.Tenant.getById(tenantId); if (!current) throw new Error(`Tenant not found: ${tenantId}`);
await store.nodes.Tenant.update(tenantId, { status: "suspended", settings: { ...(current.settings || {}), suspendedAt: new Date().toISOString(), suspendReason: reason, }, });}Delete Tenant (Shared Tables)
Section titled “Delete Tenant (Shared Tables)”async function deleteTenant(tenantId: string): Promise<void> { await store.transaction(async (tx) => { // Delete all tasks const tasks = await tx .query() .from("Task", "t") .whereNode("t", (t) => t.tenantId.eq(tenantId)) .select((ctx) => ctx.t.id) .execute();
for (const taskId of tasks) { await tx.nodes.Task.delete(taskId); }
// Delete all projects const projects = await tx .query() .from("Project", "p") .whereNode("p", (p) => p.tenantId.eq(tenantId)) .select((ctx) => ctx.p.id) .execute();
for (const projectId of projects) { await tx.nodes.Project.delete(projectId); }
// Delete all users const users = await tx .query() .from("User", "u") .whereNode("u", (u) => u.tenantId.eq(tenantId)) .select((ctx) => ctx.u.id) .execute();
for (const userId of users) { await tx.nodes.User.delete(userId); }
// Delete tenant await tx.nodes.Tenant.delete(tenantId); });}Delete Tenant (Database Per Tenant)
Section titled “Delete Tenant (Database Per Tenant)”async function deleteTenantDatabase(tenantId: string): Promise<void> { // Close active connection await dbManager.closeConnection(tenantId);
// Get database name const config = await getTenantConfig(tenantId); const dbUrl = new URL(config.databaseUrl); const dbName = dbUrl.pathname.slice(1);
// Drop database const adminPool = new Pool({ connectionString: process.env.ADMIN_DATABASE_URL }); await adminPool.query(`DROP DATABASE IF EXISTS ${dbName}`); await adminPool.end();
// Remove from registry await centralDb.delete(tenantConfigs).where(eq(tenantConfigs.id, tenantId));}Tenant Migration
Section titled “Tenant Migration”Move tenant between isolation strategies:
async function migrateTenantToSeparateDatabase(tenantId: string): Promise<string> { // 1. Create new database const { databaseUrl } = await provisionTenantDatabase( `migrated_${tenantId}`, "Migrated Tenant", "placeholder@example.com" );
// 2. Get tenant data from shared tables const sharedStore = store;
const projects = await sharedStore .query() .from("Project", "p") .whereNode("p", (p) => p.tenantId.eq(tenantId)) .select((ctx) => ctx.p) .execute();
const tasks = await sharedStore .query() .from("Task", "t") .whereNode("t", (t) => t.tenantId.eq(tenantId)) .select((ctx) => ctx.t) .execute();
const users = await sharedStore .query() .from("User", "u") .whereNode("u", (u) => u.tenantId.eq(tenantId)) .select((ctx) => ctx.u) .execute();
// 3. Insert into new database const newStore = await dbManager.getStore(tenantId);
await newStore.transaction(async (tx) => { for (const project of projects) { await tx.nodes.Project.create(project); }
for (const task of tasks) { await tx.nodes.Task.create(task); }
for (const user of users) { await tx.nodes.User.create(user); } });
// 4. Delete from shared tables await deleteTenant(tenantId);
return databaseUrl;}Next Steps
Section titled “Next Steps”- Document Management - CMS with semantic search
- Product Catalog - Categories, variants, inventory
- Integration Patterns - More deployment strategies