Implementing multi-tenancy in Ruby on Rails for SaaS applications using PostgreSQL schemas is a game-changer. It’s like giving each of your customers their own private playground within your app. Let’s dive into this exciting world and see how we can make it happen.
First things first, what exactly is multi-tenancy? Imagine you’re running an apartment building. Each tenant has their own space, but they’re all part of the same structure. That’s multi-tenancy in a nutshell. In the software world, it means multiple customers (tenants) use the same application, but their data is kept separate and secure.
Now, why use PostgreSQL schemas for this? Well, schemas are like containers within your database. They group tables and other database objects together, keeping things neat and tidy. It’s perfect for separating tenant data without the overhead of creating entirely new databases for each customer.
Let’s roll up our sleeves and get coding. We’ll start by setting up our Rails application to work with PostgreSQL schemas. First, we need to add the ‘apartment’ gem to our Gemfile:
gem 'apartment'
Don’t forget to run bundle install
after adding the gem.
Next, we need to configure the apartment gem. Create a new file config/initializers/apartment.rb
and add the following:
Apartment.configure do |config|
config.excluded_models = ['User', 'Role']
config.tenant_names = lambda { Company.pluck(:subdomain) }
end
This configuration tells Apartment which models should be shared across all tenants (like User and Role) and how to determine the tenant names (in this case, we’re using Company subdomains).
Now, let’s create a middleware to set the correct tenant for each request. Create a new file app/middleware/tenant_middleware.rb
:
class TenantMiddleware
def initialize(app)
@app = app
end
def call(env)
request = Rack::Request.new(env)
tenant = request.subdomain
Apartment::Tenant.switch!(tenant) if tenant.present?
@app.call(env)
end
end
Add this middleware to your config/application.rb
:
config.middleware.use TenantMiddleware
Great! Now our app is set up to handle multi-tenancy. But how do we actually create and manage tenants? Let’s create a Company model to represent our tenants:
rails generate model Company name:string subdomain:string
And let’s add some validations to our Company model:
class Company < ApplicationRecord
validates :name, presence: true
validates :subdomain, presence: true, uniqueness: true
end
Now, whenever we create a new Company, we need to create a new tenant. Let’s add an after_create callback to our Company model:
class Company < ApplicationRecord
after_create :create_tenant
private
def create_tenant
Apartment::Tenant.create(subdomain)
end
end
This will automatically create a new PostgreSQL schema for each new Company.
But what about actually using this in our controllers? Here’s where it gets fun. Let’s say we have a Projects controller. We can now scope all our queries to the current tenant automatically:
class ProjectsController < ApplicationController
def index
@projects = Project.all
end
def create
@project = Project.new(project_params)
if @project.save
redirect_to @project, notice: 'Project created successfully!'
else
render :new
end
end
# Other actions...
end
Notice how we don’t need to do anything special here. The Project.all
query will automatically only return projects for the current tenant. Magic!
But what if we want to do something across all tenants? Apartment gives us a neat way to do this:
Apartment::Tenant.each do |tenant|
Apartment::Tenant.switch!(tenant) do
# Do something for each tenant
puts "Number of projects for #{tenant}: #{Project.count}"
end
end
This is super useful for things like data migrations or generating reports across all your customers.
Now, let’s talk about some gotchas and best practices. First, always be mindful of your shared tables. If you’re not careful, you might accidentally expose data across tenants. That’s why we excluded User and Role in our Apartment configuration earlier.
Second, be careful with your associations. If you have a shared model (like User) that has_many of a tenanted model (like Projects), you need to be extra careful. You might want to consider using a join model that’s tenant-specific.
Third, think about how you’ll handle things like background jobs. You’ll need to make sure your jobs know which tenant they’re operating on. One way to do this is to pass the tenant as a parameter to your job:
class ProjectCleanupJob < ApplicationJob
def perform(tenant)
Apartment::Tenant.switch!(tenant) do
Project.old.destroy_all
end
end
end
ProjectCleanupJob.perform_later(current_tenant)
Let’s talk about testing. When you’re testing a multi-tenant app, you need to make sure you’re in the right context. RSpec makes this easy with its use_transactional_fixtures
option:
RSpec.configure do |config|
config.use_transactional_fixtures = false
config.before(:suite) do
DatabaseCleaner.clean_with(:truncation)
end
config.before(:each) do |example|
Apartment::Tenant.switch!('test')
DatabaseCleaner.cleaning do
example.run
end
end
end
This ensures that each test runs in its own clean tenant.
Now, let’s discuss some advanced topics. What if you want to allow users to belong to multiple tenants? One way to handle this is to have a shared User model, but use a join table to associate users with companies:
class User < ApplicationRecord
has_many :memberships
has_many :companies, through: :memberships
end
class Company < ApplicationRecord
has_many :memberships
has_many :users, through: :memberships
end
class Membership < ApplicationRecord
belongs_to :user
belongs_to :company
end
With this setup, you can easily check if a user has access to a particular tenant:
def set_tenant
subdomain = request.subdomain
company = Company.find_by(subdomain: subdomain)
if current_user.companies.include?(company)
Apartment::Tenant.switch!(subdomain)
else
redirect_to root_path, alert: 'You do not have access to this tenant'
end
end
Another advanced topic is handling file uploads in a multi-tenant environment. If you’re using ActiveStorage, you might want to prefix your keys with the tenant name to keep files separated:
class Project < ApplicationRecord
has_one_attached :avatar
def avatar_key
"#{Apartment::Tenant.current}/#{super}"
end
end
This ensures that even if two tenants upload a file with the same name, they won’t overwrite each other.
Lastly, let’s talk about performance. As your app grows, you might start to see some slowdowns, especially if you have a lot of tenants. One way to mitigate this is to use connection pooling. The ‘makara’ gem works well with Apartment:
gem 'makara'
# In database.yml
production:
adapter: 'makara_postgresql'
makara:
sticky: true
connections:
- role: master
name: master
database: my_database
- role: slave
name: slave1
database: my_database
- role: slave
name: slave2
database: my_database
This setup allows you to distribute your read queries across multiple database servers, potentially giving you a significant performance boost.
In conclusion, implementing multi-tenancy in Rails using PostgreSQL schemas is a powerful way to build scalable SaaS applications. It provides a good balance of data isolation and resource sharing, allowing you to efficiently serve multiple customers from a single application instance. While it does add some complexity to your app, the benefits in terms of scalability and data security are well worth it.
Remember, every app is unique, and what works for one might not work for another. Always consider your specific requirements and constraints when implementing multi-tenancy. And most importantly, have fun! Building multi-tenant apps can be challenging, but it’s also incredibly rewarding. Happy coding!