rails-tenantify π’
Row-level multi-tenancy for Rails β scoped models, job-safe context, zero schema-per-tenant complexity.
rails-tenantify is a lightweight Rails gem for row-level multi-tenancy. Unlike apartment, which switches entire databases or schemas per tenant, rails-tenantify keeps a single database and scopes records with a foreign key β the same model as acts_as_tenant, but maintained for Rails 7+, with retry-safe jobs, bulk-write guards, and first-class test helpers.
The RubyGems package is rails-tenantify. Require the library as tenantify (same pattern as rails-persona β persona).
Compatibility
| Version | |
|---|---|
| Ruby | >= 3.1 |
| Rails | >= 7.0 (tested on 7.1) |
| Database | SQLite3, PostgreSQL, MySQL |
Why rails-tenantify over acts_as_tenant?
| acts_as_tenant | rails-tenantify | |
|---|---|---|
| Maintenance | Stagnant / issue backlog | Actively maintained |
| Rails 7 / 8 | Partial | Full |
| Sidekiq retry loses tenant | Known issue (#356) | Tenant ID in payload + middleware |
update_all / delete_all scoped |
Unreliable | Raises unless intentionally bypassed |
| Cross-tenant association checks | Manual | Built-in validation |
| Tenant override protection | None | :log, :raise, or :ignore |
| API / header resolver | DIY | set_tenant_by :header |
| RSpec helpers | Partial | with_tenant / without_tenant |
| Test suite | Aging | RSpec, CI on Ruby 3.1β3.3 |
Installation
gem "rails-tenantify", "~> 0.1.2", require: "rails-tenantify"
bundle install
Requires v0.1.1+ β fixes
undefined method 'configure' for Tenantify:Module. Use v0.1.2+ on Ruby 3.1.
Create config/initializers/tenantify.rb:
Tenantify.configure do |config|
config.tenant_model = "Organization"
config.on_tenant_not_found = :raise # :raise, :redirect, :null_tenant
config.audit_overrides = :log # :log, :raise, :ignore
end
Add a tenant reference to scoped models (example):
rails g migration AddOrganizationToProjects organization:references
rails db:migrate
Quick start
1. Define your tenant model
class Organization < ApplicationRecord
# e.g. subdomain: "acme" for acme.yourapp.com
end
2. Scope models to a tenant
class Project < ApplicationRecord
include Tenantify::Scoped
belongs_to_tenant :organization
has_many :tasks
end
class Task < ApplicationRecord
include Tenantify::Scoped
belongs_to_tenant :organization
belongs_to :project
end
3. Resolve tenant in controllers
class ApplicationController < ActionController::Base
include Tenantify::Controller
set_tenant_by :subdomain
# set_tenant_by :header, header: "X-Tenant-ID"
end
4. Use scoped queries in the request
# Tenantify.current_tenant is set by the controller
Project.all # => only current organization's projects
Project.create!(name: "Q2 Roadmap") # organization_id set automatically
Tenant context
Tenantify.current_tenant # => #<Organization id: 1 ...>
Tenantify.current_tenant_id # => 1
Tenantify.tenant_scoped? # => true
Tenantify.switch_to(other_org) do
Project.all # scoped to other_org
end
# previous tenant restored
Tenantify.without_tenant do
Project.delete_all # bypasses default scope + bulk guards
end
Controller resolvers
| Resolver | Usage | Finds tenant by |
|---|---|---|
:subdomain |
set_tenant_by :subdomain |
request.subdomain β Organization.find_by(subdomain: ...) |
:header |
set_tenant_by :header, header: "X-Tenant-ID" |
Header value β Organization.find_by(id: ...) |
Exclude reserved subdomains:
set_tenant_by :subdomain, exclude: %w[www admin]
When no tenant is found, behavior is controlled by on_tenant_not_found:
# :raise β Tenantify::TenantNotFoundError
# :redirect β redirect_to fallback path
# :null_tenant β leave current_tenant nil
set_tenant_by :subdomain, fallback: "/login"
Pluggable classes live under Tenantify::Resolvers (Subdomain, Header).
Background jobs (ActiveJob + Sidekiq)
Tenant context is serialized when the job is enqueued and restored on perform β including Sidekiq retries.
class ReportJob < ApplicationJob
def perform
Tenantify.current_tenant # same org as when the job was enqueued
Project.find_each { |p| p.update!(status: "exported") }
end
end
# In a request:
Tenantify.current_tenant = current_organization
ReportJob.perform_later
For native Sidekiq workers (nonβActiveJob), middleware injects tenant_id into the job hash and wraps execution in Tenantify.switch_to.
Bulk-write protection
update_all, delete_all, and destroy_all on tenant-scoped models raise Tenantify::TenantMismatchError unless the relation is already limited to the current tenant:
Tenantify.current_tenant = org
Project.update_all(status: "archived") # OK β scoped to org
Project.unscoped.update_all(status: "x") # raises TenantMismatchError
Tenantify.without_tenant do
Project.update_all(status: "migrated") # OK β intentional bypass
end
Cross-tenant association validation
Tenantify.current_tenant = org_a
project_a = Project.create!(name: "Alpha")
task = Task.new(name: "Bad", organization: org_b, project: project_a)
task.valid? # => false
task.errors[:project] # => ["belongs to a different tenant"]
Tenant override auditing
Tenantify.configure { |c| c.audit_overrides = :raise }
Tenantify.current_tenant = org_a
Tenantify.current_tenant = org_b
# => Tenantify::TenantOverrideError
Use :log to warn via Rails.logger without raising.
Test helpers (RSpec / Minitest)
RSpec.configure do |config|
config.include Tenantify::TestHelpers
end
it "creates under a tenant" do
with_tenant(org_a) do
project = Project.create!(name: "Demo")
expect(project.organization_id).to eq(org_a.id)
end
end
without_tenant do
Project.delete_all
end
# Minitest
setup { Tenantify::TestHelpers.set_tenant(org_a) }
teardown { Tenantify::TestHelpers.clear_tenant }
Configuration
# config/initializers/tenantify.rb
Tenantify.configure do |config|
config.tenant_model = "Organization" # required
config.on_tenant_not_found = :raise # :raise, :redirect, :null_tenant
config.audit_overrides = :log # :log, :raise, :ignore
end
Comparison with other approaches
| Approach | How it works | rails-tenantify advantage |
|---|---|---|
| acts_as_tenant | Row-level FK scope | Modern Rails, jobs, bulk guards, maintained |
| apartment | Schema / DB per tenant | Simpler ops β one DB, one migration path |
| acts_as_subtenant | Nested tenants | Flat, explicit belongs_to_tenant |
Custom default_scope |
Hand-rolled | Override protection, jobs, tests included |
API reference
| Method / macro | Description |
|---|---|
Tenantify.configure |
Global configuration block |
Tenantify.current_tenant |
Current tenant object (thread-local) |
Tenantify.current_tenant= |
Set tenant (respects audit_overrides) |
Tenantify.current_tenant_id |
Current tenant id or nil |
Tenantify.tenant_scoped? |
Whether default scope is active |
Tenantify.tenant_class |
Constantized tenant_model class |
Tenantify.switch_to(tenant) { } |
Temporary tenant switch |
Tenantify.without_tenant { } |
Disable scoping and bulk guards |
include Tenantify::Scoped |
Model concern for row-level scope |
belongs_to_tenant :association |
FK macro + validations + default scope |
include Tenantify::Controller |
Controller concern |
set_tenant_by :subdomain |
Subdomain resolver |
set_tenant_by :header |
Header resolver |
Tenantify::Job |
ActiveJob tenant serialize / restore (auto-included) |
with_tenant(tenant) { } |
Test helper β block switch |
without_tenant { } |
Test helper β disable scope |
Tenantify::TestHelpers.clear_tenant |
Reset thread-local state |
Errors
| Error | When |
|---|---|
Tenantify::TenantNotFoundError |
Resolver cannot find a tenant |
Tenantify::TenantMismatchError |
Unsafe bulk write without tenant scope |
Tenantify::TenantOverrideError |
Unsafe current_tenant= when audit_overrides is :raise |
Tenantify::Error |
Base error (e.g. missing tenant_model) |
Roadmap
| Version | Focus |
|---|---|
| 0.1.2 (current) | Ruby 3.1 CI β pin connection_pool < 3 |
| 0.1.1 | Fix Rails boot / Tenantify.configure entrypoint |
| 0.1.0 | Core scoping, subdomain/header resolvers, ActiveJob, Sidekiq, test helpers |
| 0.2.0 | GoodJob, Solid Queue |
| 0.3.0 | JWT resolver, API improvements |
| 0.4.0 | Custom domains, Active Storage |
| 0.6.0 | Hotwire / Turbo, GraphQL context |
| 1.0.0 | Stable API, full documentation |
See CHANGELOG.md for release notes.
Development
bundle install
bundle exec rspec
Contributing
Bug reports and pull requests are welcome at https://github.com/sghani001/rails-tenantify.
License
MIT β Β© Syed M. Ghani