DEV Community

Syed Ghani
Syed Ghani

Posted on

I open-sourced a modern acts_as_tenant alternative for Rails 7+

---
title: "Introducing rails-tenantify: Row-Level Multi-Tenancy for Rails 7+"
published: true
description: "A modern, safe, and robust row-level multi-tenancy gem for Ruby on Rails. Prevent data leaks, protect bulk writes, and preserve tenant context in background jobs."
tags: rails, ruby, opensource, saas
---

## The Problem

Every multi-tenant SaaS app eventually needs to answer the same questions:

* How do we make sure School A never sees School B's data?
* How do we scope every query to the right organization?
* How do we keep tenant context alive in background jobs and Sidekiq retries?
* How do we stop a careless `update_all` from wiping another tenant's rows?

The typical answer is *"use acts_as_tenant"* or *"switch to Apartment."* But in modern Rails development, that often means:

* Fighting unmaintained APIs on Rails 7+
* Losing tenant context when a background job retries
* Dealing with schema-per-tenant complexity (Apartment) and heavy DevOps overhead
* Rolling your own `default_scope` and crossing your fingers that nobody calls `unscoped`

For most Rails apps, you just need **row-level tenancy**: one database, one `organization_id` column, and strict scoping. The pattern is simple. Getting it **safe** in production is not.

---

## What I Built

**`rails-tenantify`** is a Ruby gem that adds row-level multi-tenancy directly to your Rails models and controllers. No external services, no extra databases per tenant—just your own PostgreSQL (or SQLite in dev).

Enter fullscreen mode Exit fullscreen mode


ruby
class Project < ApplicationRecord
include Tenantify::Scoped

belongs_to_tenant :organization
end


### Set the tenant once per request

Enter fullscreen mode Exit fullscreen mode


ruby
class ApplicationController < ActionController::Base
set_tenant_by :subdomain # acme.yourapp.com → Organization
end


### Everything scopes automatically

Enter fullscreen mode Exit fullscreen mode


ruby
Tenantify.current_tenant = current_organization

Project.all # Only this org's projects
Project.create!(name: "Q2 Roadmap") # organization_id is set automatically


### Switch context safely for admins or scripts

Enter fullscreen mode Exit fullscreen mode


ruby

Temporarily switch context

Tenantify.switch_to(other_org) do
Project.count # Scoped to other_org
end

Intentional bypass

Tenantify.without_tenant do
Project.delete_all

end


---

## How it compares to `acts_as_tenant`

While `acts_as_tenant` pioneered this pattern, many teams hit walls on modern Rails—especially around background jobs, bulk SQL, and strict safety. `rails-tenantify` was built from the ground up for Rails 7+ with those gaps in mind.

| Feature | `acts_as_tenant` | `rails-tenantify` |
| --- | --- | --- |
| **Rails 7+ Focus** | Partial | **Yes** |
| **Subdomain Resolver** | DIY | `set_tenant_by :subdomain` |
| **API Header Resolver** | DIY | `set_tenant_by :header` |
| **Sidekiq Retry + Tenant** | Known issues | **Tenant ID preserved in job payload** |
| **`update_all` / `delete_all**` | Unreliable protection | **Raises unless explicitly scoped** |
| **Cross-Tenant Associations** | Manual checks | **Built-in validation** |
| **Unsafe Tenant Swap** | No audit | Options: `:log`, `:raise`, or `:ignore` |
| **Test Helpers** | Partial | Native `with_tenant` / `without_tenant` |

---

## How it compares to `Apartment`

`Apartment` uses separate schemas or databases per tenant. That offers strong isolation, but you pay a steep price in migrations, backups, and connection management.

`rails-tenantify` keeps one database and scopes via a foreign key—the exact tradeoff most B2B SaaS products actually want.

| Feature | `Apartment` | `rails-tenantify` |
| --- | --- | --- |
| **Isolation Model** | Schema / DB per tenant | Row-level Foreign Key |
| **Migrations** | Per-tenant complexity | Standard Rails migration path |
| **Ops Overhead** | Higher | **Lower** |
| **Best Fit For** | Strict regulatory separation | Typical B2B SaaS |

---

## Key Features

### 1. Multi-Resolution Strategies

Resolve your tenant out of the box via subdomains or API headers:

Enter fullscreen mode Exit fullscreen mode


ruby

Subdomain resolution (excludes common marketing/admin routes)

set_tenant_by :subdomain, exclude: %w[www admin]

API resolution for mobile or JSON clients

set_tenant_by :header, header: "X-Tenant-ID"


Or resolve manually based on the session:

Enter fullscreen mode Exit fullscreen mode


ruby
before_action do
Tenantify.current_tenant = current_user.organization if user_signed_in?
end


### 2. Bulletproof Background Jobs

The tenant automatically survives enqueueing and execution via standard **ActiveJob**:

Enter fullscreen mode Exit fullscreen mode


ruby
class ExportJob < ApplicationJob
def perform
Tenantify.current_tenant # Automatically set to the org that enqueued the job
Project.find_each { |p| p.update!(exported: true) }
end
end


*Note: If you use **Sidekiq**, the middleware registers automatically and injects the `tenant_id` straight into the job hash.*

### 3. Bulk-Write Protection

Avoid the ultimate multi-tenancy footgun: accidental database-wide updates.

Enter fullscreen mode Exit fullscreen mode


ruby
Project.update_all(status: "archived") # Raises an error if not explicitly scoped to current tenant
Project.unscoped.update_all(...) # Raises Tenantify::TenantMismatchError

Explicitly allowed bypass:

Tenantify.without_tenant do
Project.update_all(status: "migrated")

end


### 4. Cross-Tenant Association Checks

Prevent data leaking through relationships.

Enter fullscreen mode Exit fullscreen mode


ruby
task.project = project_from_other_org
task.valid? # => false (Errors: "belongs to a different tenant")


### 5. Override Auditing

Catch risky code that unexpectedly swaps tenants mid-request.

Enter fullscreen mode Exit fullscreen mode


ruby
Tenantify.configure { |c| c.audit_overrides = :raise }

Tenantify.current_tenant = org_a
Tenantify.current_tenant = org_b # => Raises Tenantify::TenantOverrideError


---

## Installation & Setup

Add the gem to your `Gemfile`:

Enter fullscreen mode Exit fullscreen mode


ruby
gem "rails-tenantify", "~> 0.1.2", require: "rails-tenantify"


Run `bundle install` and create an initializer at `config/initializers/tenantify.rb`:

Enter fullscreen mode Exit fullscreen mode


ruby
Tenantify.configure do |config|
config.tenant_model = "Organization"
config.on_tenant_not_found = :raise # Options: :raise | :redirect | :null_tenant
config.audit_overrides = :log # Options: :log | :raise | :ignore
end


Add the tenant foreign key to your tables:

Enter fullscreen mode Exit fullscreen mode


bash
rails g model Organization name:string subdomain:string:uniq
rails g migration AddOrganizationToProjects organization:references
rails db:migrate


> ⚠️ **Note on Naming:** The gem is published as `rails-tenantify` on RubyGems but required as `"rails-tenantify"`. (The old, legacy `tenantify` name on RubyGems belongs to an unrelated, abandoned library from 2016).

### Built-In Testing Helpers (RSpec Friendly)

Enter fullscreen mode Exit fullscreen mode


ruby

spec/rails_helper.rb

RSpec.configure { |c| c.include Tenantify::TestHelpers }

In your specs:

it "scopes creation automatically" do
with_tenant(org_a) do
exam = Exam.create!(title: "Midterm")
expect(exam.organization).to eq(org_a)
end
end


---

## Roadmap: Subdomains vs Custom Domains

* **Supported today:** Tenant subdomains on your primary application host (e.g., `greenwood.yourapp.com` → `Organization.find_by(subdomain: "greenwood")`).
* **Coming soon:** Full custom apex domains (e.g., `greenwood.edu` resolving via host-based lookups and custom DNS mapping).

---

## Why I Built This

I was building a multi-tenant Online Exam System in Rails—multiple schools, each with their own admins, teachers, exams, and students. I needed Greenwood School to absolutely *never* see Riverside School's data. Not in the UI, not in a background job, and definitely not via a copy-pasted `update_all` statement in a production console.

I didn't want the DevOps nightmare of managing a schema-per-school, and I didn't want to fork unmaintained tenancy gems. I wanted to run `bin/rails tenantify:verify` after my seeds ran and sleep soundly knowing data isolation was working perfectly.

So, I built `rails-tenantify`, integrated it into production, and open-sourced v0.1.2.

## Links & Feedback

* **GitHub:** [github.com/sghani001/rails-tenantify](https://www.google.com/search?q=https://github.com/sghani001/rails-tenantify)
* **RubyGems:** [rubygems.org/gems/rails-tenantify](https://www.google.com/search?q=https://rubygems.org/gems/rails-tenantify)

If you give it a try, I'd love to hear your thoughts! Open an issue, leave a comment below, or drop a ⭐ on the GitHub repository.

Enter fullscreen mode Exit fullscreen mode


plaintext


Enter fullscreen mode Exit fullscreen mode

Top comments (0)