class TenantResolver
RESERVED = %w[www app admin].freeze
def initialize(app)
@app = app
end
def call(env)
request = Rack::Request.new(env)
sub = subdomain_from(request.host)
if sub.blank? || RESERVED.include?(sub)
return not_found
end
tenant = Tenant.find_by(subdomain: sub)
return not_found unless tenant
begin
Tenant.current = tenant
@app.call(env)
ensure
Tenant.current = nil
end
end
private
def subdomain_from(host)
return if host.blank?
parts = host.split(".")
parts.length > 2 ? parts.first : nil
end
def not_found
[404, { "Content-Type" => "text/plain" }, ["Tenant not found"]]
end
end
class Current < ActiveSupport::CurrentAttributes
attribute :tenant
end
class Tenant < ApplicationRecord
has_many :invoices, dependent: :destroy
def self.current
Current.tenant
end
def self.current=(tenant)
Current.tenant = tenant
end
def self.with(tenant)
previous = Current.tenant
Current.tenant = tenant
yield
ensure
Current.tenant = previous
end
end
module TenantScoped
extend ActiveSupport::Concern
included do
belongs_to :tenant
default_scope do
require_tenant!
where(tenant_id: Tenant.current.id)
end
before_validation :assign_tenant, on: :create
end
class_methods do
def require_tenant!
return if Tenant.current
raise "No tenant in scope; wrap the call in Tenant.with(tenant) { ... }"
end
end
private
def assign_tenant
self.tenant_id ||= Tenant.current&.id
end
end
class Invoice < ApplicationRecord
include TenantScoped
scope :unpaid, -> { where(paid_at: nil) }
def self.overdue
unpaid.where("due_on < ?", Date.current)
end
end
# config/application.rb
module Billing
class Application < Rails::Application
config.middleware.insert_before Rack::Runtime, TenantResolver
end
end
This snippet shows a classic SaaS pattern: isolating tenants by subdomain so that every query is automatically scoped to the current account without threading a tenant_id through every call site. The work is split across a Rack middleware that resolves the tenant, a thread-local Current holder, and a model concern that reads that holder inside a default scope.
In TenantResolver middleware, the request is intercepted before it reaches Rails' router. The middleware pulls the host from the Rack env, extracts the leading subdomain via subdomain_from, and looks up the matching Tenant. Because middleware runs on the request-serving thread, the tenant is stashed in a thread-local through Tenant.current=, and crucially reset in an ensure block so a leaked value can never bleed into the next request served by that same pooled thread — the most dangerous bug in this whole pattern. Unknown or reserved subdomains (www, app) short-circuit with a 404 rather than silently serving another tenant's data.
Current tenant store wraps ActiveSupport::CurrentAttributes, which gives a per-request, per-thread store that Rails automatically clears between requests as a second line of defense. The Tenant class exposes current and with helpers; with is handy for background jobs or tests where no HTTP request established the context.
TenantScoped concern is where the payoff lands. Any model that includes it gains a default_scope that filters by Tenant.current.id, and a before_validation hook that stamps tenant_id on new records. This means ordinary calls like Invoice.all or Invoice.create! are transparently confined to the active tenant.
The trade-off worth understanding: default_scope is convenient but sticky — it applies to every relation including associations, so admin tooling that must cross tenants has to call unscoped explicitly. The require_tenant! guard raises when no tenant is set, turning a silent global query into a loud failure. This design keeps tenant logic in one place, but demands discipline around jobs, seeds, and any code path that runs outside a request.
Related snips
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
System test: asserting Turbo Stream responses
class Post < ApplicationRecord
belongs_to :author, class_name: 'User'
has_many :comments, dependent: :destroy
scope :published, -> { where.not(published_at: nil).where('published_at <= ?', Time.current) }
scope :draft, -> { where(published_at: nil) }
ActiveRecord scopes for reusable query logic
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
package dbutil
import (
"context"
"github.com/jackc/pgconn"
Retry Postgres serialization failures with bounded attempts
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
Share this code
Here's the card — post it anywhere.