| name | multi-tenant-setup |
| description | Implements URL-based multi-tenancy with account scoping, membership patterns, and data isolation following 37signals patterns. Use when setting up multi-tenant architecture, account isolation, membership management, or when user mentions multi-tenancy, accounts, or tenant separation. WHEN NOT: For basic model setup without tenancy (use model-patterns), for auth/session setup (use auth-setup). |
| license | MIT |
| compatibility | Ruby 3.3+, Rails 8.0+ |
Multi-Tenant Setup
Philosophy: URL-Based Multi-Tenancy, Not Subdomain or Schema
- URL-based:
app.myapp.com/123/boards/456 (account_id in path)
account_id on every table (no foreign key constraints)
Current.account set from URL params for all requests
- All queries scoped through
Current.account
- UUIDs everywhere (prevents enumeration attacks)
- No default scopes (explicit scoping preferred)
- No Apartment gem, no subdomain routing, no schema separation
Project Knowledge
Stack: URL-based multi-tenancy (/accounts/:account_id/...), Current
attributes for account/user context, UUIDs for all primary keys, single
database with single schema.
Auth: Custom passwordless with Current.user, users can belong to multiple
accounts, account membership controls access.
Commands:
rails generate model Account name:string
rails generate model Membership user:references account:references role:integer
rails generate migration AddAccountToCards account:references
Pattern 1: Account Model and Memberships
See @references/membership-patterns.md for full details.
class Account < ApplicationRecord
has_many :memberships, dependent: :destroy
has_many :users, through: :memberships
has_many :boards, dependent: :destroy
has_many :cards, dependent: :destroy
validates :name, presence: true, length: { maximum: 100 }
def member?(user)
users.exists?(user.id)
end
def add_member(user, role: :member)
memberships.find_or_create_by!(user: user) do |membership|
membership.role = role
end
end
def owner
memberships.owner.first&.user
end
end
class Membership < ApplicationRecord
belongs_to :user
belongs_to :account
enum :role, { member: 0, admin: 1, }
validates , { }
validates ,
scope , -> { where( ) }
<
has_many ,
has_many ,
()
accounts.exists?(account.id)
()
memberships.find_by( account)&.role
()
memberships.find_by( account)&.admin? ||
memberships.find_by( account)&.owner?
Pattern 2: Current Attributes for Request Context
class Current < ActiveSupport::CurrentAttributes
attribute :user, :account, :membership
delegate :admin?, :owner?, to: :membership, allow_nil: true, prefix: true
def member?
membership.present?
end
def can_edit?(resource)
return false unless member?
return true if membership_admin? || membership_owner?
resource.respond_to?(:creator) && resource.creator == user
end
end
class ApplicationController < ActionController::Base
before_action :authenticate_user!
before_action :set_current_account
before_action :set_current_membership
before_action :ensure_account_member
private
def set_current_account
if params[:account_id]
Current.account = current_user.accounts.find(params[:account_id])
end
rescue
redirect_to accounts_path,
.account
.membership = current_user.memberships.find_by(
.account
)
.account
.member?
redirect_to accounts_path,
.membership_admin?
redirect_to account_path(.account),
Pattern 3: URL-Based Routing
Rails.application.routes.draw do
resource :session, only: [:new, :create, :destroy]
resources :accounts, only: [:index, :new, :create]
scope "/:account_id" do
resource :account, only: [:show, :edit, :update, :destroy]
resources :memberships, only: [:index, :create, :destroy]
resources :boards do
resources :cards do
resources :comments, only: [:create, :destroy]
resource :closure, only: [:create, :destroy]
end
end
resources :activities, only: [:index]
root "dashboards#show", as: :account_root
end
root "accounts#index"
end
Path helpers:
account_boards_path(@account)
account_board_path(@account, @board)
account_board_cards_path(@account, @board)
Pattern 4: Account-Scoped Models
See @references/account-scoping.md for full details.
module AccountScoped
extend ActiveSupport::Concern
included do
belongs_to :account
validates :account_id, presence: true
before_validation :set_account_from_current, on: :create
scope :for_account, ->(account) { where(account: account) }
end
private
def set_account_from_current
self.account ||= Current.account
end
end
class Board < ApplicationRecord
include AccountScoped
belongs_to :creator, class_name: "User"
has_many :cards, dependent: :destroy
validates :name, presence: true
end
class Card < ApplicationRecord
include AccountScoped
belongs_to :board
validate :account_matches_board
private
.account ||= board&.account || .account
board && account_id != board.account_id
errors.add(, )
Pattern 5: Account-Scoped Controllers
class BoardsController < ApplicationController
def index
@boards = Current.account.boards.includes(:creator).recent
end
def show
@board = Current.account.boards.find(params[:id])
end
def create
@board = Current.account.boards.build(board_params)
@board.creator = Current.user
if @board.save
redirect_to account_board_path(Current.account, @board)
else
render :new, status: :unprocessable_entity
end
end
private
def board_params
params.require(:board).permit(:name, :description)
end
end
class CardsController < ApplicationController
before_action :set_board
def create
@card = @board.cards.build(card_params)
@card.creator = .user
.account = .account
.save
redirect_to account_board_card_path(.account, , )
render ,
= .account.boards.find(params[])
Pattern 6: Account Switching
class AccountsController < ApplicationController
skip_before_action :set_current_account, only: [:index, :new, :create]
skip_before_action :ensure_account_member, only: [:index, :new, :create]
def index
@accounts = current_user.accounts.order(:name)
if @accounts.size == 1
redirect_to account_root_path(@accounts.first)
end
end
def create
@account = Account.new(account_params)
if @account.save
@account.add_member(current_user, role: :owner)
redirect_to account_root_path(@account)
else
render :new, status: :unprocessable_entity
end
end
end
Pattern 7: Data Isolation and Security
module AccountIsolation
extend ActiveSupport::Concern
included do
validate :validate_account_consistency, on: :create
end
private
def validate_account_consistency
self.class.reflect_on_all_associations(:belongs_to).each do |assoc|
next if assoc.name == :account
related = send(assoc.name)
next unless related
if related.respond_to?(:account_id) && related.account_id != account_id
errors.add(assoc.name, "must belong to the same account")
end
end
end
end
module AccountSecurity
extend ActiveSupport::Concern
included do
rescue_from ActiveRecord::RecordNotFound, with: :record_not_found
end
private
def record_not_found
redirect_to account_root_path(Current.account),
alert: "Resource not found"
Pattern 8: Account Migrations
class AddAccountToCards < ActiveRecord::Migration[8.0]
def change
add_reference :cards, :account, type: :uuid, null: true
reversible do |dir|
dir.up do
execute <<-SQL
UPDATE cards
SET account_id = boards.account_id
FROM boards
WHERE cards.board_id = boards.id
SQL
change_column_null :cards, :account_id, false
end
end
add_index :cards, [:account_id, :created_at]
add_index :cards, [:account_id, :board_id]
end
end
Boundaries
Always
- Use URL-based multi-tenancy (
/:account_id/...)
- Put
account_id on every table
- Scope all queries through
Current.account
- Validate account consistency across associations
- Use UUIDs for all primary keys
- Set
Current.account from URL in ApplicationController
- Double-scope nested resources (through account AND parent)
Ask First
- Whether to use account slugs vs numeric IDs in URLs
- Cross-account reference patterns (integrations, webhooks)
- Account creation flow and initial setup
Never
- Use subdomain-based multi-tenancy
- Use schema-based multi-tenancy (Apartment gem)
- Use
default_scope for account filtering
- Set
Current.account from user's default account (use URL)
- Skip
account_id on any table
- Allow cross-account data access without explicit authorization