Multi-Tenancy
How we isolate data without isolating databases
Every SaaS needs multi-tenancy. Most get it wrong.
Option 1: Separate databases. Each customer gets their own PostgreSQL instance. Clean isolation. Operational nightmare. Doesn't scale.
Option 2: Shared tables. Everyone in the same tables. Easy ops. Data leaks waiting to happen.
Option 3: Schema per tenant. Shared database, separate schemas. Better, but migrations become complex.
We chose a fourth path.
Organization-scoped everything
Every table has an organization_id column:
class LogEntry < ApplicationRecord
belongs_to :organization
belongs_to :project
default_scope { where(organization_id: Current.organization_id) }
end
Every query is automatically scoped. Forget to filter? The default scope catches it.
The Current pattern
Rails' Current attributes flow context through the request:
class Current < ActiveSupport::CurrentAttributes
attribute :organization
attribute :project
attribute :user
attribute :api_key
end
Set once at the beginning of the request:
class ApplicationController < ActionController::Base
before_action :set_current_context
def set_current_context
context = Platform.validate(api_key)
Current.organization = context.organization
Current.project = context.project
Current.user = context.user
end
end
Every model, every query, every background job—all see the same context.
API key design
Keys encode tenant information:
sk_live_org123_proj456_abc789
^^^^^^ ^^^^^^^
org_id project_id
Parse the key, know the tenant. No database lookup required for routing.
Platform validates the key and returns full context. Products trust Platform's response.
Background job isolation
Jobs need tenant context too:
class ProcessLogJob < ApplicationJob
def perform(organization_id, project_id, payload)
Current.organization_id = organization_id
Current.project_id = project_id
# Now all queries are scoped
LogEntry.create!(payload)
end
end
Context flows from request to job. No leakage.
Query safety
Defense in depth:
1. Default scopes — Queries filter automatically
2. Model validations — Can't save without organization_id
3. Database constraints — Foreign keys enforce relationships
4. Query logging — Audit queries in development to catch mistakes
class LogEntry < ApplicationRecord
validates :organization_id, presence: true
validates :project_id, presence: true
# Can't even instantiate without context
before_validation :set_organization_from_current
private
def set_organization_from_current
self.organization_id ||= Current.organization_id
end
end
Three layers before data touches the database.
Cross-tenant operations
Sometimes you need to break isolation. Platform operations. Billing aggregation. Admin tools.
# Explicitly unscoped
LogEntry.unscoped.where(created_at: 1.day.ago..).count
# Or with a block
Organization.find_each do |org|
Current.organization = org
# Scoped operations here
end
Unscoped queries are explicit. Easy to audit. Hard to do accidentally.
The Platform hierarchy
Organization (billing entity)
└── Project (logical grouping)
└── Resources (logs, errors, traces, etc.)
Organizations own billing. Multiple projects under one org share a subscription.
Projects group related resources. Your staging environment might be one project, production another.
Resources belong to projects. Logs, errors, traces—all project-scoped.
Users belong to organizations with roles. Projects inherit organization membership.
Performance
Doesn't this slow things down?
No. Actually faster.
Smaller indexes — Queries only scan one tenant's data
Better cache hit rates — Tenant data clusters together
Predictable performance — No noisy neighbor issues
The organization_id column is indexed everywhere. Queries are fast.
Why not schemas?
PostgreSQL schemas seem appealing. Each tenant gets isolation at the database level.
Problems:
- Migrations — Must run against every schema. Thousands of tenants = hours of migrations.
- Connection pooling — Schema switching per-request complicates pooling.
- Joins — Cross-tenant queries require schema qualification.
Row-level scoping is simpler. Same schema, filtered data.
The result
Sixteen products. Thousands of organizations. One database cluster.
Clean isolation. Simple operations. No data leaks.
Multi-tenancy that actually works.
— Andres