Warming up the neural circuits...
By the end of this chapter you will:
Building a scalable SaaS backend is the capstone of backend engineering. Unlike consumer apps where users are anonymous and free, a SaaS platform has tenants who pay real money, expect their data to be isolated, and will leave if your platform is slow or unreliable. This project forces you to make every tradeoff we've discussed in L5 — tenant isolation strategy, database scaling, caching layers, queue resilience, and observability — in the context of a real multi-tenant system that needs to onboard 10,000 businesses and serve 1 million end-users.
By the end of this design, you'll have an architecture document you can use as a reference for any SaaS product you build in your career.
Product: "DashCore" — A SaaS analytics dashboard platform where businesses connect their data sources (Stripe, Google Analytics, database) and get unified dashboards.
Scale targets:
Key features:
┌──────────────────────────────────────────────────────────────────┐
│ CDN (CloudFront) │
│ Static assets, cached dashboard data │
└─────────────────────────────┬────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────┐
│ API Gateway (Kong / AWS API Gateway) │
│ - Tenant identification (JWT with tenant_id claim) │
│ - Rate limiting per tenant (tiered: 100/1000/10000 req/min) │
│ - Request validation, TLS termination │
└─────────────────────────────┬────────────────────────────────────┘
│
┌───────────────────┼───────────────────┐
▼ ▼ ▼
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ Auth Service │ │ Dashboard Svc │ │ Data Source Svc │
│ - Signup/login │ │ - Widget CRUD │ │ - OAuth flows │
│ - JWT issue │ │ - Query engine │ │ - Sync scheduler │
│ - Team invites │ │ - Caching layer │ │ - Data transform │
│ - RBAC │ │ │ │ │
└────────┬─────────┘ └────────┬─────────┘ └────────┬─────────┘
│ │ │
▼ ▼ ▼
┌──────────────────────────────────────────────────────────────────┐
│ Message Queue (RabbitMQ) │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────┐ │
│ │ sync.jobs │ │ notifications│ │ analytics.events │ │
│ │ (data source │ │ (email, │ │ (usage tracking, │ │
Decision: Use PostgreSQL Row-Level Security (RLS) with a tenant_id column on every table, rather than creating a separate schema or database per tenant.
Rationale:
SET search_path on every query)tenant_id on every table gives us: one connection pool, one set of migrations, one backup, and tenant isolation enforced at the database levelImplementation:
-- Enable RLS on every table
ALTER TABLE dashboards ENABLE ROW LEVEL SECURITY;
ALTER TABLE data_sources ENABLE ROW LEVEL SECURITY;
ALTER TABLE widgets ENABLE ROW LEVEL SECURITY;
-- Create policy: users can only see their tenant's data
CREATE POLICY tenant_isolation ON dashboards
FOR ALL
TO authenticated_user
Tradeoffs:
tenant_idstatement_timeout per role, connection pooling limits per tenantAlternatives considered:
Decision: Route all dashboard reads (widget data, dashboard metadata) to read replicas. Route all writes (dashboard creation, settings updates, data sync writes) to the primary.
Rationale:
Tradeoffs:
Decision: All data source syncs go through a message queue (sync.jobs). Workers pull sync jobs, connect to external APIs, transform data, and write to TimescaleDB.
Rationale:
sync_id (timestamp + tenant + source). Workers check "has this sync already been written?" before insertingQueue topology:
sync.jobs (main queue)
├── Stripe syncs (high priority, frequent)
├── GA syncs (medium priority, hourly)
└── Custom DB syncs (low priority, daily)
sync.jobs.dlq (dead letter queue)
└── Jobs that failed after 5 retries
→ Alert on-call → Inspect → Replay or discardDecision: Implement per tenant at three levels: API Gateway (per-tenant limit), application-level (per-endpoint cost), and database-level (connection limits).
Tier structure:
| Tier | Tenants | Req/min per tenant | Sync frequency | Price/seat/month |
|---|---|---|---|---|
| Free | 0-3 users | 100 | 60 min | $0 |
| Pro | Unlimited | 1,000 | 15 min | $12 |
| Enterprise | Unlimited | 10,000 | 5 min | Custom |
Implementation:
# Kong rate limiting plugin configuration
plugins:
- name: rate-limiting
config:
minute: 1000 # default for Pro tier
policy: redis # shared counter across API gateway instances
redis_host: redis-cluster.internal
fault_tolerant: true # allow requests if Redis is down
hide_client_headers: false
-- Every table includes tenant_id for RLS
-- Every table includes created_at/updated_at for auditing
CREATE TABLE tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL,
plan TEXT NOT NULL DEFAULT 'free', -- free, pro, enterprise
custom_domain TEXT
Phase 1 (0-10k tenants): Single PostgreSQL instance with RLS. Vertical scaling as needed. Read replicas for dashboard queries.
Phase 2 (10k-50k tenants): Shard by tenant_id range using Citus or manual hash sharding. Shard 1: tenants A-M, Shard 2: tenants N-Z. Each shard is a complete PostgreSQL instance with its own RLS policies.
Phase 3 (50k+ tenants): Consistent hashing by tenant_id with dynamic resharding. Each shard handles ~5k tenants. A shard router in the application layer maps tenant_id → shard connection pool.
At 1M end-users across 10k tenants:
Layer | 1k tenants | 10k tenants | Scaling mechanism
----------------|------------|-------------|------------------
API Gateway | 2 instances| 5 instances | Auto-scale on request count
Auth Service | 2 instances| 4 instances | Auto-scale on CPU
Dashboard Svc | 3 instances| 8 instances | Auto-scale on request latency
Sync Workers | 5 workers | 30 workers | Scale on queue depth
PostgreSQL | db.r6g.xlarge| db.r6g.4xlarge| Vertical + read replicas
Redis Cluster | 3 nodes | 6 nodes | Consistent hashing
RabbitMQ | 1 node | 3-node cluster| Clustering + quorum queues
CDN | CloudFront| CloudFront | Already scales infinitelyEnterprise tenants with 5,000+ users get:
// Circuit breakers on external API calls (sync workers)
const stripeCircuitBreaker = new CircuitBreaker({
failureThreshold: 5,
timeout: 60000, // 60s open
halfOpenMaxRequests: 3,
});
const gaCircuitBreaker = new CircuitBreaker({
failureThreshold
| Failure type | Retry strategy | Max retries | DLQ action |
|---|---|---|---|
| Rate limit (429) | Exponential backoff: 30s → 60s → 120s → 240s | 4 | Replay after 1 hour |
| Auth error (401) | No retry (creds expired) | 0 | DLQ → notify tenant to reconnect |
| Timeout (5xx) | Exponential backoff + jitter: 1s → 2s → 4s → 8s | 4 | DLQ → manual review |
| Network error | Exponential backoff: 500ms → 1s → 2s → 4s | 5 | DLQ → auto-replay after 5 min |
| Failure | User impact | Degraded experience |
|---|---|---|
| Data source sync down | No new data in dashboards | Show "Last synced: 45 min ago" with warning banner. Cached data still visible |
| Stripe API down | Stripe dashboards show old data | Show "Stripe data delayed" with last sync timestamp. Other data sources unaffected |
| Redis down | Rate limiting falls back to local | Rate limits become per-instance (generous). Sessions fall back to database |
| Read replica down | Dashboard reads go to primary | Slightly higher load on primary. Users may not notice |
| Primary database down | Writes fail | Dashboards are read-only. Users see "Edit mode unavailable — we're working on it" |
tenant_id claimapp.current_tenant_idQ: Why did you choose Row-Level Security (RLS) over schema-per-tenant for tenant isolation?
A: At 10,000 tenants, schema-per-tenant creates an operational nightmare. Each schema requires its own connection pool configuration — you can't share connections across schemas without SET search_path on every query. Database-per-tenant means 10,000 databases to back up, migrate, and monitor. RLS with a tenant_id column gives us: one connection pool, one set of migrations, one backup strategy, and tenant isolation enforced at the database level by PostgreSQL. The tradeoff is the "noisy neighbor" problem — one tenant's heavy query can impact others. We mitigate this with statement_timeout, per-tenant connection limits, and eventually sharding by tenant_id range.
Q: How does your architecture handle a sudden spike from a single enterprise tenant (e.g., a Black Friday event causing 100× normal dashboard views)? A: Several layers of protection: (1) Rate limiting at the API gateway prevents any single tenant from consuming disproportionate resources — enterprise tiers get 10× the limit, not unlimited. (2) Dashboard query caching in Redis with TTL matching the data freshness requirements — if data syncs every 15 minutes, dashboard data for 14 minutes. A 100× view spike hits Redis, not the database. (3) caching for publicly shared dashboards — no backend hit at all. (4) For enterprise tenants with sustained high load, we provision a dedicated read replica. Their dashboard traffic routes to their replica, isolating them from other tenants. (5) Auto-scaling of dashboard service instances based on request latency, not just count — if p99 crosses 500ms, add instances.
Q: Walk through the data flow when a tenant connects Stripe as a data source and views their first dashboard. Where are the failure points, and how do you handle each?
A: The flow has 4 stages and 4 failure modes. Stage 1: OAuth connection — tenant clicks "Connect Stripe" → redirected to Stripe OAuth → receives refresh_token. Failure: Stripe OAuth is down. Handle: show friendly error, suggest trying later. No data lost. Stage 2: Initial sync — sync worker receives job, calls Stripe API with , gets metrics, transforms, writes to TimescaleDB with sync_id for idempotency. Failure points: Stripe API timeout (circuit breaker opens after 5 failures, job goes to retry queue), network error (exponential backoff retry up to 4 times), database write failure (DLQ after 4 retries). — user opens dashboard, query engine checks Redis cache (miss on first view), queries TimescaleDB for metrics, computes aggregation, caches result. Failure: database slow (statement_timeout at 10s, return partial data with "some metrics unavailable" banner). — subsequent views hit Redis cache. Cache TTL = sync interval minus 10% (if 60-min sync, cache 54 min). This ensures cache expires before new sync data arrives, preventing stale reads. At every stage, the user sees either the data or a clear ("Connecting to Stripe..." → "Syncing data..." → "Dashboard ready" or "Some data delayed — retrying").
DashCore's architecture makes intentional tradeoffs at every layer. Tenant isolation uses PostgreSQL RLS — simple to operate at 10k tenants, with a clear path to sharding when needed. Dashboard reads are routed to read replicas for independent scaling, while writes stay on the primary for consistency. Data syncs flow through a message queue with idempotent workers, circuit breakers, and DLQs — because external APIs fail in every way possible. Rate limiting is tiered per tenant at the API gateway, preventing any single tenant from degrading the platform. Redis caches dashboard query results with TTLs aligned to sync intervals. Graceful degradation ensures that when Stripe's API is down, only Stripe dashboards are affected — everything else keeps working. The scaling strategy is deliberately phased: start with a well-tuned -ish architecture, introduce read replicas at 1k tenants, shard by tenant_id at 10k, and run chaos experiments at every phase.
tenant_id on every table. Simpler than schema-per-tenant at 10k scale.sync_id. Workers check before inserting.