Multi-tenant SaaS architecture is the design pattern that allows a single deployment of software to serve multiple customers (tenants) while keeping their data, configuration, and resource consumption completely isolated. The fundamental tension is between operational efficiency (one system to deploy, monitor, and upgrade) and tenant isolation (each customer’s data must be invisible to every other customer, and one tenant’s workload must not degrade another’s performance).
The architecture revolves around a resource hierarchy: Organization → Project → Table. An organization represents a paying customer. Projects provide logical grouping within an organization (development, staging, production). Tables are the actual data containers. Each level in the hierarchy carries its own configuration, quotas, and access controls. This hierarchical model maps cleanly onto billing (charge per-organization), access control (RBAC scoped to projects), and resource isolation (quotas enforced per-tier at the organization level).
A production database-as-a-service platform typically implements this pattern with deterministic naming conventions, slug immutability enforced via database triggers, soft deletes with retention policies, tier-based quota enforcement, and per-tenant Kubernetes namespace isolation for compute workloads.
The Organization → Project → Table hierarchy provides a natural scope boundary at each level. Organizations own billing relationships and define the tenant boundary. Projects partition workloads within a tenant (separating dev/staging/prod data). Tables are the leaf-level data containers. Every API request is scoped to exactly one position in this hierarchy, making authorization checks a simple path traversal: does this principal have access to this organization, this project, and this table?
Resource identifiers in a multi-tenant system serve as foreign keys across many subsystems: database schemas, Kubernetes namespaces, S3 bucket prefixes, monitoring labels, and billing records. If a slug changes, all downstream references break. Deterministic naming (slugifying the display name at creation time) combined with immutability (a database trigger that rejects UPDATE on the slug column) ensures that once a resource exists, its identifier is stable forever. Display names can change; slugs cannot.
Hard-deleting tenant data immediately is dangerous: accidental deletions are irrecoverable, and regulatory requirements may mandate data retention. Soft deletes set a deleted_at timestamp and a purge_after date. The resource becomes invisible to the API but remains in the database until a background reaper job permanently removes it after the retention period. This also simplifies billing reconciliation—you can still compute charges for the period before deletion.
Each organization is assigned a tier (free, pro, enterprise) that defines resource limits: maximum projects, maximum tables per project, storage quota, query rate limits, and API call budgets. Quotas are enforced at the API layer before any mutation. When a request would exceed a quota, the API returns a 429 or 403 with a machine-readable error that the client SDK can use to surface upgrade prompts. Quota enforcement must be atomic with the mutation to prevent races—typically implemented as a SELECT FOR UPDATE on the quota counter followed by the INSERT within the same transaction.
For compute-intensive workloads, each tenant gets a dedicated Kubernetes namespace with resource quotas (CPU, memory, pod count) and network policies that prevent cross-namespace communication. This provides both security isolation (a compromised tenant workload cannot access another tenant’s pods) and performance isolation (resource quotas prevent a single tenant from consuming all cluster capacity). The namespace is provisioned automatically when the organization is created and torn down during the purge phase of deletion.
The multi-tenant system has four main layers: the data model that encodes the resource hierarchy, the naming system that generates stable identifiers, the quota enforcement layer, and the Kubernetes isolation layer.
-- Organization: top-level tenant boundary
CREATE TABLE organizations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
tier TEXT NOT NULL DEFAULT 'free'
CHECK (tier IN ('free', 'pro', 'enterprise')),
deleted_at TIMESTAMPTZ,
purge_after TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Slug immutability trigger
CREATE OR REPLACE FUNCTION prevent_slug_change()
RETURNS TRIGGER AS $$
BEGIN
IF OLD.slug IS DISTINCT FROM NEW.slug THEN
RAISE EXCEPTION 'slug is immutable once set';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_org_slug_immutable
BEFORE UPDATE ON organizations
FOR EACH ROW EXECUTE FUNCTION prevent_slug_change();
-- Project: logical grouping within an org
CREATE TABLE projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL REFERENCES organizations(id),
slug TEXT NOT NULL,
display_name TEXT NOT NULL,
deleted_at TIMESTAMPTZ,
purge_after TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (org_id, slug)
);
CREATE TRIGGER trg_project_slug_immutable
BEFORE UPDATE ON projects
FOR EACH ROW EXECUTE FUNCTION prevent_slug_change();
-- Table: leaf-level data container
CREATE TABLE tables (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES projects(id),
slug TEXT NOT NULL,
display_name TEXT NOT NULL,
deleted_at TIMESTAMPTZ,
purge_after TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (project_id, slug)
);
package tenant
import (
"regexp"
"strings"
)
var (
nonAlphanumeric = regexp.MustCompile(`[^a-z0-9-]`)
multiDash = regexp.MustCompile(`-{2,}`)
)
// Slugify converts a display name to a stable, URL-safe identifier.
// Once assigned, the slug never changes (enforced by DB trigger).
func Slugify(displayName string) string {
s := strings.ToLower(strings.TrimSpace(displayName))
s = nonAlphanumeric.ReplaceAllString(s, "-")
s = multiDash.ReplaceAllString(s, "-")
s = strings.Trim(s, "-")
if len(s) > 63 {
s = s[:63]
}
return s
}
// ResourcePath builds the fully-qualified path for any resource
// in the hierarchy: org/project/table
func ResourcePath(orgSlug, projectSlug, tableSlug string) string {
parts := []string{orgSlug}
if projectSlug != "" {
parts = append(parts, projectSlug)
}
if tableSlug != "" {
parts = append(parts, tableSlug)
}
return strings.Join(parts, "/")
}
package tenant
import (
"context"
"database/sql"
"fmt"
)
type TierLimits struct {
MaxProjects int
MaxTablesPerProj int
MaxStorageBytes int64
MaxQPS int
}
var Tiers = map[string]TierLimits{
"free": {MaxProjects: 2, MaxTablesPerProj: 5, MaxStorageBytes: 1 << 30, MaxQPS: 10},
"pro": {MaxProjects: 20, MaxTablesPerProj: 50, MaxStorageBytes: 100 << 30, MaxQPS: 1000},
"enterprise": {MaxProjects: 500, MaxTablesPerProj: 500, MaxStorageBytes: 10 << 40, MaxQPS: 50000},
}
func EnforceProjectQuota(ctx context.Context, tx *sql.Tx, orgID string) error {
var tier string
var count int
err := tx.QueryRowContext(ctx,
`SELECT o.tier, COUNT(p.id)
FROM organizations o
LEFT JOIN projects p ON p.org_id = o.id AND p.deleted_at IS NULL
WHERE o.id = $1 AND o.deleted_at IS NULL
GROUP BY o.tier
FOR UPDATE OF o`, orgID).Scan(&tier, &count)
if err != nil {
return fmt.Errorf("quota check: %w", err)
}
limits := Tiers[tier]
if count >= limits.MaxProjects {
return &QuotaExceededError{
Resource: "projects",
Limit: limits.MaxProjects,
Current: count,
Tier: tier,
}
}
return nil
}
type QuotaExceededError struct {
Resource string
Limit int
Current int
Tier string
}
func (e *QuotaExceededError) Error() string {
return fmt.Sprintf("quota exceeded: %s tier allows %d %s, currently at %d",
e.Tier, e.Limit, e.Resource, e.Current)
}
package tenant
import (
"context"
"fmt"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
)
func ProvisionNamespace(ctx context.Context, client kubernetes.Interface,
orgSlug, tier string) error {
nsName := fmt.Sprintf("tenant-%s", orgSlug)
limits := Tiers[tier]
ns := &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{
Name: nsName,
Labels: map[string]string{
"app.kubernetes.io/managed-by": "example-platform",
"tenant.example.io/org": orgSlug,
"tenant.example.io/tier": tier,
},
},
}
_, err := client.CoreV1().Namespaces().Create(ctx, ns, metav1.CreateOptions{})
if err != nil {
return fmt.Errorf("create namespace: %w", err)
}
rq := &corev1.ResourceQuota{
ObjectMeta: metav1.ObjectMeta{
Name: "tenant-quota",
Namespace: nsName,
},
Spec: corev1.ResourceQuotaSpec{
Hard: corev1.ResourceList{
corev1.ResourceCPU: resource.MustParse(cpuForTier(tier)),
corev1.ResourceMemory: resource.MustParse(memForTier(tier)),
corev1.ResourcePods: resource.MustParse(podsForTier(tier)),
},
},
}
_, err = client.CoreV1().ResourceQuotas(nsName).Create(
ctx, rq, metav1.CreateOptions{})
if err != nil {
return fmt.Errorf("create resource quota: %w", err)
}
_ = limits // used for storage quota enforcement elsewhere
return nil
}
func cpuForTier(tier string) string {
switch tier {
case "enterprise": return "64"
case "pro": return "8"
default: return "1"
}
}
func memForTier(tier string) string {
switch tier {
case "enterprise": return "128Gi"
case "pro": return "16Gi"
default: return "2Gi"
}
}
func podsForTier(tier string) string {
switch tier {
case "enterprise": return "200"
case "pro": return "50"
default: return "5"
}
}
The multi-tenant architecture is the backbone of any database-as-a-service offering. In a typical platform, each customer organization gets isolated database instances, but the control plane (API, billing, monitoring) is shared. The resource hierarchy maps directly onto how customers think about their data: “my company has three projects (dev, staging, prod), and each project has its own set of tables.”
Deterministic naming is critical for operational tooling. When an engineer is debugging a production issue, they can derive the Kubernetes namespace (tenant-acme-corp), the S3 prefix (s3://data/acme-corp/prod/users/), and the monitoring label (org=acme-corp) from the organization slug without querying a mapping table. This makes grep-driven debugging possible even during outages when internal services are down.
Tier-based quotas serve double duty: they protect the platform from resource exhaustion (a free-tier user cannot create thousands of tables and overwhelm the metadata store) and they create natural upgrade incentives. When a customer hits a limit, the error message includes their current tier and the next tier’s limits, making the upgrade path self-service.
Every API request must carry tenant context through the entire call chain. The middleware extracts the tenant from the authentication token and injects it into the request context. All downstream database queries, cache lookups, and service calls use this context to scope their operations. This pattern prevents accidental cross-tenant data leakage—if a function forgets to filter by tenant, the context-scoped database connection handles it automatically.
A background job runs on a cron schedule (typically daily) that queries for resources where purge_after < now(). For each expired resource, it cascades the hard delete through the hierarchy (organization → projects → tables → data), cleans up external resources (Kubernetes namespace, S3 prefix, monitoring dashboards), and finally removes the database rows. The reaper uses advisory locks to prevent concurrent runs and processes deletions in small batches to avoid long-running transactions.
PostgreSQL Row-Level Security (RLS) provides a defense-in-depth layer. Even if application code has a bug that omits the tenant filter, RLS policies on the database prevent cross-tenant reads. The application sets SET LOCAL app.current_org_id = '...' at the start of each transaction, and RLS policies filter all queries to rows matching that org ID.
SELECT FOR UPDATE within the same transaction as the INSERT.