Follow the Journey
3 min read

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

All posts Follow along

Want to follow the journey?

Get Updates