Rails Concerns: When They Clean Up Code and When They Create Hidden Complexity
Rails concerns get a bad reputation they only half deserve. The ActiveSupport::Concern module ships with every Rails app, the generators put concerns/ directories in both app/models/ and app/controllers/, and yet experienced Rails developers regularly argue about whether they belong in production code at all.
The answer isn’t binary. Concerns solve specific problems well and create others when misused. After maintaining Rails applications from 20-model startups to 400-model enterprise codebases, I’ve landed on concrete rules for when concerns earn their place and when they need to be replaced with something else.
What a concern actually does
A concern is a Ruby module with some syntactic sugar from ActiveSupport::Concern. The sugar is minimal but meaningful:
# app/models/concerns/searchable.rb
module Searchable
extend ActiveSupport::Concern
included do
scope :search, ->(query) {
where("title ILIKE :q OR body ILIKE :q", q: "%#{query}%")
}
end
class_methods do
def most_searched_fields
%i[title body]
end
end
def search_summary
"#{title}: #{body.truncate(100)}"
end
end
Without ActiveSupport::Concern, you’d write this with self.included, def self.extended, and a nested ClassMethods module. The concern version reads cleaner, but the real benefit is the included block — it runs in the context of the including class, so scope, validates, has_many, and other class-level macros work naturally.
The other thing concerns handle is dependency resolution. If concern B depends on concern A:
module ConcernA
extend ActiveSupport::Concern
def method_from_a
"hello"
end
end
module ConcernB
extend ActiveSupport::Concern
include ConcernA
included do
# This works because ConcernA is resolved first
validates :name, presence: true
end
end
Without ActiveSupport::Concern, include order matters and you get cryptic errors when dependencies aren’t met. With it, Rails resolves the dependency graph for you.
When concerns work well
1. Shared behavior across unrelated models
The textbook case. You have Article, Comment, and Product models that all need soft-delete functionality:
# app/models/concerns/soft_deletable.rb
module SoftDeletable
extend ActiveSupport::Concern
included do
scope :kept, -> { where(deleted_at: nil) }
scope :discarded, -> { where.not(deleted_at: nil) }
default_scope { kept }
end
def discard
update_column(:deleted_at, Time.current)
end
def undiscard
update_column(:deleted_at, nil)
end
def discarded?
deleted_at.present?
end
end
This is a clean use because:
- The behavior is genuinely shared (not forced to fit)
- The concern is self-contained — it doesn’t reach into the host model’s internals
- Any model with a
deleted_atcolumn can include it and it just works - Testing is straightforward: test the concern once with a dummy model
2. Extracting framework integration boilerplate
When multiple models need the same ActiveRecord configuration for a gem or service:
# app/models/concerns/auditable.rb
module Auditable
extend ActiveSupport::Concern
included do
has_many :audit_logs, as: :auditable, dependent: :destroy
after_create { create_audit_log("created") }
after_update { create_audit_log("updated", previous_changes) }
after_destroy { create_audit_log("destroyed") }
end
private
def create_audit_log(action, changes = {})
audit_logs.create!(
action: action,
changed_fields: changes.keys,
user_id: Current.user&.id
)
end
end
The concern wraps a standard integration pattern. Each model that includes Auditable gets audit logging without repeating the callback setup.
3. Scoping query interfaces
When a model has many scopes related to a specific domain concept, grouping them in a concern keeps the model file readable:
# app/models/concerns/publishable.rb
module Publishable
extend ActiveSupport::Concern
included do
scope :published, -> { where.not(published_at: nil).where("published_at <= ?", Time.current) }
scope :draft, -> { where(published_at: nil) }
scope :scheduled, -> { where("published_at > ?", Time.current) }
scope :recent, -> { published.order(published_at: :desc) }
end
def published?
published_at.present? && published_at <= Time.current
end
def scheduled?
published_at.present? && published_at > Time.current
end
def publish!
update!(published_at: Time.current)
end
end
When concerns go wrong
1. The “god model in disguise” pattern
This is the most common misuse. A User model hits 500 lines, so someone splits it into concerns:
class User < ApplicationRecord
include Authenticatable
include Profileable
include Billable
include Notifiable
include Searchable
include Reportable
end
The model file is now 6 lines. The complexity didn’t decrease — it scattered across 6 files. Each concern likely depends on attributes and methods defined in other concerns or the model itself. You can’t understand Billable without also reading User, Authenticatable, and probably Notifiable.
This is horizontal decomposition: cutting a class into pieces along arbitrary lines. The number of lines per file goes down, but the cognitive load of understanding the system goes up because you now need to hold 7 files in your head instead of 1.
2. Concerns that depend on host model internals
# Bad: assumes host model structure
module Subscribable
extend ActiveSupport::Concern
included do
has_many :subscriptions
end
def active_subscription
subscriptions.find_by(
plan_id: company.current_plan_id, # assumes `company` association
status: :active
)
end
def can_access_feature?(feature)
return true if admin? # assumes `admin?` method
active_subscription&.plan&.features&.include?(feature)
end
end
This concern has invisible dependencies on company, admin?, and the structure of related models. It can’t be tested in isolation, can’t be reused, and changes to the User model can break it in non-obvious ways.
3. Callback chains that span concerns
When multiple concerns register callbacks, execution order becomes hard to predict:
class Order < ApplicationRecord
include Trackable # after_create: log event
include Notifiable # after_create: send email
include Inventoriable # after_create: decrement stock
end
Callbacks execute in include order, but that’s not obvious from reading any single file. When one callback fails and rolls back the transaction, debugging requires understanding all three concerns and their interaction. I’ve seen production bugs that took days to track down because a callback in one concern silently depended on side effects from a callback in another.
Alternatives when concerns aren’t the right tool
Service objects for business logic
When a concern is really hiding a business process, extract it to a service object:
# Instead of an OrderFulfillable concern:
class Orders::Fulfill
def initialize(order)
@order = order
end
def call
ActiveRecord::Base.transaction do
decrement_inventory
charge_payment
send_confirmation
update_status
end
end
private
attr_reader :order
def decrement_inventory
order.line_items.each do |item|
item.variant.decrement!(:stock_count, item.quantity)
end
end
# ...
end
The service object makes the process explicit. Dependencies are visible in the constructor. Testing is straightforward — no model setup needed beyond what the service actually uses. I covered service object patterns in detail in a previous post.
Plain Ruby modules for shared utilities
Not everything needs ActiveSupport::Concern. If you’re sharing utility methods that don’t need included blocks:
# app/lib/sluggable.rb
module Sluggable
def generate_slug(source)
source.parameterize.truncate(80, omission: "")
end
end
A plain module is simpler, has no framework dependency, and signals that it’s just behavior mixing — not model configuration.
Composition with delegation
For the “fat model split into concerns” problem, consider extracting a separate object:
class User < ApplicationRecord
def billing
@billing ||= UserBilling.new(self)
end
end
class UserBilling
def initialize(user)
@user = user
end
def current_plan
user.subscriptions.active.first&.plan
end
def can_access?(feature)
current_plan&.includes_feature?(feature)
end
private
attr_reader :user
end
Now user.billing.current_plan reads clearly, UserBilling is testable in isolation, and the dependency on User is explicit. This approach pairs well with the delegation patterns available in Ruby.
My rules for concerns in production code
After years of refactoring both toward and away from concerns, these rules hold up:
Use a concern when:
- The behavior is genuinely shared across 2+ unrelated models
- The concern is self-contained (no invisible dependencies on the host)
- It wraps framework integrations (callbacks, scopes, associations) that plain modules can’t handle
- You can test it with a dummy model and the tests pass without loading half your app
Use something else when:
- You’re splitting a single model to reduce file size (composition or service objects)
- The concern reaches into the host model’s associations or methods (service object)
- The behavior represents a business process with sequential steps (service object)
- The shared code doesn’t need ActiveRecord macros (plain module)
Debugging concern issues in Rails 8
When concern behavior gets confusing, Rails gives you tools to untangle it:
# See all ancestors (concerns appear in the chain)
User.ancestors
# => [User, Authenticatable, Searchable, ApplicationRecord, ...]
# Check where a method is defined
User.instance_method(:search_summary).source_location
# => ["/app/models/concerns/searchable.rb", 15]
# List all callbacks of a specific kind
User._create_callbacks.map { |cb| [cb.filter, cb.kind] }
The source_location trick is worth memorizing. When a model includes 5 concerns and you need to find where a method lives, this is faster than grep.
FAQ
How many concerns should a single model include?
There’s no hard limit, but if a model includes more than 3-4 concerns, that’s a code smell. It usually means the model has too many responsibilities and needs structural refactoring — not more file splitting. Each concern should represent a genuinely distinct capability (like Searchable or SoftDeletable), not a slice of the model’s core logic.
Should I put controller concerns in app/controllers/concerns?
Yes, but the same rules apply. Authentication checks, pagination setup, and API response formatting are good controller concern candidates. Business logic in controller concerns is a sign that the logic belongs in a service object or model instead. The custom Rack middleware approach is often better for cross-cutting HTTP concerns.
Can concerns replace gems like Devise or acts_as_paranoid?
Concerns can replicate small focused gems, but established gems bring tested edge-case handling, community maintenance, and documentation. Write a concern when the gem does more than you need (pulling in Devise for simple token auth) or when the behavior is specific to your domain. Use the gem when the problem is well-solved and you’d rather not maintain the code yourself.
Do concerns affect Rails autoloading or performance?
In Rails 8 with Zeitwerk, concerns in conventional directories (app/models/concerns/, app/controllers/concerns/) are autoloaded like any other Ruby file. There’s no performance penalty from using concerns versus inline code — Ruby’s method dispatch doesn’t care whether a method was defined directly or mixed in from a module. The included block runs once at class load time, not on every method call.
How do I test a concern without coupling to a specific model?
Create a temporary test model using an anonymous class:
RSpec.describe SoftDeletable do
let(:model_class) do
Class.new(ApplicationRecord) do
self.table_name = "articles" # any table with deleted_at
include SoftDeletable
end
end
it "soft deletes a record" do
record = model_class.create!(title: "Test", deleted_at: nil)
record.discard
expect(record.deleted_at).to be_present
end
end
This tests the concern in isolation. If it needs specific columns, use an existing table or create a temporary one in your test setup.
About the Author
Roger Heykoop is a senior Ruby on Rails developer with 19+ years of Rails experience and 35+ years in software development. He specializes in Rails modernization, performance optimization, and AI-assisted development.
Get in TouchRelated Articles
Need Expert Rails Development?
Let's discuss how we can help you build or modernize your Rails application with 19+ years of expertise
Schedule a Free Consultation