Rails Concerns — When They Help and When They Hurt
by Eric Hanson, Backend Developer at Clean Systems Consulting
What a concern actually is
A Rails concern is a module that extends ActiveSupport::Concern. It gives you two conveniences over a plain Ruby module: an included block that runs in the context of the including class (so you can call has_many, validates, scope directly without qualifying them), and automatic handling of module dependencies via depends_on.
module Publishable
extend ActiveSupport::Concern
included do
scope :published, -> { where(published: true) }
scope :draft, -> { where(published: false) }
validates :published_at, presence: true, if: :published?
end
def publish!
update!(published: true, published_at: Time.current)
end
def published?
published == true
end
end
Including this in Article and Product gives both models the same publication behavior — scopes, validations, and instance methods — without duplication. This is the use case concerns were designed for.
The legitimate use cases
Cross-model behavior with a shared database interface. If two or more unrelated models share a concept that maps to real columns they both have, a concern is the right tool. Publishable above requires published (boolean) and published_at (timestamp) columns. Any model with those columns can include it and get correct behavior.
Other canonical examples: Taggable (requires a tags association), Sluggable (requires a slug column and generates it from a configured attribute), Auditable (timestamps and user tracking), SoftDeletable (requires deleted_at, overrides destroy).
The signal that a concern is appropriate: the behavior is defined by the database interface, not by the model's domain logic. Any model that has the right columns gets the right behavior automatically.
Controller concerns for cross-cutting request handling. Controller concerns work the same way. Authentication, pagination, rate limiting, and locale detection are genuinely cross-cutting — they apply to many controllers without being tied to any one resource:
module Paginatable
extend ActiveSupport::Concern
included do
before_action :set_pagination
end
private
def set_pagination
@page = (params[:page] || 1).to_i
@per_page = (params[:per_page] || 25).to_i.clamp(1, 100)
end
def paginate(relation)
relation.page(@page).per(@per_page)
end
end
Any controller that includes Paginatable gets consistent pagination behavior with no duplication. The concern tests independently without a full controller stack.
Where concerns go wrong
Decomposing a fat model with concerns. This is the most common misuse. A User model that's 600 lines gets split into User, User::Authentication, User::Notifications, User::Billing. The model includes all three. The class is now shorter, but all those methods are still in scope on User — you've reorganized the file system, not the architecture.
The test: does User still respond to every method those concerns define? Yes. Does the concern require knowledge of the user's internal state to work? Yes. Are the concerns usable by any other model? No. Then it's not a concern — it's a private module that happens to live in the concerns directory.
The tell is concern-to-model ratio. If a concern is only ever included in one model, it's not sharing behavior — it's just a fragment. Extract it to a service object or value object instead. Those have explicit interfaces and clear input/output contracts.
Implicit dependencies between concerns. When two concerns included in the same model both define before_save or both write to the same instance variable, the interaction is invisible from the model file. You see three include lines and have no idea what order the callbacks fire in or which concern wins the instance variable race:
class User < ApplicationRecord
include Trackable # defines before_save :set_tracking_data
include Auditable # defines before_save :log_changes
include Notifiable # reads @changed_attributes set by Auditable
end
Notifiable silently depends on Auditable having run first. The ordering is determined by include order, which is invisible at the point of use and fragile to change. This is the same problem that makes god-object callbacks hard to reason about — the concern just distributes the problem across files.
Concerns that bypass the model boundary. A concern that reaches into associated models, calls external services, or fires network requests is doing too much for a module. These behaviors need explicit dependency injection and a service object's single-call interface, not the implicit activation of a mixin:
# This does not belong in a concern
module Searchable
extend ActiveSupport::Concern
included do
after_save :sync_to_elasticsearch
end
private
def sync_to_elasticsearch
ElasticsearchClient.index(id: id, data: as_indexed_json)
end
end
When the Elasticsearch client is down, every save in every model that includes Searchable fails or swallows the error. The callback is invisible at the call site. The external dependency is buried in a module that looks like a simple mixin.
The included block gotcha
The included block runs in the context of the class — self is the including class. This means any method you call without qualification is called on the class. This is convenient but creates a scope that can confuse developers unfamiliar with the pattern:
module Cacheable
extend ActiveSupport::Concern
included do
# This is correct — validates is called on the including class
validates :cache_key, presence: true
# This is wrong — instance_variable_set here sets a class-level ivar
@cache_ttl = 300
end
def cached_value
# @cache_ttl here is an instance variable on the object, not the class-level one
Rails.cache.fetch(cache_key, expires_in: @cache_ttl) { compute_value }
end
end
Class-level state in the included block and instance-level state in instance methods are different things. The confusion surfaces when you need configuration per-including-class (Article caches for 5 minutes, Product caches for 1 hour). The correct approach uses a class-level accessor:
module Cacheable
extend ActiveSupport::Concern
included do
class_attribute :cache_ttl, default: 300
end
end
class Article < ApplicationRecord
include Cacheable
self.cache_ttl = 600
end
class_attribute (from ActiveSupport) creates an inheritable class-level attribute that each including class can override independently.
Testing concerns in isolation
A concern is testable without any specific model using an anonymous class:
RSpec.describe Publishable do
let(:model_class) do
Class.new do
include Publishable
attr_accessor :published, :published_at
def update!(attrs)
attrs.each { |k, v| send(:"#{k}=", v) }
end
end
end
subject(:instance) { model_class.new }
describe "#publish!" do
it "sets published to true" do
instance.publish!
expect(instance.published).to be true
end
it "sets published_at to the current time" do
freeze_time do
instance.publish!
expect(instance.published_at).to eq(Time.current)
end
end
end
end
The anonymous class provides the minimum interface the concern requires — no ActiveRecord, no database, no factory. If you can't test a concern this way because it requires too many methods from the including class, the concern is too tightly coupled to a specific model to be a genuine cross-cutting concern.
The decision
Use a concern when: the behavior is defined by a database interface that multiple unrelated models share, the concern is testable with an anonymous class, and you expect it to be included in at least two models.
Use a service object, value object, or plain module instead when: the behavior is only relevant to one model, it has external dependencies, or it requires intimate knowledge of the model's internal state.
The directory name matters less than the discipline. A project with five focused, genuinely shared concerns is in better shape than one with twenty concerns that are just fat-model fragments in disguise.