Building SaaS applications requires careful handling of multiple customers’ data. I’ve implemented various patterns to isolate tenant data securely in Ruby on Rails. Here are seven effective approaches I use, with practical examples.
When starting with multi-tenancy, row-level isolation is my first choice. Each table gets a tenant_id column, and all queries automatically scope to the current tenant. I implement this through concerns.
# app/models/concerns/tenant_scoped.rb
module TenantScoped
extend ActiveSupport::Concern
included do
belongs_to :tenant
default_scope { where(tenant_id: Tenant.current_id) }
validates :tenant_id, presence: true
before_validation :set_tenant, on: :create
end
private
def set_tenant
self.tenant_id ||= Tenant.current_id
end
end
# app/models/document.rb
class Document < ApplicationRecord
include TenantScoped
# Business logic here
end
For stronger separation, I use PostgreSQL schema separation. Each tenant gets their own schema while sharing the same database instance. The Apartment gem simplifies this, but I sometimes implement a custom solution.
# lib/tenant_middleware.rb
class TenantMiddleware
def initialize(app)
@app = app
end
def call(env)
request = Rack::Request.new(env)
tenant = Tenant.find_by(subdomain: request.subdomain)
return @app.call(env) unless tenant
Tenant.switch(tenant.schema_name) do
@app.call(env)
end
end
end
# app/models/tenant.rb
class Tenant < ApplicationRecord
def self.switch(schema)
original_schema = ActiveRecord::Base.connection.schema_search_path
ActiveRecord::Base.connection.schema_search_path = schema
yield
ensure
ActiveRecord::Base.connection.schema_search_path = original_schema
end
end
When applications scale, I implement database sharding. Each tenant gets a dedicated database instance. Rails 6+ connection handling works well for this.
# config/database.yml
production:
primary:
database: main
tenant_shard_1:
database: tenant1_prod
migrations_paths: db/tenant_migrate
tenant_shard_2:
database: tenant2_prod
migrations_paths: db/tenant_migrate
# app/models/tenant.rb
class Tenant < ApplicationRecord
def self.using_shard(shard, &block)
ActiveRecord::Base.connected_to(shard: shard, &block)
end
end
# In controller
Tenant.using_shard(current_tenant.shard_key) do
@documents = Document.where(status: :published)
end
Identifying tenants happens through middleware. I resolve tenants from domains, subdomains, or API keys early in the request cycle.
# app/middleware/tenant_resolver.rb
class TenantResolver
def initialize(app)
@app = app
end
def call(env)
request = ActionDispatch::Request.new(env)
tenant = resolve_tenant(request)
Tenant.set_current(tenant)
@app.call(env)
ensure
Tenant.clear_current
end
private
def resolve_tenant(request)
case
when request.subdomain.present?
Tenant.find_by!(subdomain: request.subdomain)
when request.headers["X-Tenant-Key"]
Tenant.find_by!(api_key: request.headers["X-Tenant-Key"])
else
raise "Tenant identification failed"
end
end
end
# config/application.rb
config.middleware.use TenantResolver
Background jobs require special handling. I propagate tenant context using ActiveJob hooks.
# app/jobs/application_job.rb
class ApplicationJob < ActiveJob::Base
around_perform :with_tenant_context
private
def with_tenant_context(&block)
tenant_id = arguments.extract_options![:tenant_id]
Tenant.set_current(tenant_id, &block)
end
end
# Usage in controller
DocumentExportJob.perform_later(current_user.id, tenant_id: Tenant.current_id)
Caching needs tenant isolation. I namespace cache keys using tenant identifiers.
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
def tenant_cache_key(key)
"tenant/#{Tenant.current_id}/#{key}"
end
end
# In view
<% cache tenant_cache_key(@document) do %>
<%= render @document %>
<% end %>
Security is non-negotiable. I layer controller validations with policy objects.
# app/policies/document_policy.rb
class DocumentPolicy
attr_reader :tenant, :document
def initialize(tenant, document)
@tenant = tenant
@document = document
end
def show?
document.tenant_id == tenant.id
end
end
# app/controllers/documents_controller.rb
def show
@document = Document.find(params[:id])
authorize @document
# Render logic
end
def authorize(record)
policy = DocumentPolicy.new(Tenant.current, record)
raise "Forbidden" unless policy.show?
end
For compliance, I implement audit logs tracking tenant activities.
# app/models/audit_log.rb
class AuditLog < ApplicationRecord
belongs_to :tenant
belongs_to :user
after_create :notify_admins
private
def notify_admins
TenantMailer.audit_alert(tenant, self).deliver_later
end
end
# In controller
AuditLog.create!(
tenant: Tenant.current,
user: current_user,
action: "document.update",
metadata: { document_id: @document.id }
)
Performance considerations include connection pooling. I configure separate pools for shards.
# config/database.yml
tenant_shard_1:
adapter: postgresql
pool: 15
database: tenant1_prod
Migrating from single-tenant to multi-tenant requires careful planning. I run phased rollouts.
# Migration script example
class AddTenantIdToDocuments < ActiveRecord::Migration[7.0]
def change
add_column :documents, :tenant_id, :integer
add_index :documents, :tenant_id
reversible do |dir|
dir.up do
execute "UPDATE documents SET tenant_id = 1" # Default tenant
end
end
end
end
These patterns balance isolation with resource efficiency. Row-based scoping offers simplicity, while sharding provides stronger separation. The right choice depends on compliance needs and scale. I start with schema separation for mid-sized apps, moving to sharding at larger scales. Consistent tenant propagation through jobs and caching prevents data leaks. Security layers enforce boundaries at every access point. With these approaches, I build SaaS platforms that securely scale with customer growth while maintaining data integrity.