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.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

Why an Ideal Engineering Team Needs More Than Just Full-Stack Developers

Hiring a few “full-stack developers” sounds like the efficient choice. But relying on them alone often creates hidden gaps that slow everything down.

Read more

Choosing Clients That Will Respect Your Time

Some clients make you feel productive. Others make you feel constantly behind. The difference often isn’t the work—it’s how they treat your time.

Read more

Handling Clients Who Think You’re a 24/7 Developer

It starts with a “quick message” at night. Then suddenly, you’re expected to reply at 2 AM like it’s normal.

Read more

Lessons From Failed Software Projects

Failure stings, but in software, it’s often a faster teacher than success. By analyzing what went wrong, teams can avoid repeating mistakes and build smarter, more resilient projects.

Read more