SaaS & data, an interactive architecture walkthrough
Multi-tenant SaaS analytics pipeline: how the architecture works
How a SaaS product collects events from many customers, keeps each tenant's data isolated, and serves fast dashboards from a columnar store.
This is a learning explainer based on public engineering writing and general design practice. It is simplified, it is not the actual architecture of any company, and it may be inaccurate.
Interactive diagram
Overview
A multi-tenant analytics pipeline serves many customers from shared infrastructure, so the tenant id is carried through every stage. Events are authenticated and rate-limited at ingestion, spread across a stream keyed by tenant and user, enriched, and written to a columnar store where the query layer injects the tenant filter itself. A control plane tracks plans, keys and usage for billing.
Trade-offs and what changes at scale
Shared tables with a tenant_id column are cheap to run and fast to onboard, but isolation depends on every query being scoped; separate databases or clusters per tenant isolate better and cost far more, so large or regulated tenants are often moved to dedicated resources. Keying the stream by tenant alone would send a large tenant's whole load to one partition, so keys include a user or session id and the biggest tenants can get dedicated topics. Putting tenant_id first in the ClickHouse sort key keeps tenant scans cheap. Usage is counted asynchronously, so quotas are enforced approximately in real time and billed exactly later. At scale the work shifts to per-tenant quotas, retention by plan, and deleting one tenant's data on request, which means partitioning ClickHouse by tenant or accepting slow mutations, and deciding how the immutable Parquet archive is erased.
How it works, step by step
The walkthrough above, as text. Each step lights up the components named under it.
An end-user action in the tenant's app is captured by the SDK, which batches events and attaches the tenant's write key before sending. Write keys are public in browsers, so abuse is limited by origin checks and a per-key rate limit.
The gateway terminates TLS and routes the batch to the collector, shielding it from malformed or oversized traffic.
The collector resolves the write key to a tenant from a short-lived cache of the control plane's data in Redis (refreshed or invalidated by the control plane when keys change), going to the control plane only on a miss so ingestion doesn't depend on it for every batch. It then applies per-tenant rate limits and quotas from Redis. Bad keys or exhausted quotas are rejected early.
Events are validated against the schema registry so events that fail validation are rejected before the stream and cannot poison downstream consumers. Free-form properties are validated loosely.
Valid events are published to Kafka keyed by tenant and user or session id. That spreads one large tenant's load across partitions while each user's events stay in order, and the stream is a durable buffer if downstream slows.
The stream processor enriches events with geo/IP and user agent, deduplicates and sessionises them. Keying by session keeps one user's state on one partition, so sessionisation stays correct.
The stream processor writes processed events to ClickHouse in large batches (ClickHouse wants few big inserts, not many tiny ones), with tenant_id on every row; late or duplicate events are handled by deduplication within a time window. Raw copies are archived as Parquet in S3 for replay and reprocessing, and the archive can be read back into the stream processor.
A tenant analyst opens the dashboard and signs in; the identity provider issues a JWT carrying the tenant id.
The query API verifies the JWT locally with the identity provider's cached signing keys and injects the tenant filter itself, so clients do not choose the tenant. This holds because the API accepts structured queries, not raw SQL. ClickHouse row policies or per-tenant views can enforce the same filter in the database as a second line of defence.
The tenant-keyed Redis cache is checked first; on a miss the scoped query runs on ClickHouse and the result is cached, protecting the store from repeated dashboards.
The collector counts usage in memory and Redis, never with a Postgres write per request. Redis counters are volatile, so they drive quota enforcement and an early estimate; the billable figure is reconciled against deduplicated event counts in ClickHouse. The billing job reads those counters on a schedule, aggregates them per tenant against plans, writes totals to Postgres, and reconciles against those ClickHouse counts before invoicing. Quota enforcement uses the same counters.
A tenant admin onboards through the control plane, which creates the tenant, plan and write keys in its Postgres; keys and quotas then reach the collector through the Redis cache. Noisy-neighbour limits keep one tenant from starving others.
Every tier emits metrics to observability, with per-tenant detail for the top tenants (or in logs and ClickHouse), since per-tenant metric labels explode cardinality, to spot lag, hot partitions and slow queries.
Sequence diagram
The same walkthrough as a UML sequence diagram, one lane per layer or group (20 components in all), left to right in the order a request reaches them, numbered by step. A loop on a lane is a step that stays inside that group. Solid arrows are synchronous calls, dashed are asynchronous.
Key components
Tenant Appsboundary
- Analytics SDK: JS/mobile SDK, batches events (javascript)
- End Users: Customers' app users
- Tenant Admin: onboarding and API keys
- Tenant Analyst: Views dashboards
Ingestion Edgelayer
- API Gateway: TLS, routing, L7 limits
- Collector Service: Auth write key (cached), quotas, validate
- Rate Limit Store: Token buckets, usage counters, cached keys (redis)
- Schema Registry: Event schemas per tenant
Control Planedomain
- Control DB: Tenants, plans, keys, usage (postgresql)
- Control Plane API: Tenants, plans, API keys
- Identity Provider: Issues JWT with tenant id
- Metering & Billing Job: Aggregates usage per tenant
Streaming & Processinglayer
- Event Stream: Keyed by tenant + user/session id (kafka)
- Stream Processor: Enrich, dedupe, sessionise (flink)
Data Layerlayer
- Analytics Store: tenant_id on every table (clickhouse)
- Raw Event Archive: Data lake, Parquet
Query & Dashboardlayer
- Dashboard Web App: Charts and reports
- Query API: JWT auth, tenant-scoped queries
- Query Cache: Tenant-keyed results (redis)
Observability
- Observability: Metrics, logs, traces
Sources
Public writing this explainer draws on. Read these for what the real systems actually do.
- AWS SaaS Architecture Fundamentals: Tenant isolation
- Confluent Developer: Apache Kafka partitions and message keys
- ClickHouse docs: Choosing a primary key
- ClickHouse docs: Selecting an insert strategy (batching)
- ClickHouse docs: Access control and row policies
- Apache Flink docs: Stateful stream processing
Make it yours
Copy this diagram and its walkthrough into your own workspace, then change anything: swap Postgres for DynamoDB, add a cache, rewrite the narration. Free, and it uses no AI credits.
Related walkthroughs
- Metrics and monitoring systemHow a monitoring system scrapes metrics from every service, alerts through grouped and routed notifications, and keeps years of data in object storage.
- Log aggregation pipelineHow a log pipeline collects lines from every host, buffers them in a topic, indexes only labels, and keeps compressed text in object storage.
- Time-series databaseHow a time-series database appends samples behind a write-ahead log, compresses them into immutable blocks, and answers range queries by label.
This is a learning explainer based on public engineering writing and general design practice. It is simplified, it is not the actual architecture of any company, and it may be inaccurate. Real systems differ in the details. Browse the library.