rails-37-style-backend-multi-tenancy

star 0

Path-based tenancy, middleware, ActiveJob extensions

Chwistophe By Chwistophe schedule Updated 3/6/2026

name: rails-37-style-backend-multi-tenancy description: Path-based tenancy, middleware, ActiveJob extensions license: MIT

Multi-Tenancy Patterns

URL path-based multi-tenancy patterns from 37signals' Fizzy.


Path-Based Tenancy with Middleware (#283)

Extract tenant from URL paths and "mount" Rails at that prefix:

module AccountSlug
  PATTERN = /(\d{7,})/
  PATH_INFO_MATCH = /\A(\/#{AccountSlug::PATTERN})/

  class Extractor
    def initialize(app)
      @app = app
    end

    def call(env)
      request = ActionDispatch::Request.new(env)

      if request.path_info =~ PATH_INFO_MATCH
        # Move prefix from PATH_INFO to SCRIPT_NAME
        request.engine_script_name = request.script_name = $1
        request.path_info = $'.empty? ? "/" : $'
        env["fizzy.external_account_id"] = AccountSlug.decode($2)
      end

      if env["fizzy.external_account_id"]
        account = Account.find_by(external_account_id: env["fizzy.external_account_id"])
        Current.with_account(account) { @app.call(env) }
      else
        Current.without_account { @app.call(env) }
      end
    end
  end
end

# Insert middleware
Rails.application.config.middleware.insert_after Rack::TempfileReaper, AccountSlug::Extractor

Why path-based: No wildcard DNS/SSL, simpler local dev, no /etc/hosts hacking.

Current Context Pattern (#168, #279)

class Current < ActiveSupport::CurrentAttributes
  attribute :session, :user, :identity, :account

  def with_account(value, &)
    with(account: value, &)
  end

  def without_account(&)
    with(account: nil, &)
  end
end

ActiveJob Tenant Preservation (#168)

Automatically capture/restore tenant in background jobs:

module FizzyActiveJobExtensions
  extend ActiveSupport::Concern

  prepended do
    attr_reader :account
    self.enqueue_after_transaction_commit = true
  end

  def initialize(...)
    super
    @account = Current.account
  end

  def serialize
    super.merge({ "account" => @account&.to_gid })
  end

  def deserialize(job_data)
    super
    if _account = job_data.fetch("account", nil)
      @account = GlobalID::Locator.locate(_account)
    end
  end

  def perform_now
    if account.present?
      Current.with_account(account) { super }
    else
      super
    end
  end
end

ActiveSupport.on_load(:active_job) do
  prepend FizzyActiveJobExtensions
end

Uses GlobalID for serialization - works across all job backends.

Recurring Jobs: Iterate All Tenants (#279)

# Recurring jobs run outside request context
class AutoPopAllDueJob < ApplicationJob
  def perform
    ApplicationRecord.with_each_tenant do |tenant|
      Bubble.auto_pop_all_due
    end
  end
end

Easy to forget during multi-tenant migration.

Session Cookie Path Scoping (#879)

For simultaneous login to multiple tenants:

def set_current_session(session)
  cookies.signed.permanent[:session_token] = {
    value: session.signed_id,
    httponly: true,
    same_site: :lax,
    path: Account.sole.slug  # e.g., "/1234567"
  }
end

Without path scoping, cookies from one tenant clobber another.

Test Setup for Path-Based Tenancy (#879)

# test_helper.rb
Rails.application.config.active_record_tenanted.default_tenant =
  ActiveRecord::FixtureSet.identify(:'37s_fizzy')

class ActionDispatch::IntegrationTest
  setup do
    integration_session.default_url_options[:script_name] =
      "/#{ApplicationRecord.current_tenant}"
  end
end

class ActionDispatch::SystemTestCase
  setup do
    self.default_url_options[:script_name] =
      "/#{ApplicationRecord.current_tenant}"
  end
end

Always Scope Controller Lookups (#372)

Defense in depth - don't rely solely on middleware:

# Bad
def set_comment
  @comment = Comment.find(params[:comment_id])
end

# Good - scope through tenant
def set_comment
  @comment = Current.account.comments.find(params[:comment_id])
end

# Better - scope through user's accessible records
def set_bubble
  @bubble = Current.user.accessible_bubbles.find(params[:bubble_id])
end

Default Tenant for Dev Console (#168, #879)

# config/initializers/tenanting/default_tenant.rb
Rails.application.configure do
  if Rails.env.development?
    config.active_record_tenanted.default_tenant = "175932900"
  end
end

Makes console work ergonomic without constant tenant switching.

Solid Cache Multi-Tenant Config (#168, #279)

Avoid Rails' automatic shard swapping conflicts:

# config/cache.yml
# DON'T use database: key - causes shard swap issues

default_connection: &default_connection
  connects_to:
    database:
      writing: :cache

development:
  <<: *default_connection
  store_options:
    max_size: <%= 256.megabytes %>
    namespace: <%= Rails.env %>

Test Middleware in Isolation

def call_with_env(path, extra_env = {})
  captured = {}
  extra_env = { "action_dispatch.routes" => Rails.application.routes }.merge(extra_env)

  app = ->(env) do
    captured[:script_name] = env["SCRIPT_NAME"]
    captured[:path_info] = env["PATH_INFO"]
    captured[:current_account] = Current.account
    [ 200, {}, [ "ok" ] ]
  end

  middleware = AccountSlug::Extractor.new(app)
  middleware.call Rack::MockRequest.env_for(path, extra_env.merge(method: "GET"))

  captured
end

test "moves account prefix from PATH_INFO to SCRIPT_NAME" do
  account = accounts(:initech)
  slug = AccountSlug.encode(account.external_account_id)

  captured = call_with_env "/#{slug}/boards"

  assert_equal "/#{slug}", captured.fetch(:script_name)
  assert_equal "/boards", captured.fetch(:path_info)
  assert_equal account, captured.fetch(:current_account)
end

Architecture Decision

Fizzy settled on path-based tenancy with shared database (not database-per-tenant):

  • URL paths like /1234567/boards/123
  • Middleware sets Current.account
  • Models scoped via account_id foreign keys
  • Simpler than database-per-tenant while maintaining isolation
Install via CLI
npx skills add https://github.com/Chwistophe/agent-skills-unofficial-37-signals-rails-way-fizzy --skill rails-37-style-backend-multi-tenancy
Repository Details
star Stars 0
call_split Forks 0
navigation Branch main
article Path SKILL.md
More from Creator