Rails concerns, and when not to write one
Rails concerns get argued about as an architecture question and the argument usually skips the
mechanics. ActiveSupport::Concern is 120 lines of activesupport and adds three specific things to
Module#include. Knowing which three is what turns "should this be a concern" from taste into a
question with an answer, because two of the three are worth a file and one of them is the reason
people end up with a directory of code nobody wanted to name.
What ActiveSupport::Concern adds to a plain module
Three things, and the source in activesupport 8.1.3.1 is short enough to hold in your head.
First, included do. The block is stashed, and append_features runs it with base.class_eval
once the module is genuinely being mixed into a class. Second, class_methods do, which builds or
reopens a nested ClassMethods module and arranges base.extend on it. Third, dependency
resolution, covered in the next section.
Here is the boilerplate's FeatureGated, entire, from
app/controllers/concerns/feature_gated.rb:
# Lets a controller declare the feature it belongs to. When that feature is switched off in the
# admin, the whole controller answers 404 - exactly as if the route did not exist. The check is
# prepended so it wins over authentication/onboarding: a disabled feature is "not found" even to
# anonymous callers, rather than bouncing them to a login page.
module FeatureGated
extend ActiveSupport::Concern
class_methods do
def gated_by(feature, **options)
prepend_before_action(**options) do
head :not_found if Feature.disabled?(feature)
end
end
end
end
Now the same thing with no ActiveSupport::Concern at all, which is what class_methods do is
saving you:
module FeatureGated
def self.included(base)
base.extend(ClassMethods)
end
module ClassMethods
def gated_by(feature, **options)
prepend_before_action(**options) do
head :not_found if Feature.disabled?(feature)
end
end
end
end
Two extra lines and one extra level of nesting. Not a crisis. The saving is real but small, and
anybody who tells you the concern is dramatically shorter is comparing against a version with an
included do block in it too, where the plain form also needs base.class_eval.
The dependency resolution that the two-line version cannot fake
Module dependencies are the part with no plain-Ruby workaround, and the API docs for Rails 8.1.3.1
spell out the failure before the fix. Take a module Foo that injects a class method, and a module
Bar that wants to call it:
module Foo
def self.included(base) = base.extend(ClassMethods)
module ClassMethods
def stamp = "from foo"
end
end
module Bar
include Foo
def self.included(base) = base.stamp
end
class HostA
include Bar
end
On Ruby 4.0.5 that raises NoMethodError: undefined method 'stamp' for class HostA. When Foo is
included into Bar, Foo's hook receives Bar as its base, so the class method lands on the
module and never reaches the host. The docs put it plainly: "With ActiveSupport::Concern, module
dependencies are properly resolved."
The trick is that a concern refuses to be mixed into another concern. Concern.extended sets
@_dependencies = [] on every concern, and append_features checks for that variable first:
def append_features(base)
if base.instance_variable_defined?(:@_dependencies)
base.instance_variable_get(:@_dependencies) << self
false
else
return false if base < self
@_dependencies.each { |dep| base.include(dep) }
super
base.extend const_get(:ClassMethods) if const_defined?(:ClassMethods)
base.class_eval(&@_included_block) if instance_variable_defined?(:@_included_block)
end
end
Including CFoo into CBar records it on a list and returns false. Only when something without
@_dependencies shows up, a real class, does the list get drained into it first. Rewrite the pair
above as concerns and HostB.ancestors comes back [HostB, CBar, CFoo, Object], with the
included block reading "from cfoo".
That same method carries the guard against double inclusion, one line: return false if base < self.
The difference is measurable. Three include Plain lines in one class fire a plain module's
self.included hook three times, while leaving exactly one copy in the ancestors. Do that with a
module whose hook calls before_action :require_authentication and the filter is in the chain
three times. A concern's included do block runs once, and the concern also refuses the sloppier
mistake outright: two included do blocks in one module raise
ActiveSupport::Concern::MultipleIncludedBlocks, "Cannot define multiple 'included' blocks for a
Concern".
Ordering is why FeatureGated is a concern
FeatureGated earns its file on the strength of one word in it: prepend_before_action. The
feature check has to beat every other gate in the application, because a disabled feature should be
indistinguishable from a route that was never drawn, including to a caller with no session.
prepend_before_action inserts at the front of the chain as it stands when the macro runs, so
calling gated_by in a subclass puts the block ahead of everything the parent registered.
ReferralsController is three lines of class body over gated_by :referrals, and its resolved
before-chain is:
["block", :capture_referral, :verify_authenticity_token, "block", "block",
:require_authentication, "block", :require_onboarding]
The leading "block" is the gate. Ahead of referral capture, ahead of CSRF, ahead of
authentication, ahead of onboarding. Api::V1::BaseController shows the same shape with the noise
removed: ["block", :authenticate_api_user!], so a switched-off JSON API answers 404 to a request
carrying no bearer token rather than 401.
None of that ordering can live in a service object, because the thing being configured is the
class's callback chain and the configuring happens at class definition time. A concern is the only
shape in Rails that hands a class a macro. Nine controllers in the boilerplate call gated_by, each
on one line, and the alternative is nine copies of a prepend_before_action block.
Concerns vs modules: the verdict
Use a plain module when the file contains only instance methods and constants. Use
ActiveSupport::Concern the moment the file has class-level work to do.
BotFiltered on this site is the test case. The file holds two constants and two private predicate
methods, bot_request? and prefetch_request?, and no included do, no class_methods do. It
still writes extend ActiveSupport::Concern, which buys it nothing today. That is not a bug, and
rewriting it would be churn: the line is there so that adding an included do later is a one line
change rather than a restructuring, and consistency across a directory has value of its own. It is
worth being honest that the line is a placeholder.
The cost of the default is that extend ActiveSupport::Concern reads as a signal and usually is
not one. A reader opening a concern expects a macro or a callback and finds two private methods. If
your team is small enough to hold a convention, plain module for method bags is the more
informative choice. If it is not, uniformity wins and you write the extend every time.
Concerns vs services: the verdict
A concern changes what an object is. A service object is something an object calls. Decide on whether the behaviour needs the host's own state.
Api::Auth::VerifyToken in the boilerplate is the service side, and the whole class is a
constructor, a call and a private reader:
def call
return if token.blank?
payload, = JWT.decode(token, Api::Auth.secret, true, algorithm: "HS256")
User.find_by(id: payload["sub"])
rescue JWT::DecodeError
nil
end
Nothing there wants to be a controller method. It takes a string, returns a user or nil, and
Api::V1::BaseController calls it in one line. A background job could call it tomorrow with no
change at all, which is the property a concern destroys the moment you reach for request or
Current.user inside it.
RequiresLiveSubscription is the other side, and it is a concern because it cannot be anything
else:
included do
before_action :require_live_subscription
end
private
def require_live_subscription
@subscription = Current.user.current_subscription
redirect_to(billing_path, alert: t("billing.subscription.flash.none")) unless @subscription
end
Assigning @subscription and calling redirect_to are both operations on the controller instance.
Extract them into a service and the service needs the controller passed in, at which point the
service is a concern with extra ceremony.
The verdict: if the behaviour could be called with plain arguments from a job, a rake task or a
console, make it a service. If it sets instance variables on the host, registers callbacks, or calls
redirect_to, make it a concern. The question "is this class getting too big" is not the question,
and answering it with a concern is how the drawer gets started.
Concerns vs helpers: the verdict
Helpers are not a general-purpose extraction point in Rails, whatever a directory named after a
verb suggests. Every helper module is mixed into the view context of every direct subclass of
ActionController::Base, which is what the inherited hook in
ActionController::Railties::Helpers does with klass.helper :all, guarded by
class_attribute :include_all_helpers, default: true. BlogHelper in the boilerplate builds a
schema.org JSON-LD <script> tag with tag.script. Calling that from a controller would mean
reaching into helpers. and getting a view-layer object back, which is a smell in both directions.
So the verdict needs no hedging: helper for markup, concern for behaviour, and never the other way around. A helper that neither renders a tag nor formats something for display is misfiled, and a concern that returns HTML has put view logic on the controller.
Worth knowing where that boundary runs, because "extract it to a helper" is the reflex answer for a view getting long and it is frequently the wrong one. View components vs partials is the same extraction question one layer up, and the reasoning there transfers: the thing that decides is whether the extracted piece has state of its own.
Where Rails puts concerns, and what that directory does not mean
The directory is a Rails convention with one piece of real machinery behind it, and one piece of
folklore. railties 8.1.3.1 registers the app path like this in Rails::Engine::Configuration:
paths.add "app", eager_load: true,
glob: "{*,*/concerns}",
exclude: ["assets", javascript_path]
{*,*/concerns} is the whole story. Any app/<anything>/concerns directory becomes an autoload
root, which means the path segment contributes no namespace. The Rails guide states the consequence:
"By default, app/models/concerns belongs to the autoload paths and therefore it is assumed to be a
root directory. So, by default, app/models/concerns/foo.rb should define Foo, not
Concerns::Foo."
So the naming convention is: name the module after what it does to the host, with no prefix and no
suffix that says "concern". Adjectives and participles read well against include, which is where
the module name is seen: FeatureGated, BotFiltered, SearchIndexable, RequiresLiveSubscription.
CanonicalOrigin on this site is a noun and reads worse for it.
And the folklore: concerns are not a controller feature or a model feature. rails new runs
keep_file "app/controllers/concerns" and keep_file "app/models/concerns", so those two exist in
every application, but the glob means app/jobs/concerns or app/mailers/concerns works the moment
you create it. Nor is the directory required. A concern is a module, and a module resolves from any
autoload root.
One more fact against the idea that concerns are a code smell: rails generate authentication, in
railties 8.1.3.1, runs template "app/controllers/concerns/authentication.rb" and then
inject_into_class "app/controllers/application_controller.rb", "ApplicationController", " include
Authentication\n". The framework's own scaffold for the most security-sensitive code in your
application ships as a concern.
The smell: methods the concern never declares
A concern is right when the behaviour genuinely belongs to the object. A concern is wrong when it is a drawer, and the reliable tell of a drawer is a module that calls methods the including class has to define and the module never mentions.
This site has one. TrafficSource is a fine concern by every other measure, two private methods
answering "did this reader come from Google", and here is the body of it:
def referring_domain
visit_domain = current_visit&.referring_domain
return visit_domain if visit_domain.present?
URI.parse(request.referer.to_s).host
rescue URI::InvalidURIError
nil
end
current_visit is not defined in that file and not defined anywhere in the application.
ahoy_matey 5.5.0 supplies it, from Ahoy::Controller, through one line at the bottom of ahoy.rb:
ActiveSupport.on_load(:action_controller) do
include Ahoy::Controller
end
So the concern works, invisibly, because a gem four levels away reached into the base class. Move
TrafficSource into a mailer, a job or a plain object to reuse the Google test somewhere other than
a controller and the first call raises NoMethodError, with a backtrace pointing at a file that
never mentions ahoy.
One undeclared dependency on a gem that is in every controller anyway is survivable. Three is the
pattern worth naming. A module whose methods call current_account, current_plan and notify!,
none of which it defines, is not a mixin, it is a fragment of a class that was cut in half along a
line nobody chose deliberately. The give-away is that you cannot answer "what does a class need in
order to include this" without opening the including class.
The cheap fix is a comment naming the contract, which costs a line and is what
RequiresLiveSubscription does with Current.user. The real fix is usually that the three methods
and the behaviour that needs them belonged in the same object all along.
The position, and what would change it
Write the concern when the behaviour is a property of the object: something it is, something it
does to every request that reaches it, or a macro its subclasses call. FeatureGated is all three
at once, which is why eleven lines of code buy nine controllers a one line declaration. Refuse the
concern when
the motivation is that a class got long, because length is not a seam and extracting along a
non-seam gives you two files that have to be read together.
The cost of that position, stated honestly: it produces fat controllers and fat models in the places where the behaviour really does belong to the object, and sometimes a 300 line model is the correct model and looks like a failure in review.
What would change it: a concern extracted for length that turns out to be reusable is a win however it was motivated, and if your codebase keeps producing those, the length heuristic is working better than the argument above says it should. What would not change it is a style guide, a line count cop, or the shape of the directory listing.
What this post does not cover
Model concerns, which is the argument people actually mean when they say concerns are bad, because
a model concern can define scopes, validations and associations and therefore hides far more than a
controller concern can. Neither repository behind this post has a single file in
app/models/concerns, so there is nothing here to read out and quoting somebody else's would be a
different article.
Also absent: prepended do and prepend_features, which mirror the included path and matter for
overriding a method the host already defines; ActiveSupport::Concern::MultiplePrependBlocks;
testing concerns in isolation with an anonymous class; and the performance of a deep ancestor chain,
which is real and, at the ten to twenty modules a Rails class actually carries, not worth measuring.
What FeatureGated gates and how the 404 behaves for a logged-in admin is a separate piece of the
boilerplate's documentation, not a repeat of this one.
Comments
No comments yet. Be the first.