```
---
``` ruby [3]
class ApplicationRecord < ActiveRecord::Base
primary_abstract_class
tenanted
end
```
``` ruby [2-3|6-8|10-20]
# β This is greatly simplified code!
module ActiveRecord::Tenanted::Tenant
extend ActiveSupport::Concern # mixed into the abstract connection class, e.g. ApplicationRecord
class_methods do
def current_tenant
current_shard
end
def connection_pool
pool = retrieve_connection_pool(strict: false)
if pool.nil?
config = tenanted_root_config.new_tenant_config(current_tenant)
establish_connection(config)
pool = retrieve_connection_pool(strict: true)
end
pool
end
end
end
```
---
### `ConnectionPool` complications
1. All classes inheriting from a common connection class must share the same connection pool
2. Must allow creation of a connection pool without a current tenant (return a mock pool)
3. Cannot connect to the database during boot
4. Database must be migrated when created
5. Race conditions when creating a new database
6. Race conditions when creating a connection pool
7. Test fixtures try to wrap all databases in a transaction
---
### Basic API
``` ruby [1-2|4-5|7-8|10-12|14-15|17-21]
# Enumerate existing tenants
ApplicationRecord.tenants # => ["foo", "bar", "baz"]
# Check for existence
ApplicationRecord.tenant_exist?("foo") # => true
# Create the database and migrate it
ApplicationRecord.create_tenant(tenant_name)
# No current tenant by default
ApplicationRecord.current_tenant # => nil
User.current_tenant # => nil
User.where(name: "Jeremy")
# => raises ActiveRecord::Tenanted::NoTenantError: Cannot connect to a tenanted database while untenanted (User).
ApplicationRecord.with_tenant("foo") do
ApplicationRecord.current_tenant # => "foo"
User.current_tenant # => "foo"
user = User.where(name: "Jeremy")
user.tenant # => "foo"
end
```
---
### Safety checks
It should be really hard to accidentally connect to the wrong database.
``` ruby [1|3-4|6-7]
user = ApplicationRecord.with_tenant("foo") { User.first }
user.update! name: "Jim"
# => raises ActiveRecord::Tenanted::NoTenantError: Cannot connect to a tenanted database while untenanted (User).
ApplicationRecord.with_tenant("bar") { user.update! name: "Jim" }
# => raises ActiveRecord::Tenanted::WrongTenantError: User model belongs to tenant "foo", but current tenant is "bar"
```
---
### Safety checks
Even on associations!
``` ruby [3-4|6-7]
user = ApplicationRecord.with_tenant("foo") { User.first }
user.comments
# => raises ActiveRecord::Tenanted::NoTenantError: Cannot connect to a tenanted database while untenanted (User).
ApplicationRecord.with_tenant("bar") { user.comments }
# => raises ActiveRecord::Tenanted::WrongTenantError: User model belongs to tenant "foo", but current tenant is "bar"
```
---
### Safety checks
When changing tenants!
``` ruby [1-4|6-8|10-12]
ApplicationRecord.with_tenant("foo") do
ApplicationRecord.with_tenant("bar") { }
# => raises ArgumentError: cannot swap `shard` while shard swapping is prohibited.
end
ApplicationRecord.with_tenant("foo", prohibit_shard_swapping: false) do
ApplicationRecord.with_tenant("bar") { puts "This is OK" }
end
ApplicationRecord.with_tenant("foo") do
ApplicationRecord.with_tenant("foo") { puts "This is also OK" }
end
```
---
But I don't want to call `with_tenant` everywhere!
## Middleware to the rescue
---
### Rack Middleware
Setting the tenant for request handling
``` ruby [|9-16|10-11|13-15]
# β This is greatly simplified code!
class ActiveRecord::Tenanted::TenantSelector
attr_reader :app
def initialize(app)
@app = app
end
def call(env)
request = ActionDispatch::Request.new(env)
tenant_name = tenant_resolver.call(request)
connection_class.with_tenant(tenant_name) do
@app.call(env)
end
end
end
```
---
### Rack Middleware
Configuring how the tenant is discovered
``` ruby [2-6|10-12]
class ActiveRecord::Tenanted::Railtie < ::Rails::Railtie
# Omakase default: assume ApplicationRecord will be tenanted.
config.active_record_tenanted.connection_class = "ApplicationRecord"
# Omakase default: assume request subdomain determines the tenant
config.active_record_tenanted.tenant_resolver = ->(request) { request.subdomain }
config.before_initialize do
Rails.application.configure do
if config.active_record_tenanted.connection_class.present?
config.middleware.use ActiveRecord::Tenanted::TenantSelector
end
end
end
end
```
To get more complicated, override `tenant_resolver`
---
## Chapter 3
# The Rest Of The Owl
---
---
### `config.active_record_tenanted.connection_class`
Controls all (well, most) of the fancy integrations.
``` ruby
Rails.application.configure do
if config.active_record_tenanted.connection_class.present?
config.middleware.use ActiveRecord::Tenanted::TenantSelector
end
end
```
Set it to `nil` if you want to draw the owl yourself.
---
### Rails's record types
We want these Rails models to be tenanted:
- `ActionText::Record`
- `ActiveStorage::Record`
- `ActionMailbox::Record`
But they all subclass `ActiveRecord::Base`! π₯΄
``` ruby[|4]
ActiveStorage::Record.connection
# => raises ActiveRecord::Tenanted::NoTenantError:
# Cannot use an untenanted ActiveRecord::Base connection. If you have a model that inherits
# directly from ActiveRecord::Base, make sure to use 'subtenant_of'. In development,
# you may see this error if constant reloading is not being done properly.
```
---
### Subtenanting and Rails's record types
`subtenant_of` forwards `connection_pool` to the tenanted connection class
``` ruby [1-2|9-13|21-25]
# Omakase default
ActiveStorage::Record.subtenant_of "ApplicationRecord"
module ActiveRecord::Tenanted
module Base # always mixed into ActiveRecord::Base
extend ActiveSupport::Concern
class_methods do
def subtenant_of(class_name)
prepend Subtenant
@tenanted_subtenant_of = class_name
end
end
end
module Subtenant
extend ActiveSupport::Concern
class_methods do
def tenanted_subtenant_of
@tenanted_subtenant_of&.constantize || superclass.tenanted_subtenant_of
end
delegate :current_tenant, :connection_pool, to: :tenanted_subtenant_of
end
end
end
```
π¬
Remember how we overrode `.connection_pool` in the connection class?
Nothing stopping us from overriding it in these classes, too.
---
### Subtenanting and Rails's record types
``` ruby [2-3|5-6|8-13|13]
ApplicationRecord.with_tenant("foo") do
ActiveStorage::Record.current_tenant
# => "foo"
ActiveStorage::Record.connection_pool == ApplicationRecord.connection_pool
# => true
ApplicationRecord.with_tenant("foo") { ActiveStorage::Record.connection }
# => #
end
```
And the same for `ActionText` and `ActionMailbox`.
---
### Fragment cache
``` erb
<%# app/views/users/show.html.erb %>
<% cache @user do %>
<%= @user.name %>
Account info: <%= @user.expensive_account_info %>
<% end %>
```
Timeline:
1. User 1 in tenant "foo" views their profile.
2. A fragment is cached under the key `users/1`
3. User 1 in tenant "bar" views their profile
4. β Cache hit on `users/1` displays tenant "foo"'s data β
---
### Fragment cache
If you're using Solid Cache,
one option is to put the cache in the tenanted database:
``` ruby
SolidCache::Record.subtenant_of "ApplicationRecord"
```
So every tenant has their own cache.
(I haven't tried this myself, some tweaks may be needed
to Solid Cache's connection logic.)
---
### Fragment cache
But what if you're using:
- `MemoryStore`
- `FileStore`
- `MemCacheStore`
- `RedisStore`
- a custom store?
---
### Fragment cache
`ActiveRecord::Tenanted` includes the tenant in the cache key of all tenanted models.
``` ruby
ApplicationRecord.with_tenant("foo") { User.first.cache_key }
# => "users/1?tenant=foo"
# ~~~~~~~~~~
```
which works for all cache stores.
---
### Fragment cache, take two
``` erb
<%# app/views/users/show.html.erb %>
<% cache @user do %>
<%= @user.name %>
Account info: <%= @user.expensive_account_info %>
<% end %>
```
Timeline:
1. User 1 in tenant "foo" views their profile.
2. A fragment is cached under the key `users/1?tenant=foo`.
3. User 1 from tenant "bar" views their profile.
4. β A fragment is cached under the key `users/1?tenant=bar`. β
---
### Active Storage
Without any special handling:
``` ruby
ApplicationRecord.with_tenant("foo") { ActiveStorage::Blob.first.key }
# => "q5gc2oc313zr7berq0fomrta6of3"
ApplicationRecord.with_tenant("bar") { ActiveStorage::Blob.first.key }
# => "zy161pmzbc8igjr89ouodkjeocy5"
```
In either an S3 blob store or the disk store, all tenants will be commingled.
---
### Active Storage
`ActiveRecord::Tenanted` includes the tenant in the key of all blobs
(if `ActiveStorage::Record` is tenanted).
``` ruby
ApplicationRecord.with_tenant("foo") { ActiveStorage::Blob.first.key }
# => "foo/q5gc2oc313zr7berq0fomrta6of3"
# ~~~
ApplicationRecord.with_tenant("bar") { ActiveStorage::Blob.first.key }
# => "bar/zy161pmzbc8igjr89ouodkjeocy5"
# ~~~
```
- In an S3 blob store, each tenant's blobs will be in a distinct "folder"
- In a disk blob store, each tenant's blobs will be in a distinct subdirectory
---
### Active Job
``` ruby
ApplicationRecord.with_tenant("foo") do
DeleteUserCommentsJob.perform_later(User.find(1))
end
```
How does the job worker know which tenant you mean?
---
### Active Job
``` ruby [1-2|4-13|15-22|25-26]
module ActiveRecord::Tenanted::Job
extend ActiveSupport::Concern # mixed into ActiveJob::Base
prepended do
attr_reader :tenant
end
def initialize(...)
super
if klass = ActiveRecord::Tenanted.connection_class
@tenant = klass.current_tenant
end
end
def serialize
super.merge!({ "tenant" => tenant })
end
def deserialize(job_data)
super
@tenant = job_data.fetch("tenant", nil)
end
def perform_now
if tenant.present? && (klass = ActiveRecord::Tenanted.connection_class)
klass.with_tenant(tenant) { super }
else
super
end
end
end
```
---
### Active Job
``` ruby
ApplicationRecord.with_tenant("foo") do
# The current_tenant context is captured as part of the job metadata
DeleteUserCommentsJob.perform_later(User.find(1))
end
# Untenanted jobs can just have a `nil` tenant in their metadata
SmokeTestJob.perform_later
```
---
### Active Job
Imagine this scenario, though:
``` ruby
# User from tenant "foo" ...
user = ApplicationRecord.with_tenant("foo") { User.find(1) }
ApplicationRecord.with_tenant("bar") do
# ... being passed to a job that will run for tenant "bar"
DeleteUserCommentsJob.perform_later(user)
end
```
Is this safe? How are model instances serialized and deserialized?
---
### `GlobalID`
Normally, the "global id" is a simple URL:
``` ruby
User.first.to_gid.to_s # => "gid://fizzy/User/1"
```
The job argument will be "gid://fizzy/User/1",
so this code would do something surprising:
``` ruby
# User from tenant "foo" ...
user = ApplicationRecord.with_tenant("foo") { User.find(1) }
ApplicationRecord.with_tenant("bar") do
# ... being passed to a job that will run for tenant "bar"
DeleteUserCommentsJob.perform_later(user)
end
```
β Comments for User 1 in tenant "bar" would be deleted instead of User 1 in tenant "foo". β
---
### `GlobalID`
Similar to the fragment cache entries and Blobs, let's include the tenant.
``` ruby
ApplicationRecord.with_tenant("foo") { User.find(1).to_gid.to_s }
# => "gid://fizzy/User/1?tenant=foo"
# ~~~~~~~~~~
ApplicationRecord.with_tenant("bar") { User.find(1).to_gid.to_s }
# => "gid://fizzy/User/1?tenant=bar"
# ~~~~~~~~~~
```
---
### `GlobalID`
And for safety, let's make sure `GlobalID::Locator` is tenant-aware:
``` ruby [1-3|5-7|9-10|12-13]
untenanted_gid = GlobalID.parse("gid://fizzy/User/1")
GlobalID::Locator.locate(untenanted_gid)
# raises ActiveRecord::Tenanted::MissingTenantError Tenant not present in "gid://fizzy/User/1"
tenanted_gid = GlobalID.parse("gid://fizzy/User/1?tenant=foo")
GlobalID::Locator.locate tenanted_gid
# raises ActiveRecord::Tenanted::NoTenantError: Cannot connect to a tenanted database while untenanted (gid://fizzy/User/1?tenant=foo)
ApplicationRecord.with_tenant("foo") { GlobalID::Locator.locate tenanted_gid }
# => #
ApplicationRecord.with_tenant("bar") { GlobalID::Locator.locate tenanted_gid }
# raises ActiveRecord::Tenanted::WrongTenantError: GlobalID "gid://fizzy/User/1?tenant=foo" does not belong the current tenant "bar"
```
---
### Active Job
Back to this scenario:
``` ruby
# User from tenant "foo" ...
user = ApplicationRecord.with_tenant("foo") { User.find(1) }
ApplicationRecord.with_tenant("bar") do
# ... being passed to a job that will run for tenant "bar"
DeleteUserCommentsJob.perform_later(user)
end
```
The job will fail with a `ActiveRecord::Tenanted::WrongTenantError` exception.
---
### Action Cable and Turbo
`ActionCable::Connection` acts a bit like the Rack middleware:
- uses `config.active_record_tenanted.tenant_resolver` to set the tenant
- wraps commands with `ApplicationRecord.with_tenant`
Turbo frames and streams use `GlobalID` and so are automatically tenanted for free.
---
### Action Mailer
`ActionMailer::Base` will interpolate `"%{tenant}"` in its url options:
``` ruby
Rails.application.configure do
# Set host to be used by links generated in mailer templates.
config.action_mailer.default_url_options = { host: "%{tenant}.example.com" }
end
```
---
### `load_async`
``` ruby [|8]
users = TenantedApplicationRecord.with_tenant("foo") do
User.where(email: "foo@example.org").load_async
end
TenantedApplicationRecord.with_tenant("bar") do
users.scheduled? # => still true
users.to_a
users.first.tenant # => "foo"
end
```
---
### SQL Query Logging
Every database query log contains the tenant name.
``` ruby
User.with_tenant("foo") { User.first }
```
``` text
# log/development.log
User Load [tenant=foo] (0.2ms) SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ? [["LIMIT", 1]]
~~~~~~~~~~
```
---
### Tagged logging
In production, each log entry is tagged with the tenant name.
``` text
Started GET "/175932900/collections/2/cards/12" for ::1 at 2025-08-27 19:31:10 -0400
[tenant=175932900] Processing by CardsController#show as HTML
[tenant=175932900] Parameters: {"collection_id" => "2", "id" => "12"}
[tenant=175932900] Session Load [tenant=175932900] (0.2ms) SELECT "sessions".* FROM "sessions" WHERE "sessions"."id" = ? LIMIT ? [["id", 2], ["LIMIT", 1]]
[tenant=175932900] β³ app/controllers/concerns/authentication.rb:52:in 'Authentication#find_session_by_cookie'
[tenant=175932900] User Load [tenant=175932900] (0.2ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]]
[tenant=175932900] β³ app/controllers/concerns/authentication.rb:82:in 'Authentication#set_current_session'
[tenant=175932900] Authorized User#1
[tenant=175932900] Account Load [tenant=175932900] (0.1ms) SELECT "accounts".* FROM "accounts" LIMIT ? [["LIMIT", 2]]
```
---
### Testing frameworks
#### Your tests shouldn't have to know about tenanting, either.
- Set up a default tenant, and load fixtures there
- Support for parallel testing: namespaced tenants per worker
- Integration tests and Action Cable tests have the host subdomain set properly
- Integration tests configure the tenant selector middleware
- System test `default_url_options` configured properly with the subdomain host
- Clean up any new tenants in `teardown`
---
### Testing frameworks
Your tests shouldn't have to know about tenanting
#### ... unless you're testing tenant behavior.
e.g., unit tests:
``` ruby [|2-3,5]
test "User.create_account creates a new tenant" do
ApplicationRecord.without_tenant do
# In this block, there is _explicitly_ no current tenant
assert User.create_account.kind_of(Account)
end
end
```
---
### Testing frameworks
Your tests shouldn't have to know about tenanting
#### ... unless you're testing tenant behavior.
e.g., integration tests:
``` ruby [|2,6]
test "create a new tenant" do
integration_session.host = "example.com" # no subdomain
post signup_accounts_url, params: { signup: { name: "Jeremy", account_name: "corp-1" } }
account = ApplicationRecord.with_tenant("corp-1") { Account.last }
assert_redirected_to(account.login_url)
end
```
---
## In summary
- Thread-safe Active Record connection management
- Action Dispatch and Rack middleware
- Active Storage, Action Mailer, and Action Text records
- The fragment cache
- Active Storage Blob keys
- Active Job workers
- GlobalID, GlobalID::Locator (including signed GIDs)
- Action Cable Connections and Turbo Streams
- Action Mailer
- Rails's testing frameworks
- Database tasks
---
---
## Chapter 4
# Looking Ahead
---
## Drawing even moar!!1! owl
Some features I'd like to add before 1.0:
- Cap number of connection pools
- Reap unused connection pools
- Handle streaming Rack bodies
π¬
And one missing piece that I haven't needed yet:
- Action Mailbox mail routing
---
## Smoothing rough edges
Some things I want to clean up before 1.0:
- Overhaul database task support
- Upstream: Database-specific shard swap prohibition
---
## This library is now open source
https://github.com/basecamp/activerecord-tenanted
and I would love for you to try it.
---
## Going global with SQLite
You're going to want replication for read locality and for fast failover.
### Watch Kevin McConnell's talk on Beamer!
---
# THANK YOU
for sitting through this _exciting_ presentation on
# Multi-Tenant Rails
Rails World 2025
Mike Dalessio | [@flavorjones](https://github.com/flavorjones) | https://mike.daless.io/