LaunchKit
← All posts
· 13 min read · by The LaunchKit team · 2 views

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.

One request to ReferralsController running down two callback chains side by side. On the left the gated_by block is prepended, so it runs first, ahead of capture_referral, CSRF, authentication and onboarding, and a switched off feature answers as if the route was never drawn. On the right a plain before_action would append the same block last, so authentication halts the chain first and the gate never runs.

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.

#rails #architecture

Comments

No comments yet. Be the first.

Only used to confirm and publish your comment. Never shown publicly, never shared.

Markdown: **bold**, `code`, ```fenced blocks```, > quotes, [links](url). HTML and images are not rendered.