Rails 8 Concerns: Patterns That Work

Rails concerns get a bad reputation. Developers either avoid them entirely or stuff every shared method into a concern, creating a tangled mess. The truth lies in the middle: concerns are powerful when used for specific patterns and problematic when used as a dumping ground.

What Concerns Actually Solve

Concerns address horizontal code sharing—behavior that cuts across multiple models or controllers without fitting into inheritance. Think of trackable behavior (who created/updated a record), searchable functionality, or soft deletion. These features don't define what a model is, but rather what it can do.

The key distinction: inheritance answers "what is this?" while concerns answer "what can this do?" A Post is a type of content, but it can be searchable, trackable, and soft-deletable.

Pattern 1: Behavior Modules with Callbacks

The most common use case wraps callbacks and instance methods into a reusable package. Consider tracking who creates and updates records across multiple models:

# app/models/concerns/trackable.rb
module Trackable
  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 if Current.user
  end
end

Apply this to any model that needs audit tracking:

# app/models/post.rb
class Post < ApplicationRecord
  include Trackable

  validates :title, presence: true
  validates :body, presence: true
end

# app/models/comment.rb
class Comment < ApplicationRecord
  include Trackable

  belongs_to :post
  validates :content, presence: true
end

This pattern works because the behavior is self-contained. The concern doesn't need to know anything about the including class beyond the existence of the required columns.

Pattern 2: Query Interface Extensions

Concerns excel at adding scopes and class methods that models can opt into. A searchable concern demonstrates this pattern:

# app/models/concerns/searchable.rb
module Searchable
  extend ActiveSupport::Concern

  class_methods do
    def search(query)
      return all if query.blank?

      columns = searchable_columns.map { |col| "#{table_name}.#{col}" }
      conditions = columns.map { |col| "#{col} LIKE :query" }.join(' OR ')

      where(conditions, query: "%#{sanitize_sql_like(query)}%")
    end

    def searchable_columns
      raise NotImplementedError, "#{name} must define searchable_columns"
    end
  end
end

# app/models/article.rb
class Article < ApplicationRecord
  include Searchable

  def self.searchable_columns
    %i[title summary body]
  end
end

# app/models/product.rb
class Product < ApplicationRecord
  include Searchable

  def self.searchable_columns
    %i[name description sku]
  end
end

The concern defines the interface, and each model specifies its searchable columns. This creates a contract: include the concern, implement the required method, and gain the functionality.

Pattern 3: Stateful Behavior with Validations

Complex state management benefits from concern extraction. Soft deletion with restoration capabilities makes a good example:

# app/models/concerns/soft_deletable.rb
module SoftDeletable
  extend ActiveSupport::Concern

  included do
    scope :kept, -> { where(deleted_at: nil) }
    scope :deleted, -> { where.not(deleted_at: nil) }

    default_scope { kept }
  end

  def soft_delete
    update(deleted_at: Time.current)
  end

  def restore
    update(deleted_at: nil)
  end

  def deleted?
    deleted_at.present?
  end

  class_methods do
    def with_deleted
      unscope(where: :deleted_at)
    end

    def only_deleted
      with_deleted.deleted
    end
  end
end

Usage in a controller shows the clean interface this provides:

# app/controllers/posts_controller.rb
class PostsController < ApplicationController
  def destroy
    @post = Post.find(params[:id])
    @post.soft_delete

    redirect_to posts_path, notice: 'Post moved to trash'
  end

  def restore
    @post = Post.with_deleted.find(params[:id])
    @post.restore

    redirect_to posts_path, notice: 'Post restored'
  end

  def trash
    @posts = Post.only_deleted
  end
end

Common Mistakes to Avoid

Several patterns indicate concern misuse. Watch for these warning signs:

  • Concerns that require specific columns without documenting them. Always add a comment listing required database columns or raise helpful errors when they're missing.
  • Concerns that include other concerns. This creates hidden dependencies and makes debugging difficult. Keep concerns flat and independent.
  • Concerns with more than 100 lines. Large concerns often indicate a missing abstraction. Consider whether a service object or separate class would work better.
  • Concerns used by only one class. If only one model uses it, the code belongs in that model. Extract to a concern when the second use case appears.

When to Skip Concerns Entirely

Some situations call for different solutions. Service objects handle complex business logic better than concerns. Form objects manage multi-model forms more cleanly. Query objects encapsulate complex database queries with better testability.

The decision framework: concerns work for declarative behavior ("this model has these capabilities"), while objects work for imperative processes ("perform this operation").

Testing Concerns in Isolation

Concerns deserve their own tests using anonymous classes:

# spec/models/concerns/trackable_spec.rb
RSpec.describe Trackable do
  let(:model_class) do
    Class.new(ApplicationRecord) do
      self.table_name = 'posts'
      include Trackable
    end
  end

  let(:user) { create(:user) }

  before { Current.user = user }
  after { Current.user = nil }

  describe 'on create' do
    it 'sets created_by to current user' do
      record = model_class.create!(title: 'Test', body: 'Content')
      expect(record.created_by).to eq(user)
    end
  end

  describe 'on update' do
    it 'sets updated_by to current user' do
      record = model_class.create!(title: 'Test', body: 'Content')
      record.update!(title: 'Updated')
      expect(record.updated_by).to eq(user)
    end
  end
end

Summary

Effective concerns share specific characteristics: they're self-contained, well-documented, and focused on a single responsibility. The three patterns covered—behavior modules, query extensions, and stateful behavior—represent the most maintainable uses of concerns in Rails applications.

Start with code in the model. Extract to a concern when a second model needs the same behavior. Keep concerns small, flat, and independent. Following these guidelines turns concerns from a source of confusion into a powerful tool for code organization.

10 claps
← Back to Blog