← Back to blog

Rails Concerns Done Right

· Lachlan Young

Few topics in Rails start arguments faster than concerns. One camp says they’re essential for organizing shared behavior. The other says they’re a code smell that hides complexity. Both are right, depending on how you use them.

The problem with most concern criticism is that it’s aimed at bad concerns, and bad concerns are everywhere. But the pattern itself is fine. The issue is discipline.

The concern that gives concerns a bad name

You’ve seen this. A model gets too big, so someone creates a concern and moves methods into it. Not related methods. Just… methods. Whatever makes the model file shorter.

# app/models/concerns/user_stuff.rb
module UserStuff
  extend ActiveSupport::Concern

  def full_name
    "#{first_name} #{last_name}"
  end

  def recent_orders
    orders.where("created_at > ?", 30.days.ago)
  end

  def avatar_url
    # ...
  end

  def send_password_reset
    # ...
  end

  def calculate_loyalty_points
    # ...
  end
end

This concern has no concept. It’s a drawer where unrelated methods go to make user.rb look smaller. The model is still just as complex, you’ve just spread it across two files. Now when you’re debugging, you have to check both places. You’ve made things worse.

One concern, one concept

A good concern represents a single, named behavior that could apply to multiple models. Think adjectives: Publishable, Searchable, Sluggable, Archivable, Taggable. If you can’t name the behavior with a single word, the concern is probably doing too much.

# app/models/concerns/publishable.rb
module Publishable
  extend ActiveSupport::Concern

  included do
    scope :published, -> { where.not(published_at: nil) }
    scope :draft, -> { where(published_at: nil) }
  end

  def published?
    published_at.present?
  end

  def publish!
    update!(published_at: Time.current)
  end

  def unpublish!
    update!(published_at: nil)
  end
end

This concern has a clear concept: publication status. It defines scopes, predicates, and actions that all relate to one thing. You can include it in Post, Page, Announcement, or any other model that has a published_at column. And when you see include Publishable at the top of a model, you immediately know what behavior it adds.

The ActiveSupport::Concern pattern

ActiveSupport::Concern gives you a few tools beyond plain modules. The included block runs in the context of the including class, which lets you declare scopes, validations, callbacks, and associations:

module Sluggable
  extend ActiveSupport::Concern

  included do
    before_validation :generate_slug, if: -> { slug.blank? }
    validates :slug, presence: true, uniqueness: true
  end

  def to_param
    slug
  end

  private

  def generate_slug
    self.slug = title.parameterize if respond_to?(:title)
  end
end

The class_methods block lets you add class-level methods:

module Searchable
  extend ActiveSupport::Concern

  class_methods do
    def search(query)
      where("#{searchable_column} ILIKE ?", "%#{query}%")
    end

    def searchable_column
      :name
    end
  end
end

Models can override searchable_column to customize which column gets searched, while inheriting the search method. This is a clean, predictable pattern.

When to use a concern vs. alternatives

Concerns are one of several tools for organizing code. Choosing the right one matters.

Use a concern when:

Use a service object when:

# This is a service object, not a concern
class OrderFulfillment
  def initialize(order)
    @order = order
  end

  def fulfill!
    charge_payment
    update_inventory
    send_confirmation
    @order.update!(status: "fulfilled")
  end
end

Use a plain module when:

The litmus tests

Before you create a concern, ask yourself three questions:

1. Can you name it with one word? If the name is UserHelpers or OrderMethods, it’s a dumping ground. If it’s Archivable or Trackable, it’s a concept.

2. Would this make sense on a different model? Good concerns are reusable by nature. Publishable works on posts, pages, and announcements. UserStuff only works on users, which means it’s not a concern, it’s just part of the user model.

3. Are all the methods related to each other? Every method in the concern should be part of the same concept. If you removed any one of them, the concept would feel incomplete. If you could remove a method and nobody would notice, it doesn’t belong.

Concerns and testing

One of the underappreciated benefits of well-structured concerns is testability. You can test a concern independently using a lightweight test class:

class PublishableTest < ActiveSupport::TestCase
  class TestModel < ApplicationRecord
    self.table_name = "posts"  # borrow an existing table
    include Publishable
  end

  test "publish! sets published_at" do
    record = TestModel.create!(title: "Test", published_at: nil)
    record.publish!
    assert record.published_at.present?
  end

  test "published scope excludes drafts" do
    TestModel.create!(title: "Draft", published_at: nil)
    TestModel.create!(title: "Live", published_at: 1.day.ago)

    assert_equal 1, TestModel.published.count
  end
end

When those tests involve comparing hashes, like checking that a serialized model matches expected output, the diffs can get noisy. That’s the kind of thing RubyHash handles well.

The real rule

Concerns aren’t good or bad. They’re a tool. Like any tool, they work well when applied to the right problem and poorly when forced onto the wrong one.

The right problem is: “I have a named behavior that several models share, and it needs access to ActiveRecord features.” If that’s your situation, write a concern. Name it clearly, keep it focused, test it independently.

If your concern file is named after a model instead of a behavior, or if it contains methods that have nothing to do with each other, you’ve gone wrong. Rename it, split it, or move the code back to the model where it belongs. A 300-line model is honest. A 300-line model that looks like 50 lines because the rest is hidden in three grab-bag concerns is just self-deception.

One concern, one concept. That’s the whole rule.


Testing concerns and your hash diffs are a mess? Try RubyHash, paste your expected and actual output for a clean, highlighted comparison.

Enjoyed this post?

Subscribe to get notified when we publish more Ruby and Rails content.