35+ Years Experience Netherlands Based ⚡ Fast Response Times Ruby on Rails Experts AI-Powered Development Fixed Pricing Available Senior Architects Dutch & English 35+ Years Experience Netherlands Based ⚡ Fast Response Times Ruby on Rails Experts AI-Powered Development Fixed Pricing Available Senior Architects Dutch & English
Rails Concerns: When They Clean Up Code and When They Create Hidden Complexity

Rails Concerns: When They Clean Up Code and When They Create Hidden Complexity

Roger Heykoop
Ruby on Rails
A practical guide to Rails ActiveSupport::Concern with real examples of good and bad uses. Learn when concerns reduce duplication, when they create tangled dependencies, and what alternatives work better for each case.

Rails concerns get a bad reputation they only half deserve. The ActiveSupport::Concern module ships with every Rails app, the generators put concerns/ directories in both app/models/ and app/controllers/, and yet experienced Rails developers regularly argue about whether they belong in production code at all.

The answer isn’t binary. Concerns solve specific problems well and create others when misused. After maintaining Rails applications from 20-model startups to 400-model enterprise codebases, I’ve landed on concrete rules for when concerns earn their place and when they need to be replaced with something else.

What a concern actually does

A concern is a Ruby module with some syntactic sugar from ActiveSupport::Concern. The sugar is minimal but meaningful:

# app/models/concerns/searchable.rb
module Searchable
  extend ActiveSupport::Concern

  included do
    scope :search, ->(query) {
      where("title ILIKE :q OR body ILIKE :q", q: "%#{query}%")
    }
  end

  class_methods do
    def most_searched_fields
      %i[title body]
    end
  end

  def search_summary
    "#{title}: #{body.truncate(100)}"
  end
end

Without ActiveSupport::Concern, you’d write this with self.included, def self.extended, and a nested ClassMethods module. The concern version reads cleaner, but the real benefit is the included block — it runs in the context of the including class, so scope, validates, has_many, and other class-level macros work naturally.

The other thing concerns handle is dependency resolution. If concern B depends on concern A:

module ConcernA
  extend ActiveSupport::Concern

  def method_from_a
    "hello"
  end
end

module ConcernB
  extend ActiveSupport::Concern
  include ConcernA

  included do
    # This works because ConcernA is resolved first
    validates :name, presence: true
  end
end

Without ActiveSupport::Concern, include order matters and you get cryptic errors when dependencies aren’t met. With it, Rails resolves the dependency graph for you.

When concerns work well

1. Shared behavior across unrelated models

The textbook case. You have Article, Comment, and Product models that all need soft-delete functionality:

# app/models/concerns/soft_deletable.rb
module SoftDeletable
  extend ActiveSupport::Concern

  included do
    scope :kept, -> { where(deleted_at: nil) }
    scope :discarded, -> { where.not(deleted_at: nil) }

    default_scope { kept }
  end

  def discard
    update_column(:deleted_at, Time.current)
  end

  def undiscard
    update_column(:deleted_at, nil)
  end

  def discarded?
    deleted_at.present?
  end
end

This is a clean use because:

  • The behavior is genuinely shared (not forced to fit)
  • The concern is self-contained — it doesn’t reach into the host model’s internals
  • Any model with a deleted_at column can include it and it just works
  • Testing is straightforward: test the concern once with a dummy model

2. Extracting framework integration boilerplate

When multiple models need the same ActiveRecord configuration for a gem or service:

# app/models/concerns/auditable.rb
module Auditable
  extend ActiveSupport::Concern

  included do
    has_many :audit_logs, as: :auditable, dependent: :destroy

    after_create  { create_audit_log("created") }
    after_update  { create_audit_log("updated", previous_changes) }
    after_destroy { create_audit_log("destroyed") }
  end

  private

  def create_audit_log(action, changes = {})
    audit_logs.create!(
      action: action,
      changed_fields: changes.keys,
      user_id: Current.user&.id
    )
  end
end

The concern wraps a standard integration pattern. Each model that includes Auditable gets audit logging without repeating the callback setup.

3. Scoping query interfaces

When a model has many scopes related to a specific domain concept, grouping them in a concern keeps the model file readable:

# app/models/concerns/publishable.rb
module Publishable
  extend ActiveSupport::Concern

  included do
    scope :published, -> { where.not(published_at: nil).where("published_at <= ?", Time.current) }
    scope :draft, -> { where(published_at: nil) }
    scope :scheduled, -> { where("published_at > ?", Time.current) }
    scope :recent, -> { published.order(published_at: :desc) }
  end

  def published?
    published_at.present? && published_at <= Time.current
  end

  def scheduled?
    published_at.present? && published_at > Time.current
  end

  def publish!
    update!(published_at: Time.current)
  end
end

When concerns go wrong

1. The “god model in disguise” pattern

This is the most common misuse. A User model hits 500 lines, so someone splits it into concerns:

class User < ApplicationRecord
  include Authenticatable
  include Profileable
  include Billable
  include Notifiable
  include Searchable
  include Reportable
end

The model file is now 6 lines. The complexity didn’t decrease — it scattered across 6 files. Each concern likely depends on attributes and methods defined in other concerns or the model itself. You can’t understand Billable without also reading User, Authenticatable, and probably Notifiable.

This is horizontal decomposition: cutting a class into pieces along arbitrary lines. The number of lines per file goes down, but the cognitive load of understanding the system goes up because you now need to hold 7 files in your head instead of 1.

2. Concerns that depend on host model internals

# Bad: assumes host model structure
module Subscribable
  extend ActiveSupport::Concern

  included do
    has_many :subscriptions
  end

  def active_subscription
    subscriptions.find_by(
      plan_id: company.current_plan_id,  # assumes `company` association
      status: :active
    )
  end

  def can_access_feature?(feature)
    return true if admin?  # assumes `admin?` method
    active_subscription&.plan&.features&.include?(feature)
  end
end

This concern has invisible dependencies on company, admin?, and the structure of related models. It can’t be tested in isolation, can’t be reused, and changes to the User model can break it in non-obvious ways.

3. Callback chains that span concerns

When multiple concerns register callbacks, execution order becomes hard to predict:

class Order < ApplicationRecord
  include Trackable      # after_create: log event
  include Notifiable     # after_create: send email
  include Inventoriable  # after_create: decrement stock
end

Callbacks execute in include order, but that’s not obvious from reading any single file. When one callback fails and rolls back the transaction, debugging requires understanding all three concerns and their interaction. I’ve seen production bugs that took days to track down because a callback in one concern silently depended on side effects from a callback in another.

Alternatives when concerns aren’t the right tool

Service objects for business logic

When a concern is really hiding a business process, extract it to a service object:

# Instead of an OrderFulfillable concern:
class Orders::Fulfill
  def initialize(order)
    @order = order
  end

  def call
    ActiveRecord::Base.transaction do
      decrement_inventory
      charge_payment
      send_confirmation
      update_status
    end
  end

  private

  attr_reader :order

  def decrement_inventory
    order.line_items.each do |item|
      item.variant.decrement!(:stock_count, item.quantity)
    end
  end

  # ...
end

The service object makes the process explicit. Dependencies are visible in the constructor. Testing is straightforward — no model setup needed beyond what the service actually uses. I covered service object patterns in detail in a previous post.

Plain Ruby modules for shared utilities

Not everything needs ActiveSupport::Concern. If you’re sharing utility methods that don’t need included blocks:

# app/lib/sluggable.rb
module Sluggable
  def generate_slug(source)
    source.parameterize.truncate(80, omission: "")
  end
end

A plain module is simpler, has no framework dependency, and signals that it’s just behavior mixing — not model configuration.

Composition with delegation

For the “fat model split into concerns” problem, consider extracting a separate object:

class User < ApplicationRecord
  def billing
    @billing ||= UserBilling.new(self)
  end
end

class UserBilling
  def initialize(user)
    @user = user
  end

  def current_plan
    user.subscriptions.active.first&.plan
  end

  def can_access?(feature)
    current_plan&.includes_feature?(feature)
  end

  private

  attr_reader :user
end

Now user.billing.current_plan reads clearly, UserBilling is testable in isolation, and the dependency on User is explicit. This approach pairs well with the delegation patterns available in Ruby.

My rules for concerns in production code

After years of refactoring both toward and away from concerns, these rules hold up:

Use a concern when:

  • The behavior is genuinely shared across 2+ unrelated models
  • The concern is self-contained (no invisible dependencies on the host)
  • It wraps framework integrations (callbacks, scopes, associations) that plain modules can’t handle
  • You can test it with a dummy model and the tests pass without loading half your app

Use something else when:

  • You’re splitting a single model to reduce file size (composition or service objects)
  • The concern reaches into the host model’s associations or methods (service object)
  • The behavior represents a business process with sequential steps (service object)
  • The shared code doesn’t need ActiveRecord macros (plain module)

Debugging concern issues in Rails 8

When concern behavior gets confusing, Rails gives you tools to untangle it:

# See all ancestors (concerns appear in the chain)
User.ancestors
# => [User, Authenticatable, Searchable, ApplicationRecord, ...]

# Check where a method is defined
User.instance_method(:search_summary).source_location
# => ["/app/models/concerns/searchable.rb", 15]

# List all callbacks of a specific kind
User._create_callbacks.map { |cb| [cb.filter, cb.kind] }

The source_location trick is worth memorizing. When a model includes 5 concerns and you need to find where a method lives, this is faster than grep.

FAQ

How many concerns should a single model include?

There’s no hard limit, but if a model includes more than 3-4 concerns, that’s a code smell. It usually means the model has too many responsibilities and needs structural refactoring — not more file splitting. Each concern should represent a genuinely distinct capability (like Searchable or SoftDeletable), not a slice of the model’s core logic.

Should I put controller concerns in app/controllers/concerns?

Yes, but the same rules apply. Authentication checks, pagination setup, and API response formatting are good controller concern candidates. Business logic in controller concerns is a sign that the logic belongs in a service object or model instead. The custom Rack middleware approach is often better for cross-cutting HTTP concerns.

Can concerns replace gems like Devise or acts_as_paranoid?

Concerns can replicate small focused gems, but established gems bring tested edge-case handling, community maintenance, and documentation. Write a concern when the gem does more than you need (pulling in Devise for simple token auth) or when the behavior is specific to your domain. Use the gem when the problem is well-solved and you’d rather not maintain the code yourself.

Do concerns affect Rails autoloading or performance?

In Rails 8 with Zeitwerk, concerns in conventional directories (app/models/concerns/, app/controllers/concerns/) are autoloaded like any other Ruby file. There’s no performance penalty from using concerns versus inline code — Ruby’s method dispatch doesn’t care whether a method was defined directly or mixed in from a module. The included block runs once at class load time, not on every method call.

How do I test a concern without coupling to a specific model?

Create a temporary test model using an anonymous class:

RSpec.describe SoftDeletable do
  let(:model_class) do
    Class.new(ApplicationRecord) do
      self.table_name = "articles"  # any table with deleted_at
      include SoftDeletable
    end
  end

  it "soft deletes a record" do
    record = model_class.create!(title: "Test", deleted_at: nil)
    record.discard
    expect(record.deleted_at).to be_present
  end
end

This tests the concern in isolation. If it needs specific columns, use an existing table or create a temporary one in your test setup.

#rails 8 #architecture #concerns #refactoring #code organization
R

About the Author

Roger Heykoop is a senior Ruby on Rails developer with 19+ years of Rails experience and 35+ years in software development. He specializes in Rails modernization, performance optimization, and AI-assisted development.

Get in Touch

Share this article

Need Expert Rails Development?

Let's discuss how we can help you build or modernize your Rails application with 19+ years of expertise

Schedule a Free Consultation