Back to BlogSaaS Development

SaaS Architecture Best Practices: Building Scalable Applications

Yasir Ahmed GhauriJune 18, 202516 min read
Share:
S

The SaaS Architecture Challenge

Building a SaaS application isn't just about features—it's about architecture decisions that impact every user, every day. Get it wrong early, and you'll face painful rewrites. Get it right, and you can scale from 10 to 10,000 customers without breaking a sweat.

I've architected SaaS platforms for fintech, healthcare, e-commerce, and AI companies across Dubai, London, and the US. The patterns in this guide have been battle-tested in production environments handling millions of requests daily.

Multi-Tenancy Architecture Patterns

Pattern 1: Shared Database, Shared Schema

Best for: Startups, early-stage products, when simplicity matters

-- Every table has a tenant_id column
CREATE TABLE users (
  id UUID PRIMARY KEY,
  tenant_id UUID NOT NULL,
  email VARCHAR(255) NOT NULL,
  name VARCHAR(255),
  created_at TIMESTAMP DEFAULT NOW(),
  FOREIGN KEY (tenant_id) REFERENCES tenants(id),
  UNIQUE(tenant_id, email)
);

-- Always filter by tenant_id
CREATE INDEX idx_users_tenant ON users(tenant_id);

-- Row Level Security (RLS) in PostgreSQL
ALTER TABLE users ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON users
  USING (tenant_id = current_setting('app.current_tenant')::UUID);

Pros:

  • Simplest to implement and maintain
  • Easiest to query across tenants (for analytics)
  • Lowest infrastructure cost
  • Simplest backup/restore

Cons:

  • Risk of data leakage (mitigated by RLS)
  • Harder to customize per tenant
  • Performance can degrade with scale

Pattern 2: Shared Database, Schema-per-Tenant

Best for: B2B SaaS with enterprise customers needing customization

-- Each tenant gets their own schema
CREATE SCHEMA tenant_acme_corp;

CREATE TABLE tenant_acme_corp.users (
  id UUID PRIMARY KEY,
  email VARCHAR(255) UNIQUE NOT NULL,
  name VARCHAR(255)
);

-- Search path switching based on tenant
SET search_path TO tenant_acme_corp, public;

Pros:

  • Strong data isolation
  • Per-tenant customization possible
  • Easier to migrate individual tenants
  • Can offer "dedicated database" feel

Cons:

  • More complex connection management
  • Schema migrations across many schemas
  • Higher memory usage (each schema has overhead)

Pattern 3: Database-per-Tenant

Best for: Enterprise SaaS with compliance requirements (HIPAA, SOC2)

Implementation:

  • Each tenant gets completely separate database
  • Connection pooling per database
  • Full isolation at infrastructure level

Pros:

  • Maximum data isolation
  • Independent scaling per tenant
  • Easy per-tenant backup/restore
  • Compliance-friendly

Cons:

  • Most expensive (database connections)
  • Complex monitoring across databases
  • Harder to implement cross-tenant features
  • Operational complexity

Database Design for SaaS

Tenant Table Structure

CREATE TABLE tenants (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name VARCHAR(255) NOT NULL,
  slug VARCHAR(255) UNIQUE NOT NULL,
  plan VARCHAR(50) NOT NULL DEFAULT 'free',
  status VARCHAR(50) NOT NULL DEFAULT 'active',
  settings JSONB DEFAULT '{}',
  created_at TIMESTAMP DEFAULT NOW(),
  updated_at TIMESTAMP DEFAULT NOW(),
  deleted_at TIMESTAMP -- Soft delete
);

-- Indexes for common queries
CREATE INDEX idx_tenants_slug ON tenants(slug);
CREATE INDEX idx_tenants_status ON tenants(status);
CREATE INDEX idx_tenants_plan ON tenants(plan);

User Management

CREATE TABLE users (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID NOT NULL REFERENCES tenants(id),
  email VARCHAR(255) NOT NULL,
  password_hash VARCHAR(255),
  role VARCHAR(50) NOT NULL DEFAULT 'member',
  status VARCHAR(50) NOT NULL DEFAULT 'active',
  last_login_at TIMESTAMP,
  created_at TIMESTAMP DEFAULT NOW(),
  updated_at TIMESTAMP DEFAULT NOW(),
  
  -- Unique email per tenant
  UNIQUE(tenant_id, email)
);

-- Always query with tenant_id
CREATE INDEX idx_users_tenant_email ON users(tenant_id, email);
CREATE INDEX idx_users_tenant_role ON users(tenant_id, role);

Soft Deletes for Data Safety

-- Add deleted_at to all tenant tables
ALTER TABLE users ADD COLUMN deleted_at TIMESTAMP;

-- View for active records only
CREATE VIEW active_users AS
SELECT * FROM users WHERE deleted_at IS NULL;

-- Query pattern
SELECT * FROM users 
WHERE tenant_id = ? 
  AND deleted_at IS NULL 
  AND status = 'active';

Authentication & Authorization

JWT with Tenant Context

// lib/auth.ts
import jwt from 'jsonwebtoken';

export function createToken(user: User, tenant: Tenant) {
  return jwt.sign(
    {
      userId: user.id,
      email: user.email,
      role: user.role,
      tenantId: tenant.id,
      tenantSlug: tenant.slug,
      plan: tenant.plan,
    },
    process.env.JWT_SECRET,
    { expiresIn: '24h' }
  );
}

// Middleware to verify and extract tenant
export async function authMiddleware(request: Request) {
  const token = request.headers.get('authorization')?.replace('Bearer ', '');
  
  if (!token) throw new Error('No token provided');
  
  const decoded = jwt.verify(token, process.env.JWT_SECRET) as JWTPayload;
  
  // Set tenant context for database queries
  await db.query('SET app.current_tenant = $1', [decoded.tenantId]);
  
  return decoded;
}

Role-Based Access Control (RBAC)

// Define permissions
const PERMISSIONS = {
  user: ['read:own', 'update:own'],
  admin: ['read:all', 'update:all', 'create:all', 'delete:own'],
  owner: ['read:all', 'update:all', 'create:all', 'delete:all', 'manage:billing'],
} as const;

// Permission checking
export function can(user: User, action: string, resource: string) {
  const role = user.role as keyof typeof PERMISSIONS;
  const permissions = PERMISSIONS[role] || [];
  
  return permissions.includes(action) || 
         permissions.includes(`${action}:all`) ||
         (resource.ownerId === user.id && permissions.includes(`${action}:own`));
}

API Design for Multi-Tenancy

URL Structure Options

Option 1: Subdomain-based

acme-corp.yoursaas.com/api/users
other-tenant.yoursaas.com/api/users

Option 2: Path-based

yoursaas.com/api/acme-corp/users
yoursaas.com/api/other-tenant/users

Option 3: Header-based (API key)

GET /api/users
X-Tenant-ID: acme-corp
Authorization: Bearer <token>

Express.js Implementation

// middleware/tenant.ts
export function tenantMiddleware(req, res, next) {
  // Extract tenant from subdomain
  const subdomain = req.headers.host?.split('.')[0];
  
  // Or from header
  const tenantId = req.headers['x-tenant-id'] || subdomain;
  
  if (!tenantId) {
    return res.status(400).json({ error: 'Tenant required' });
  }
  
  // Fetch tenant and validate
  const tenant = await getTenantBySlug(tenantId);
  
  if (!tenant || tenant.status !== 'active') {
    return res.status(404).json({ error: 'Tenant not found' });
  }
  
  // Attach to request
  req.tenant = tenant;
  
  // Set database context
  await db.setTenantContext(tenant.id);
  
  next();
}

Scaling Strategies

Read Replicas

// lib/db.ts
const pool = new Pool({
  host: process.env.DB_HOST,
  // Read from replicas
  read: [
    { host: process.env.DB_REPLICA_1 },
    { host: process.env.DB_REPLICA_2 },
  ],
  // Write to primary
  write: { host: process.env.DB_HOST },
});

// Route queries automatically
export async function query(sql: string, params: any[], options?: { read?: boolean }) {
  if (options?.read || isSelectQuery(sql)) {
    return pool.read.query(sql, params);
  }
  return pool.write.query(sql, params);
}

Caching Layer

// Cache tenant-specific data
async function getTenantConfig(tenantId: string) {
  const cacheKey = `tenant:config:${tenantId}`;
  
  // Try cache first
  let config = await redis.get(cacheKey);
  if (config) return JSON.parse(config);
  
  // Fetch from database
  config = await db.query('SELECT settings FROM tenants WHERE id = $1', [tenantId]);
  
  // Cache for 5 minutes
  await redis.setex(cacheKey, 300, JSON.stringify(config));
  
  return config;
}

// Invalidate on update
async function updateTenantConfig(tenantId: string, settings: any) {
  await db.query('UPDATE tenants SET settings = $1 WHERE id = $2', [settings, tenantId]);
  await redis.del(`tenant:config:${tenantId}`);
}

Feature Flags per Tenant

// lib/features.ts
const FEATURES = {
  basic: ['dashboard', 'reports'],
  pro: ['dashboard', 'reports', 'api', 'integrations'],
  enterprise: ['dashboard', 'reports', 'api', 'integrations', 'ss', 'sla'],
};

export function hasFeature(tenant: Tenant, feature: string) {
  const plan = tenant.plan as keyof typeof FEATURES;
  const allowedFeatures = FEATURES[plan] || [];
  
  // Check plan features
  if (allowedFeatures.includes(feature)) return true;
  
  // Check custom overrides in tenant settings
  const customFeatures = tenant.settings?.features || [];
  return customFeatures.includes(feature);
}

// Usage in code
if (hasFeature(req.tenant, 'api')) {
  // Show API documentation
}

Billing & Usage Tracking

Event-Driven Architecture

// Track usage events
interface UsageEvent {
  tenantId: string;
  userId: string;
  event: string;
  quantity: number;
  metadata?: Record<string, any>;
  timestamp: Date;
}

// Publish events
async function trackUsage(event: UsageEvent) {
  // Send to queue for processing
  await messageQueue.publish('usage-events', event);
  
  // Real-time counter update
  await redis.incrby(`usage:${event.tenantId}:${event.event}:${getCurrentMonth()}`, event.quantity);
}

// Consume and aggregate
async function processUsageEvents() {
  await messageQueue.subscribe('usage-events', async (event) => {
    // Store in time-series database
    await timeseriesDB.insert({
      tenant_id: event.tenantId,
      event: event.event,
      quantity: event.quantity,
      timestamp: event.timestamp,
    });
    
    // Check limits
    const usage = await getCurrentUsage(event.tenantId, event.event);
    const limit = await getPlanLimit(event.tenantId, event.event);
    
    if (usage > limit * 0.9) {
      await sendWarningEmail(event.tenantId, event.event, usage, limit);
    }
    
    if (usage >= limit) {
      await enforceLimit(event.tenantId, event.event);
    }
  });
}

Deployment Architecture

Docker Compose for Development

# docker-compose.yml
version: '3.8'

services:
  app:
    build: .
    ports:
      - "3000:3000"
    environment:
      - DATABASE_URL=postgresql://user:pass@db:5432/saas
      - REDIS_URL=redis://redis:6379
      - JWT_SECRET=dev-secret
    depends_on:
      - db
      - redis

  db:
    image: postgres:15-alpine
    environment:
      POSTGRES_USER: user
      POSTGRES_PASSWORD: pass
      POSTGRES_DB: saas
    volumes:
      - postgres_data:/var/lib/postgresql/data

  redis:
    image: redis:7-alpine
    volumes:
      - redis_data:/data

volumes:
  postgres_data:
  redis_data:

Production Kubernetes

# k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: saas-app
spec:
  replicas: 3
  selector:
    matchLabels:
      app: saas
  template:
    metadata:
      labels:
        app: saas
    spec:
      containers:
      - name: app
        image: your-registry/saas:latest
        ports:
        - containerPort: 3000
        env:
        - name: DATABASE_URL
          valueFrom:
            secretKeyRef:
              name: db-credentials
              key: url
        - name: REDIS_URL
          valueFrom:
            secretKeyRef:
              name: redis-credentials
              key: url
        resources:
          requests:
            memory: "256Mi"
            cpu: "250m"
          limits:
            memory: "512Mi"
            cpu: "500m"
        livenessProbe:
          httpGet:
            path: /health
            port: 3000
          initialDelaySeconds: 30
          periodSeconds: 10
        readinessProbe:
          httpGet:
            path: /ready
            port: 3000
          initialDelaySeconds: 5
          periodSeconds: 5

Security Best Practices

Data Encryption

// Encrypt sensitive tenant data
import crypto from 'crypto';

const ALGORITHM = 'aes-256-gcm';

export function encrypt(text: string, key: string) {
  const iv = crypto.randomBytes(16);
  const cipher = crypto.createCipher(ALGORITHM, key);
  
  let encrypted = cipher.update(text, 'utf8', 'hex');
  encrypted += cipher.final('hex');
  
  const authTag = cipher.getAuthTag();
  
  return {
    encrypted,
    iv: iv.toString('hex'),
    authTag: authTag.toString('hex'),
  };
}

// Store encrypted in database
const encryptedApiKey = encrypt(apiKey, process.env.ENCRYPTION_KEY);
await db.query(
  'UPDATE tenants SET api_key = $1, api_key_iv = $2 WHERE id = $3',
  [encryptedApiKey.encrypted, encryptedApiKey.iv, tenantId]
);

API Rate Limiting per Tenant

// middleware/rate-limit.ts
import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';

const limiter = rateLimit({
  store: new RedisStore({
    client: redis,
    prefix: 'rl:', // tenant-specific prefix added dynamically
  }),
  max: async (req) => {
    // Different limits per plan
    const tenant = req.tenant;
    const limits = {
      free: 100,
      pro: 1000,
      enterprise: 10000,
    };
    return limits[tenant.plan] || 100;
  },
  windowMs: 15 * 60 * 1000, // 15 minutes
  keyGenerator: (req) => `${req.tenant.id}:${req.ip}`,
  handler: (req, res) => {
    res.status(429).json({
      error: 'Rate limit exceeded',
      upgradeUrl: '/billing/upgrade',
    });
  },
});

Testing Multi-Tenant Applications

Integration Tests

// tests/tenant-isolation.test.ts
describe('Tenant Isolation', () => {
  const tenantA = { id: 'tenant-a', token: generateToken('tenant-a') };
  const tenantB = { id: 'tenant-b', token: generateToken('tenant-b') };
  
  beforeEach(async () => {
    // Seed data for both tenants
    await seedTenantData(tenantA.id);
    await seedTenantData(tenantB.id);
  });
  
  it('should not allow cross-tenant data access', async () => {
    // Create resource as tenant A
    const resource = await createResource(tenantA.token, { name: 'Test' });
    
    // Try to access as tenant B
    const response = await request(app)
      .get(`/api/resources/${resource.id}`)
      .set('Authorization', `Bearer ${tenantB.token}`);
    
    expect(response.status).toBe(404);
  });
  
  it('should scope queries to current tenant', async () => {
    // Get resources as tenant A
    const responseA = await request(app)
      .get('/api/resources')
      .set('Authorization', `Bearer ${tenantA.token}`);
    
    // Should only see tenant A's data
    responseA.body.forEach((r: any) => {
      expect(r.tenantId).toBe(tenantA.id);
    });
  });
});

Conclusion

SaaS architecture is about making decisions that don't paint you into a corner. The patterns in this guide provide a foundation that scales from your first customer to your thousandth.

Key takeaways:

  1. Start simple (shared database), evolve as needed
  2. Tenant context is king—every query, every request
  3. Security is non-negotiable—RLS, encryption, validation
  4. Plan for scale—read replicas, caching, async processing
  5. Test isolation—prevent data leakage at all costs

Whether you're building the next Salesforce or a niche B2B tool, these architectural patterns will serve you well.

Need help architecting your SaaS? Let's discuss your requirements.

SaaSArchitectureScalabilityDatabaseMulti-tenancy

Frequently Asked Questions

What is the best database architecture for SaaS applications?

The best approach depends on your scale: 1) Shared Database with Tenant ID columns (best for startups, easiest to manage), 2) Schema-per-tenant (good for data isolation, medium complexity), 3) Database-per-tenant (maximum isolation, highest complexity/cost). Most successful SaaS start with shared database and migrate as they grow.

How do you handle authentication in multi-tenant SaaS?

Use JWT tokens with tenant context. Store tenant identifier in the JWT payload. Validate tenant access on every request. Implement row-level security (RLS) in PostgreSQL or equivalent in other databases. Consider auth providers like Auth0, Clerk, or Supabase Auth that support multi-tenancy out of the box.

What are the biggest scaling challenges for SaaS?

The top challenges are: 1) Database performance under concurrent load (solution: connection pooling, read replicas, caching), 2) Noisy neighbor problems (solution: resource quotas, rate limiting), 3) Data migration between tenants (solution: async background jobs), 4) Feature flags per tenant (solution: configuration management), 5) Billing and usage tracking (solution: event-driven architecture).

Need Help With SaaS Development?

I specialize in saas development for businesses across UAE, UK, USA, and beyond. Let's discuss your project.

Get in Touch
Yasir Ahmed Ghauri | AI Agent Developer & OpenClaw Expert | Hire Elite AI Developer