Ruby on Rails has become a popular framework for building multi-tenant Software as a Service (SaaS) applications. As a developer, I’ve found that implementing multi-tenancy in Rails requires careful planning and execution. In this article, I’ll share eight advanced techniques I’ve used to create robust and scalable multi-tenant SaaS applications.
Data Isolation Strategies
One of the most critical aspects of multi-tenant applications is ensuring proper data isolation between tenants. There are two main approaches to achieve this in Rails: schema-based and row-level multi-tenancy.
Schema-based multi-tenancy involves creating separate database schemas for each tenant. This approach provides strong isolation but can be more complex to manage. Here’s an example of how to implement schema-based multi-tenancy using the apartment gem:
# config/initializers/apartment.rb
Apartment.configure do |config|
config.excluded_models = ['User', 'Tenant']
config.tenant_names = -> { Tenant.pluck(:subdomain) }
end
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
before_action :set_tenant
private
def set_tenant
tenant = Tenant.find_by(subdomain: request.subdomain)
Apartment::Tenant.switch!(tenant.subdomain) if tenant
end
end
Row-level multi-tenancy, on the other hand, uses a single database schema with a tenant identifier column in each table. This approach is simpler to implement but requires careful query scoping. Here’s how to implement row-level multi-tenancy:
# app/models/application_record.rb
class ApplicationRecord < ActiveRecord::Base
self.abstract_class = true
belongs_to :tenant
default_scope { where(tenant: Tenant.current) }
end
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
before_action :set_tenant
private
def set_tenant
Tenant.current = Tenant.find_by(subdomain: request.subdomain)
end
end
Tenant Identification
Identifying the current tenant is crucial for proper data isolation and customization. I’ve found that using subdomains is an effective way to identify tenants. Here’s how to implement subdomain-based tenant identification:
# config/routes.rb
Rails.application.routes.draw do
constraints subdomain: /.*/ do
# Your routes here
end
end
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
before_action :set_tenant
private
def set_tenant
@current_tenant = Tenant.find_by!(subdomain: request.subdomain)
rescue ActiveRecord::RecordNotFound
render plain: 'Tenant not found', status: :not_found
end
end
This approach allows each tenant to have their own subdomain, making it easy to identify and switch between tenants.
Customization and Configuration
Multi-tenant applications often require tenant-specific configurations and customizations. I’ve found that storing tenant-specific settings in a separate table and using a flexible data structure like JSON can provide the necessary flexibility:
# db/migrate/YYYYMMDDHHMMSS_create_tenant_settings.rb
class CreateTenantSettings < ActiveRecord::Migration[6.1]
def change
create_table :tenant_settings do |t|
t.references :tenant, null: false, foreign_key: true
t.jsonb :settings, null: false, default: {}
t.timestamps
end
end
end
# app/models/tenant.rb
class Tenant < ApplicationRecord
has_one :settings, class_name: 'TenantSettings'
def get_setting(key)
settings.settings[key]
end
def set_setting(key, value)
settings.settings[key] = value
settings.save
end
end
This approach allows you to store and retrieve tenant-specific settings easily.
Shared Resources and Global Data
While data isolation is important, some resources and data may need to be shared across all tenants. I’ve found that using a separate database connection for shared data can be an effective solution:
# config/database.yml
shared:
adapter: postgresql
database: shared_db
username: shared_user
password: shared_password
# app/models/shared_record.rb
class SharedRecord < ActiveRecord::Base
self.abstract_class = true
establish_connection :shared
end
# app/models/global_setting.rb
class GlobalSetting < SharedRecord
# Shared model logic here
end
This setup allows you to access shared data without switching tenants or compromising data isolation.
Performance Optimization
Multi-tenant applications can face performance challenges due to the increased complexity of data access. I’ve found that implementing proper caching strategies and database indexing can significantly improve performance.
For caching, you can use Rails’ built-in caching mechanisms with tenant-specific cache keys:
# app/helpers/application_helper.rb
module ApplicationHelper
def cache_key_for(model, prefix = nil)
count = model.count
max_updated_at = model.maximum(:updated_at).try(:utc).try(:to_s, :number)
"#{Tenant.current.id}/#{prefix}#{model.model_name.cache_key}/#{count}-#{max_updated_at}"
end
end
# In your views
<% cache(cache_key_for(@products, 'list')) do %>
<%= render @products %>
<% end %>
For database indexing, make sure to include the tenant_id column in your indexes:
# db/migrate/YYYYMMDDHHMMSS_add_indexes_to_products.rb
class AddIndexesToProducts < ActiveRecord::Migration[6.1]
def change
add_index :products, [:tenant_id, :name]
add_index :products, [:tenant_id, :created_at]
end
end
Security Considerations
Security is paramount in multi-tenant applications. I always implement strict access controls and data validation to prevent unauthorized access between tenants. Here’s an example of how to implement a tenant-aware authorization system using Pundit:
# app/policies/application_policy.rb
class ApplicationPolicy
attr_reader :user, :record
def initialize(user, record)
@user = user
@record = record
end
def same_tenant?
user.tenant_id == record.tenant_id
end
class Scope
attr_reader :user, :scope
def initialize(user, scope)
@user = user
@scope = scope
end
def resolve
scope.where(tenant_id: user.tenant_id)
end
end
end
# app/policies/product_policy.rb
class ProductPolicy < ApplicationPolicy
def show?
same_tenant?
end
def update?
same_tenant?
end
# Other actions...
end
This setup ensures that users can only access and modify data within their own tenant.
Billing Integration
Integrating billing systems in multi-tenant SaaS applications can be complex. I’ve found that using a third-party service like Stripe, along with a flexible pricing model, works well. Here’s an example of how to integrate Stripe for tenant billing:
# Gemfile
gem 'stripe'
# config/initializers/stripe.rb
Stripe.api_key = ENV['STRIPE_SECRET_KEY']
# app/models/tenant.rb
class Tenant < ApplicationRecord
def create_stripe_customer
customer = Stripe::Customer.create(email: admin_email)
update(stripe_customer_id: customer.id)
end
def create_subscription(plan_id)
Stripe::Subscription.create(
customer: stripe_customer_id,
items: [{ plan: plan_id }]
)
end
end
# app/controllers/subscriptions_controller.rb
class SubscriptionsController < ApplicationController
def create
plan = Plan.find(params[:plan_id])
subscription = current_tenant.create_subscription(plan.stripe_id)
current_tenant.update(subscription_id: subscription.id)
redirect_to dashboard_path, notice: 'Subscription created successfully'
end
end
This setup allows you to manage subscriptions on a per-tenant basis, integrating seamlessly with Stripe’s billing system.
Testing Multi-Tenant Applications
Testing multi-tenant applications requires special considerations to ensure that data isolation and tenant-specific functionality work correctly. I’ve developed a testing strategy that includes setting up test tenants and switching between them in tests:
# spec/support/multi_tenant_context.rb
RSpec.shared_context 'multi-tenant' do
let(:tenant1) { create(:tenant, subdomain: 'tenant1') }
let(:tenant2) { create(:tenant, subdomain: 'tenant2') }
def switch_to_tenant(tenant)
Apartment::Tenant.switch!(tenant.subdomain)
end
def as_tenant(tenant)
original_tenant = Apartment::Tenant.current
switch_to_tenant(tenant)
yield
ensure
Apartment::Tenant.switch!(original_tenant)
end
end
# spec/models/product_spec.rb
RSpec.describe Product, type: :model do
include_context 'multi-tenant'
it 'scopes products to the current tenant' do
as_tenant(tenant1) do
create(:product, name: 'Product 1')
end
as_tenant(tenant2) do
create(:product, name: 'Product 2')
expect(Product.count).to eq(1)
expect(Product.first.name).to eq('Product 2')
end
end
end
This testing approach ensures that your multi-tenant functionality is working correctly across different tenants.
In conclusion, building multi-tenant SaaS applications with Ruby on Rails requires careful consideration of data isolation, tenant identification, customization, performance, security, and billing integration. By implementing these eight techniques, you can create robust, scalable, and secure multi-tenant applications that meet the needs of your customers.
Remember that each multi-tenant application may have unique requirements, so it’s essential to adapt these techniques to your specific use case. As you develop your application, continually assess its performance, security, and scalability to ensure it can grow with your business and meet the evolving needs of your tenants.