Using acts_as_tenant for Multi-tenant Postgres with Rails
Since its launch, Ruby on Rails has been a preferred open source framework for small-team B2B SaaS companies. Ruby on Rails uses a conventions-over-configuration mantra. This approach reduces common technical choices, thus elevating decisions. With this approach, the developers get an ORM (ActiveRecord), templating engine (ERB), helper methods (like number_to_currency
), controller (ActiveController), directory setup defaults (app/{models,controllers,views}
), authentication methods (has_secure_password
), and more.
Multi-tenant is the backbone of B2B SaaS products, yet core-Rails remains un-opinionated on multi-tenant implementations. Through the years, there has been many different Ruby gem implementations for multi-tenant. Many of these gems were built for complicated situations — either adapting to scaling needs or regulated industries that require physical separation of data. Many of these gems required deep integration with your Rails application code.
Enter acts_as_tenant
With all that as context, the acts_as_tenant
gem is super simple. acts_as_tenant
has recently released version 1.0 after 12 years of development — so it’s not new. The gem implements multi-tenant best-practices by augmenting Rails’ ActiveRecord ORM:
- protects developers from building queries that return other tenant’s records
- requires a
tenant_id
on the tables for models specific to a tenant - adds the
tenant_id
scope to the query - includes ActionController, ActiveRecord, ActiveJob helpers to insert new records with the scoped tenant
Acts_as_tenant is built for row-level multi-tenancy, and that is it. So, no need to manage multiple databases or schemas for data structures — it keeps it simple. One of the best things I can say about acts_as_tenant
is that it can be implemented by an existing application code-base. Too many times, with the older multi-tenant gems, the implementation was invasive, and thus required complex refactoring.
What it’s not: acts_as_tenant is not for account-based sharding — either schema-based or multi-cluster based sharding. It’s purely for multi-tenant safety.
For the paranoid
I have built a few multi-tenant apps in industries with data regulation (think finance and education). I am overly cautious when building multi-tenant apps — so this guardrail is my favorite.
To enforce the tenant_id
on every ActiveRecord query within an application, add the following to a initializer file in config/initializers/acts_as_tenant.rb
:
ActsAsTenant.configure do |config|
config.require_tenant = true
end
Having worked in a few multi-tenant apps where showing data to another customer is consequential, I wish acts_as_tenant
had an enforcing requirement of a tenant_id
for queries. One of the apps I wrote required high-performance, large-scale data loads. We had an intermittent bug where people would be assigned to the incorrect tenant. After tracking down the bug, we found the incident in the implementation of multiple external_ids
:
-- bug code
SELECT
*
FROM people
WHERE tenant_id = %1 AND external_id = $2 OR other_external_id = $2;
-- correct code
SELECT
*
FROM people
WHERE tenant_id = %1 AND (external_id = $2 OR other_external_id = $2);
The lesson: wrap your OR
statements in parenthesis. The bug code interpreted as:
(tenant_id = %1 AND external_id = $2) OR other_external_id = $2;
When using acts_as_tenant, you can avoid this bug when using ActiveRecord models. Below, you’ll see that ActiveRecord encapsulates the following:
Remember, if you choose to use raw SQL, you’ll need to keep your guard up.
Testing from rails new app
To install from a new rails application, do the following:
- Run
rails new multi-tenant-app
- Decide on your application’s tenant model: typically
Organization
orAccount
orTeam
orSchool
. Use the underscore version of the name with_id
appended as your tenant id for all columns, such asorganization_id
oraccount_id
orteam_id
orschool_id
. Below, we will use the tenant nameAccount
. - Add
gem "acts_as_tenant"
toGemfile
, and runbundle install
. - Create some models:
rails g model Account name:string
rails g model User email:string account_id:integer
rails g model Post content:string user_id:integer account_id:integer
rails db:create && rails db:migrate
- Add the following to
app/models/account.rb
class Account < ApplicationRecord
has_many :users
has_many :posts
end
- Add the following to
app/models/post.rb
:
class Post < ApplicationRecord
belongs_to :user
acts_as_tenant :account
end
- Add the following to
app/models/user.rb
:
class User < ApplicationRecord
acts_as_tenant :account
validates_uniqueness_to_tenant :email
end
- Now, let’s experiment with the Rails REPL:
rails console
Then, you can run the following commands:
first_account = Account.create!(name: "First Account")
last_account = Account.create!(name: "Last Account")
ActsAsTenant.with_tenant(first_account) do
user = User.create!(email: "test@example.com")
post = Post.create!(user: user, content: "Lorem Ipsum")
end
ActsAsTenant.with_tenant(first_account) do
Post.first.content # -> "Lorem Ipsum"
end
ActsAsTenant.with_tenant(last_account) do
Post.first.nil? # -> true because we did not create a tenant
end
Post.first.content # -> "Lorem Ipsum"
ActsAsTenant.configure do |config|
config.require_tenant = true
end
Post.first.content # -> ActsAsTenant::Errors::NoTenantSet (ActsAsTenant::Errors::NoTenantSet)
When looking at the queries that are run by ActiveRecord, you’ll see it automatically appends the account_id
to the User and Post that are created. Later, after we set require_tenant
, you’ll see that the next command fails with an error.
- From the terminal, we explicitly used
with_tenant
. acts_as_tenant has helpers for the controller as well. Depending on how your authentication systems and tenancy work, you can use domains, subdomains, or implicit tenancy based on the authenticated user. From here, you’ll need to implement something like:
class ApplicationController < ActionController::Base
set_current_tenant_through_filter
before_action :require_authentication
before_action :set_tenant
def require_authentication
current_user || redirect_to(new_session_path)
end
def current_user
@current_user ||= if session[:user_id].present?
User.find(session[:user_id])
end
end
def current_acount
@current_account ||= current_user.try(:account)
end
def set_tenant
set_current_tenant(current_account)
end
end
Implementation of proper authentications are complex, so this is simply for example. The code specific to acts_as_tenant are set_current_tenant_through_filter
and before_action :set_tenant
and def set_tenant
.
Migrating to acts_as_tenant
If you have an existing codebase that would benefit from acts_as_tenant, the migration is a process and can be broken into multiple steps:
- Add a tenant_id column to each affected model - this step can be quite complicated. It requires data migrations and data updates. The method of updating columns will be dependent on the size of your database.
- Add the acts_as_tenant gem, but do not set require_tenant yet
- Define the tenancy for your ApplicationController using either domains, subdomains, or filter
- Define the tenancy for your Action Job
- Define tenancy for your models
Taking a measured approach to migrating, you can deploy each of the steps above independently. And, you can deploy each model change independently of the entire change.
Removing acts_as_tenant
The best thing I can say about a library is: you can migrate away from it if it does not work for you. Because acts_as_tenant is not a deep integration as past multi-tenant libraries, it is possible to move away from acts_as_tenant.
Summary
Back in the 2009-ish era, Ruby on Rails and “The Cloud” grew up together when cloud-SaaS and social networks took off. Back then, the maximum performance of network attached storage was 100 IOPs and size maxed out at 1TB. The IOPs strangled database performance, and 1TB was an unbreakable limitation (if you did not RAID early). I started my career in that era. Due to infrastructure limitations, multi-tenant databases would start to see issues when an application hit as little as 50 requests per second. In this era, RAM was expensive and disk performance was not available. Because of this, “sharding” was talked about at all the conferences.
Side note: also, data was suddenly available everywhere, and there were business models that stored massive amounts of data hoping to figure out a business model later.
Now, in 2023, RAM is plentiful and IOPs are available. Scaling the database can be punted to 10s of thousands of requests per second.
Why do I say all this? Because now, we can approach multi-tenant apps and scaling more practically. Multi-tenant can focus on data-security and coding-practically instead of scaling. You may not ever get to the point of needing distributed data stores, but a solid multi-tenant implementation creates foundational success for your application.
The old multi-tenant Ruby Gems were for scalability. acts_as_tenant is built for practicality.
Related Articles
- Sidecar Service Meshes with Crunchy Postgres for Kubernetes
12 min read
- pg_incremental: Incremental Data Processing in Postgres
11 min read
- Smarter Postgres LLM with Retrieval Augmented Generation
6 min read
- Postgres Partitioning with a Default Partition
16 min read
- Iceberg ahead! Analyzing Shipping Data in Postgres
8 min read