Rails 8 Current Attributes Guide

Every Rails application eventually needs to access the current user, tenant, or request metadata deep in the model layer. The typical solution—passing context through every method call—creates verbose, cluttered code. Rails solves this elegantly with CurrentAttributes, a thread-safe store for request-scoped globals.

The Problem with Passing Context

Consider a common scenario: tracking which user created or modified a record. Without CurrentAttributes, the implementation becomes awkward:

# app/models/post.rb
class Post < ApplicationRecord
  belongs_to :author, class_name: 'User'
  
  def self.create_with_author(attributes, author)
    create(attributes.merge(author: author))
  end
end

# app/services/post_creator.rb
class PostCreator
  def initialize(user)
    @user = user
  end
  
  def call(params)
    Post.create_with_author(params, @user)
  end
end

This pattern forces every service, job, and model method to accept and pass the current user. The code becomes noisy, and forgetting to pass context causes bugs.

Setting Up CurrentAttributes

Create a Current class that inherits from ActiveSupport::CurrentAttributes:

# app/models/current.rb
class Current < ActiveSupport::CurrentAttributes
  attribute :user, :request_id, :tenant
  
  resets { Time.zone = 'UTC' }
  
  def user=(user)
    super
    Time.zone = user&.time_zone || 'UTC'
  end
end

The attribute macro defines thread-local accessors. The resets callback runs when attributes clear between requests, ensuring clean state.

Set the current user in the application controller:

# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  before_action :set_current_attributes
  
  private
  
  def set_current_attributes
    Current.user = current_user
    Current.request_id = request.request_id
  end
  
  def current_user
    @current_user ||= User.find_by(id: session[:user_id])
  end
end

Now Current.user is accessible anywhere in the request cycle—models, services, mailers, and jobs.

Automatic Audit Trails

With Current available globally, implementing audit trails becomes trivial:

# app/models/concerns/auditable.rb
module Auditable
  extend ActiveSupport::Concern
  
  included do
    belongs_to :created_by, class_name: 'User', optional: true
    belongs_to :updated_by, class_name: 'User', optional: true
    
    before_create :set_created_by
    before_update :set_updated_by
  end
  
  private
  
  def set_created_by
    self.created_by ||= Current.user
  end
  
  def set_updated_by
    self.updated_by = Current.user
  end
end

# app/models/post.rb
class Post < ApplicationRecord
  include Auditable
  
  validates :title, presence: true
end

The migration to support this:

# db/migrate/20260217000001_add_audit_columns_to_posts.rb
class AddAuditColumnsToPosts < ActiveRecord::Migration[8.0]
  def change
    add_reference :posts, :created_by, foreign_key: { to_table: :users }
    add_reference :posts, :updated_by, foreign_key: { to_table: :users }
  end
end

Every post now automatically tracks who created and last modified it, with zero changes to existing code.

Multi-Tenancy Made Simple

Multi-tenant applications benefit enormously from CurrentAttributes. Set the tenant once and scope all queries automatically:

# app/models/current.rb
class Current < ActiveSupport::CurrentAttributes
  attribute :user, :tenant
  
  def tenant=(tenant)
    super
    ActsAsTenant.current_tenant = tenant if tenant
  end
end

# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  before_action :set_current_tenant
  
  private
  
  def set_current_tenant
    Current.tenant = Tenant.find_by!(subdomain: request.subdomain)
  rescue ActiveRecord::RecordNotFound
    render plain: 'Tenant not found', status: :not_found
  end
end

For applications not using the acts_as_tenant gem, implement scoping with a concern:

# app/models/concerns/tenant_scoped.rb
module TenantScoped
  extend ActiveSupport::Concern
  
  included do
    belongs_to :tenant
    
    default_scope -> { where(tenant: Current.tenant) if Current.tenant }
    
    before_validation :set_tenant, on: :create
  end
  
  private
  
  def set_tenant
    self.tenant ||= Current.tenant
  end
end

Background Jobs and Current Attributes

A critical gotcha: CurrentAttributes reset between requests and don't automatically propagate to background jobs. Serialize necessary context explicitly:

# app/jobs/application_job.rb
class ApplicationJob < ActiveJob::Base
  attr_accessor :current_user_id, :current_tenant_id
  
  before_perform do |job|
    Current.user = User.find_by(id: job.current_user_id)
    Current.tenant = Tenant.find_by(id: job.current_tenant_id)
  end
  
  def serialize
    super.merge(
      'current_user_id' => Current.user&.id,
      'current_tenant_id' => Current.tenant&.id
    )
  end
  
  def deserialize(job_data)
    super
    self.current_user_id = job_data['current_user_id']
    self.current_tenant_id = job_data['current_tenant_id']
  end
end

# app/jobs/send_notification_job.rb
class SendNotificationJob < ApplicationJob
  queue_as :default
  
  def perform(post_id)
    post = Post.find(post_id)
    # Current.user and Current.tenant are available here
    NotificationService.notify(post, triggered_by: Current.user)
  end
end

Now enqueuing a job captures the current context automatically.

Request Logging Enhancement

Improve log correlation by including request context:

# config/initializers/log_tags.rb
Rails.application.configure do
  config.log_tags = [
    :request_id,
    -> { Current.user&.id ? "user:#{Current.user.id}" : 'guest' },
    -> { Current.tenant&.subdomain || 'no-tenant' }
  ]
end

Log output now includes user and tenant context for every line:

[req-abc123] [user:42] [acme] Started GET "/posts" for 127.0.0.1
[req-abc123] [user:42] [acme] Processing by PostsController#index

Testing with Current Attributes

Tests require explicit setup of Current values:

# spec/support/current_attributes.rb
RSpec.configure do |config|
  config.before(:each) do
    Current.reset
  end
end

# spec/models/post_spec.rb
RSpec.describe Post, type: :model do
  let(:user) { create(:user) }
  let(:tenant) { create(:tenant) }
  
  before do
    Current.user = user
    Current.tenant = tenant
  end
  
  it 'automatically assigns created_by' do
    post = Post.create!(title: 'Test')
    expect(post.created_by).to eq(user)
  end
  
  it 'scopes to current tenant' do
    post = Post.create!(title: 'Scoped Post')
    expect(post.tenant).to eq(tenant)
  end
end

Common Mistakes to Avoid

Storing non-request data: Only store values that genuinely belong to the current request. Configuration or computed values belong elsewhere.

Heavy objects: Store IDs rather than full ActiveRecord objects when possible. This reduces memory usage and avoids stale data issues.

Forgetting job serialization: Background jobs run in separate threads without access to request context. Always serialize and restore necessary attributes.

Overusing default scopes: While convenient, default scopes with Current.tenant can cause issues in console sessions and rake tasks where Current isn't set. Consider using explicit scopes or the unscoped method when needed.

Summary

Rails CurrentAttributes provides a clean, thread-safe mechanism for request-scoped globals. The pattern shines for authentication context, multi-tenancy, audit trails, and request correlation. Key points to remember: set attributes early in the request cycle, explicitly propagate to background jobs, and reset in tests. Used judiciously, CurrentAttributes eliminates parameter drilling while maintaining clean, testable code.

10 claps
← Back to Blog