/ Tags: RAILS / Categories: RAILS

Rails Concerns — When They Help, When They Hurt, and What to Use Instead

Concerns are one of Rails’ most polarizing features. On one hand, they’re built-in, well-supported, and solve the real problem of reusing code across models and controllers. On the other hand, a codebase full of concerns quickly becomes one where you can’t answer “where is this behavior defined?” without grepping. The truth is somewhere in between, and it hinges on a single question: does the extracted code represent a coherent concept that belongs together, or is it just code that happened to be similar?

What Concerns Are


A concern is a Ruby module that mixes into a class, enhanced with ActiveSupport::Concern to handle module dependency resolution and class-level method definitions cleanly.

Example:

# app/models/concerns/publishable.rb
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 unpublish!
    update!(published: false, published_at: nil)
  end

  def published?
    published && published_at.present?
  end
end

# app/models/article.rb
class Article < ApplicationRecord
  include Publishable
end

# app/models/post.rb
class Post < ApplicationRecord
  include Publishable
end

included do runs in the context of the including class — that’s where you put scope, validates, has_many, and before_action calls. Instance methods go directly in the module body.

When Concerns Are the Right Tool


Concerns earn their place when the extracted behavior is genuinely reusable across multiple classes and forms a coherent concept with a clear name.

Good concern characteristics:

  • The module name describes a capability: Searchable, Taggable, Publishable, Auditable, Sluggable
  • The behavior belongs together conceptually — not just “code that was in the same file”
  • Multiple models (or controllers) genuinely need the same behavior
  • The concern encapsulates a stable interface that doesn’t bleed into the host class’s structure

Example:

# app/models/concerns/sluggable.rb
module Sluggable
  extend ActiveSupport::Concern

  included do
    before_validation :generate_slug, if: -> { slug.blank? }
    validates :slug, presence: true, uniqueness: true, format: { with: /\A[a-z0-9-]+\z/ }
  end

  def to_param
    slug
  end

  private

  def generate_slug
    self.slug = title.to_s.parameterize
  end
end

# Works for any model with a title and slug column
class Article < ApplicationRecord
  include Sluggable
end

class Product < ApplicationRecord
  include Sluggable
end

Sluggable has a clear single purpose, a meaningful name, a defined interface (to_param, slug), and applies cleanly to any model with the right columns. This is what a concern should look like.

Controller Concerns


Controller concerns work identically and are useful for shared behavior across controllers: authentication helpers, pagination, response formatting.

Example:

# app/controllers/concerns/paginatable.rb
module Paginatable
  extend ActiveSupport::Concern

  private

  def paginate(scope)
    scope.page(params[:page]).per(params[:per_page] || 25)
  end

  def pagination_meta(scope)
    {
      current_page:  scope.current_page,
      total_pages:   scope.total_pages,
      total_count:   scope.total_count,
      per_page:      scope.limit_value
    }
  end
end

class Api::PostsController < ApplicationController
  include Paginatable

  def index
    @posts = paginate(Post.published)
    render json: { posts: @posts, meta: pagination_meta(@posts) }
  end
end

When Concerns Are the Wrong Tool


Concerns start causing problems when they’re used as a dumping ground for code that doesn’t belong together, or when they create behavior that’s too tightly coupled to the host class.

The “fat model, skinny concern” trap

The most common misuse: extracting groups of methods from a fat model into concerns to make the model file shorter, without actually separating responsibilities. The class is still doing too many things — the code is just in different files.

Example:

# Smell: concern that's really just model code moved elsewhere
module UserBillingMethods   # Not a capability — just a file split
  extend ActiveSupport::Concern

  def calculate_mrr; end
  def update_subscription; end
  def prorate_charge; end
  def cancel_subscription; end
end

This isn’t Billable — it’s billing logic for users that belongs in a service object or a dedicated class (UserBillingService), not a module mixed into User.

Concerns with too many dependencies on the host

When a concern reaches into the host class’s instance variables, calls methods it didn’t define, or has a long list of prerequisites, it’s a sign the abstraction doesn’t hold.

Example:

# Smell: concern that depends on host class internals
module AnalyticsMethods
  extend ActiveSupport::Concern

  def track_event
    # depends on @user, @request, current_subscription_tier...
    # If any of these aren't defined in the host, this silently does nothing
  end
end

What to Use Instead


Service Objects

When the concern represents a process or operation rather than a capability, a service object is cleaner:

# Instead of a concern, use a plain service object
class UserPublisher
  def initialize(user:, actor:)
    @user  = user
    @actor = actor
  end

  def call
    @user.update!(published: true, published_at: Time.current)
    AuditLog.create!(action: "publish", resource: @user, actor: @actor)
    PublishedNotificationJob.perform_later(@user.id)
  end
end

UserPublisher.new(user: @user, actor: current_user).call
Query Objects

When the concern is mainly scopes, a query object is more testable:

class PublishedPostsQuery
  def initialize(relation = Post.all)
    @relation = relation
  end

  def call
    @relation.where(published: true).where("published_at <= ?", Time.current).order(published_at: :desc)
  end
end

PublishedPostsQuery.new.call
PublishedPostsQuery.new(current_user.posts).call

Pro-Tip: Before extracting a concern, ask: “Can I give this a clear, noun-based name that describes what it makes a class able to do?” Publishable, Searchable, Auditable, Geocodable — these are concern names. UserHelpers, CommonMethods, SharedBehavior — these are file-splitting names, not abstractions. If you can’t name it with a clear capability word, you’re probably solving organization rather than design, and the code doesn’t belong in a concern.

Conclusion


Concerns are a good tool with a specific job: extracting genuinely reusable, cohesively named capabilities that multiple classes share. They become a liability when used to manage code volume in a single class, when they’re tightly coupled to their host class’s internals, or when the module name describes a collection of code rather than a concept. The question isn’t “should I use concerns?” — it’s “does this extraction produce a module I can name clearly and include in multiple places without modification?” When the answer is yes, concerns are the right choice.

FAQs


Q1: Should concerns go in app/models/concerns or app/concerns?
Rails convention puts model concerns in app/models/concerns and controller concerns in app/controllers/concerns. Both directories are autoloaded. Some teams prefer a single app/concerns directory for all concerns — either works, just be consistent.

Q2: Is there a performance cost to including many concerns?
Negligible. Ruby’s method lookup traverses the ancestor chain, but even with many included modules this is fast. The cost of concerns is design legibility, not runtime performance.

Q3: Can concerns include other concerns?
Yes. ActiveSupport::Concern handles dependency ordering — if concern A depends on concern B, including A in a class automatically includes B first. Use depends_on (or just include ModuleB inside the module) to declare this.

Q4: How do I test a concern in isolation?
Create a test class that includes the concern and test behavior on that class. let(:klass) { Class.new { include Publishable; attr_accessor :published, :published_at } } then test klass.new.published? etc. This verifies the concern’s behavior independently of any real model.

Q5: Should I use concerns or decorators for view-related model behavior?
For view-specific presentation logic — formatting currency, constructing display names, building label strings — a decorator (via Draper or a plain Ruby presenter) is cleaner than a concern. Concerns in models that include view-formatting methods create a separation-of-concerns violation. Keep model concerns focused on data behavior, not display.

cdrrazan

Rajan Bhattarai

Full Stack Software Developer! 💻 🏡 Grad. Student, MCS. 🎓 Class of '23. GitKraken Ambassador 🇳🇵 2021/22. Works with Ruby / Rails. Photography when no coding. Also tweets a lot at TW / @cdrrazan!

Read More