Rails Concerns Done Right
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:
- The behavior is shared across multiple models
- It maps to a single, nameable concept
- It needs access to ActiveRecord features (scopes, callbacks, validations)
- It represents something the model is (Publishable, Taggable) rather than something the model does once
Use a service object when:
- The operation is complex and involves multiple models
- It represents a business process, not a model trait
- It has conditional logic that doesn’t belong in a callback
- You’d struggle to name it as an adjective
# 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:
- You need shared behavior but don’t need ActiveRecord integration
- The methods are pure utilities that don’t depend on model state
- You want explicit
includewithout theincludedblock magic
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.