Back to Course

SRP Series · 6 of 7

Concerns

Where the pattern comes from, why it is the most controversial Rails pattern, and how Mastodon uses small focused concerns like Expireable to share behavior cleanly across models.

Where this rule comes from

Ruby has had modules since the language was born. A module is a bag of methods you can mix into a class with include. The capability is there at the language level; the question Rails had to answer was where the modules should live and how they should be organized.

Rails 4, released in 2013, added the app/models/concerns/ and app/controllers/concerns/ directories to the default app skeleton, plus the ActiveSupport::Concern helper to make modules with callbacks and class methods less painful to write. The move was partly a code-organization decision and partly an ideological one. DHH had been arguing that the "fat model" critique in the Rails community was solving the wrong problem. Instead of pulling logic out into services and forms and queries, he suggested pulling it out into concerns — modules that stay attached to the model but live in their own file.

That choice made concerns the most divisive pattern in Rails. One camp uses them everywhere, on the theory that a 1000-line model is fine as long as it is split across ten 100-line concerns. Another camp avoids them entirely, on the theory that a method mixed in from somewhere else is harder to find than a method called on a collaborator, and that the SRP failure of a fat model is not solved by chopping the model into modules that all share its state.

The honest answer, and the one the most experienced Rails codebases converge on, is somewhere in between. Concerns are the right tool when you have a small, focused capability that genuinely applies to multiple models and does not have its own state. They are the wrong tool when they are a "junk drawer" for model methods nobody knew where else to put. The rest of this lesson is about telling those two cases apart.

The anti-pattern

Picture a Rails app where the team learned about concerns and moved everything in. The User model file shrank from 800 lines to 50 lines. The 750 lines did not disappear, though. They moved into eight files in app/models/concerns/user/, with names like UserAuthentication, UserBilling, UserNotifications, UserSearch:

# app/models/user.rb
class User < ApplicationRecord
  include UserAuthentication
  include UserBilling
  include UserNotifications
  include UserSearch
  include UserExports
  include UserSocialGraph
  include UserAdmin
  include UserAvatar
end

# app/models/concerns/user_billing.rb
module UserBilling
  extend ActiveSupport::Concern

  included do
    has_many :charges
    has_many :subscriptions
    has_one :default_payment_method

    after_commit :sync_stripe_customer
    after_destroy :cancel_active_subscriptions
  end

  def charge!(amount_cents)
    # ... 60 lines of charging logic
  end

  def subscribed?
    subscriptions.active.exists?
  end

  # ... 40 more methods
end

The User model is still a 1000-line class. The lines are spread across eight files instead of one, but every concern can read and write every other concern's instance variables, call every other concern's methods, and participate in every other concern's callbacks. The coupling did not get smaller. It got harder to see, because now a method invoked on User might be defined in any of eight files.

Two specific failure modes show up in this code. First, the concerns are not reusable. UserBilling only applies to User. The name says so. There is no second model in the app that could include it. If the concern is single-use, putting it in a separate file is only a navigation hop with no abstraction win.

Second, the concerns have state coupling. UserBilling#charge! calls self.email from the User table. UserNotifications reads self.preferences which is set by UserAuthentication. The concerns share the model's mutable state implicitly, and any one of them can break any other one's assumptions about what that state contains.

How Mastodon uses concerns well

Mastodon has a few dozen concerns under app/models/concerns/, and they look nothing like the User-overload pattern above. Here is a real one:

# mastodon/app/models/concerns/expireable.rb · @bdad4f78
# License: AGPL-3.0

module Expireable
  extend ActiveSupport::Concern

  included do
    scope :expired, -> { where.not(expires_at: nil).where(expires_at: ...Time.now.utc) }

    def expires_in
      return @expires_in if defined?(@expires_in)
      return nil if expires_at.nil?

      (expires_at - created_at).to_i
    end

    def expires_in=(interval)
      self.expires_at = interval.present? ? interval.to_i.seconds.from_now : nil
      @expires_in     = interval
    end

    def expire!
      touch(:expires_at)
    end
  end
end

Read it carefully. Four design choices are doing the work:

The concern names a capability, not a domain. Expireable is a behavior: "this thing has an expires_at column and a notion of expiring." The name says nothing about which models include it. The capability is reusable — anywhere Mastodon has a record that can expire (invites, applications, app sessions, tokens), the same module is mixed in. One implementation, multiple subjects.

The concern is small and focused. Twenty lines. One scope, three methods, one shared assumption (the expires_at column). The whole module fits on a screen, and you can predict every other model's behavior around expiry by reading these twenty lines once.

The state the concern depends on is explicit. The concern reads expires_at and created_at. That is its entire interface to the host model. Any class that has those two columns can include Expireable. The concern does not silently depend on other concerns or on a sprawling model state.

The concern does not couple to other concerns. Expireable does not call methods defined in Lockable or Reviewable. It uses the model's columns and that is it. A teammate adding a new model that needs expiry behavior knows the only requirement is "have an expires_at column."

Using it is a single line:

class Invite < ApplicationRecord
  include Expireable
end

class UserInviteRequest < ApplicationRecord
  include Expireable
end

# Both now have:
#   .expired                   (scope)
#   invite.expires_in          (instance method)
#   invite.expires_in = 1.hour (instance method)
#   invite.expire!             (instance method)

The test that tells you a concern is earning its weight

There is a simple thought experiment for every concern in your codebase:

  1. Is the concern included in at least two models? If yes, the capability is genuinely shared and the concern is doing what concerns are for. If no, the concern is single-use and is probably only a navigation hop. Inline it back into the model or extract it to a service object.
  2. Can you describe the concern's name without using the word "stuff" or "logic"? "Expireable" passes. "UserStuff" or "AccountLogic" fails. A concern that needs vague words to describe what it does is a junk drawer.
  3. Does the concern depend on a small, explicit set of columns or methods? Expireable needs expires_at and created_at. That is a clear contract. A concern that depends on twenty methods and seven columns and three callbacks is too tangled to be reusable.

If all three checks pass, the concern is doing real work. If any one of them fails, the pattern is being misapplied.

Why this design holds up

Three benefits come out of using concerns correctly, each one solving a specific failure mode of the model-by-model duplicated version.

One implementation across many models. Mastodon has expiry behavior on at least five models. Without Expireable, the scope, the predicates, and the expire! method would be copy-pasted five times. A bug fix would need to land in five files. With Expireable, the fix lands in one file and applies everywhere.

The shared contract is discoverable. A new engineer looking at the Invite model sees include Expireable. They open the concern file, read twenty lines, and understand the expiry behavior across the whole codebase. The line of code is documentation as well as inclusion.

The host model stays focused. The Invite model file is short, because the expiry logic lives elsewhere. The lines in invite.rb are about what an invite specifically is, not about the generic capability of expiring. The model's main file is about the model's main purpose.

When you should not use a concern

Three failure modes to watch for:

  • The concern is included in exactly one model. The name probably gives it away (UserBilling, OrderShipping). If there is no second model that would include it, it is only code organization within the model, and a service object or a value object would carry the responsibility more honestly.
  • The concern has its own internal state. If the module has @instance_variables that other parts of the host class read or write, you are building a class disguised as a module. Make it a real class with its own initializer and have the model collaborate with it.
  • The concern is a workflow. If the concern is "do these eight things in order to charge a user," it is a service object pretending to be a mixin. Workflows do not belong as concerns; workflows belong in service objects, where they can be tested and called from anywhere.

One pragmatic rule: a good concern fits on a screen, names a capability not a domain, and is included by more than one model. If you find yourself writing a concern that fails any of those three checks, the right pattern is usually a service object or a value object, not a concern.

The principle at play

The Single Responsibility Principle is about giving each reason to change its own home. Concerns are an unusual tool in that family because they let multiple "homes" share a single physical location. Expiry is a responsibility that genuinely applies to five different models, and putting it in five separate files would create the opposite problem (duplication).

What makes a concern correct, then, is whether the capability it captures is genuinely cross-cutting. A capability is cross-cutting when the same logic applies to multiple subjects whose other behaviors have nothing in common. Expiry applies equally to invites, sessions, and tokens, even though invites, sessions, and tokens are otherwise unrelated. That is what concerns are for.

The failure mode is using concerns as a code-organization trick for a single model. The model's coupling does not change when its code is split across eight files inside the same directory. Concerns are an SRP tool for capabilities that span models, not for organizing a single model's internals. When in doubt, ask "would a second model include this?" If the answer is no, it is not a concern.

Practice exercise

  1. List the files under app/models/concerns/. For each one, grep app/models/ for include ConcernName and count how many models include it.
  2. Any concern included in exactly one model is suspect. Open it. Is the capability inherently model-specific (UserBilling, OrderShipping)? If yes, the concern should probably be inlined into the model or extracted into a service.
  3. For each concern that is included in multiple models, check that the contract is small. Grep the concern for self\\. calls. Each one is a method or column the concern assumes the host model has. If the list is short and named, the concern is well-defined. If it is long, the concern is a leaky abstraction.
  4. Bonus: look at the file size of your models. If a 1000-line model has been split into ten 100-line concerns, the model has not gotten simpler. The coupling is the same; only the navigation got worse. The honest fix is service objects, not concerns.

Related lessons