Rails Concerns: When And How To Use Them
Learn how Rails Concerns work, when to use ActiveSupport::Concern, and when concerns make your Rails app harder to maintain.
By Jean Emmanuel Cadet • 12 min read
Rails Concerns are one of the most useful and most misused tools in Ruby on Rails code organization. Used well, they let several models or controllers share a focused piece of behavior without duplication. Used carelessly, they turn a large model into a large model split across many files, with the same complexity and less clarity.
This article explains what concerns are, how ActiveSupport::Concern works, and how to build model and controller concerns. It also covers when concerns are the wrong choice, so you can decide with confidence whether one belongs in your Rails architecture.
What Are Rails Concerns?
A Rails concern is a Ruby module that extends ActiveSupport::Concern. It packages behavior that can be mixed into classes, usually Active Record models or controllers. Rails generates two default locations for them: app/models/concerns and app/controllers/concerns.
Concerns are not a separate Rails feature with their own runtime. A concern is a module, and including it works the same way as including any Ruby module. What ActiveSupport::Concern adds is a cleaner way to define class-level behavior (scopes, callbacks, validations, class methods) alongside instance methods.
Why Rails Provides Concerns
Rails models and controllers often need behavior that belongs to a shared concept: something can be archived, something has a slug, something is searchable. Plain Ruby modules can do this, but they get awkward once the shared behavior needs to declare scopes or callbacks on the class that includes it.
Concerns solve that problem. They give you a standard shape for modules that touch both instances and the class itself, and they handle dependencies between modules more predictably than hand-written self.included hooks.
How ActiveSupport::Concern Works
Start with the difference between a concern and a plain module, then look at the two mechanisms you will use most: included and class_methods.
Concern vs a Regular Ruby Module
Suppose you want a module that adds a scope and an instance method to a model. With a plain module, you need to hook into inclusion yourself:
module Archivable
def self.included(base)
base.extend(ClassMethods)
base.scope :active, -> { where(archived_at: nil) }
end
module ClassMethods
def archived_count
where.not(archived_at: nil).count
end
end
def archived?
archived_at.present?
end
end
This works, but it is noisy. You manage the ClassMethods module manually and call class-level macros on base. The same module as a concern reads much more clearly:
module Archivable
extend ActiveSupport::Concern
included do
scope :active, -> { where(archived_at: nil) }
end
class_methods do
def archived_count
where.not(archived_at: nil).count
end
end
def archived?
archived_at.present?
end
end
Both versions do the same thing. The concern version removes the plumbing and lets you write class-level code as if you were inside the model.
Using included and class_methods
extend ActiveSupport::Concern turns on two behaviors:
included do ... endruns its block in the context of the class that includes the concern. This is wherescope,validates,has_many, and callbacks likebefore_validationbelong, because those are class-level macros that must run on the model itself.class_methods do ... enddefines methods that become class methods on the including class. Under the hood, Rails creates aClassMethodsmodule and extends the class with it.
Any method defined directly in the concern body (outside those blocks) becomes an instance method. If you are curious how the included block is captured and evaluated later, it is the same idea covered in Ruby blocks, procs, and lambdas: the block is stored and executed in the context of the including class.
A Practical Model Concern Example
A good concern captures a domain concept that several models genuinely share. Imagine an application where both Post and Comment can be archived instead of deleted. Both tables have an archived_at datetime column, and both need the same behavior.
# app/models/concerns/archivable.rb
module Archivable
extend ActiveSupport::Concern
included do
scope :archived, -> { where.not(archived_at: nil) }
scope :active, -> { where(archived_at: nil) }
end
class_methods do
def archive_older_than(time)
active.where(created_at: ...time).update_all(archived_at: Time.current)
end
end
def archive!
update!(archived_at: Time.current)
end
def unarchive!
update!(archived_at: nil)
end
def archived?
archived_at.present?
end
end
Then include it in each model:
# app/models/post.rb
class Post < ApplicationRecord
include Archivable
end
# app/models/comment.rb
class Comment < ApplicationRecord
include Archivable
end
How the Methods Become Available
When you write include Archivable, Ruby inserts the module into the model's ancestor chain. You can see this in the console:
Post.ancestors.first(3)
# => [Post, Archivable, ApplicationRecord]
Because Archivable sits between Post and ApplicationRecord, calls like post.archive! find the method there. The included block runs once at include time, so Post.active and Comment.archived exist as scopes. The class_methods block makes Post.archive_older_than(30.days.ago) available.
Why This Concern Is Appropriate
This is a healthy concern for several reasons. Two models share it, it represents one clear idea (archiving), and everything inside it relates to that idea. It also depends on a single, obvious contract: the including model needs an archived_at column.
There are trade-offs. update_all in archive_older_than skips callbacks and validations, which is fast but deliberate, so document it. As the app grows, you might add an archived_by column or an audit trail. At that point the archiving workflow may deserve its own object, but the simple state behavior can stay in the concern.
Using Callbacks and Class Methods in Concerns
Callbacks and validations are the other common reason to reach for included. A slug generator is a classic shared behavior:
# 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
end
class_methods do
def find_by_slug!(slug)
find_by!(slug: slug)
end
end
private
def slug_source
title
end
def generate_slug
self.slug = slug_source.to_s.parameterize
end
end
The callback and validation must run on the model class, so they go in included. The finder is a class-level concern, so it goes in class_methods. The generate_slug method is private because callers should not use it directly.
Notice slug_source. A first version of this concern might call title directly, which silently assumes every including model has a title method. That is a hidden dependency. Pulling it into an overridable method makes the contract explicit: a model that uses name instead can override slug_source and nothing else changes.
A Practical Controller Concern Example
Controller concerns work the same way and are useful for cross-cutting behavior that several controllers need. Setting the locale for each request is a good example:
# app/controllers/concerns/locale_switchable.rb
module LocaleSwitchable
extend ActiveSupport::Concern
included do
around_action :switch_locale
end
private
def switch_locale(&action)
locale = params[:locale].presence_in(I18n.available_locales.map(&:to_s))
I18n.with_locale(locale || I18n.default_locale, &action)
end
end
class ApplicationController < ActionController::Base
include LocaleSwitchable
end
The around_action is registered inside included because it is a class-level macro. If you want to see where callbacks like this sit in the processing of a request, how the Rails request lifecycle works walks through the order of events.
This concern is appropriate because it handles one cross-cutting concern (request locale), applies to many controllers, and has no knowledge of any specific model.
When Rails Concerns Are a Good Choice
Concerns are most useful when multiple classes share a cohesive piece of behavior. These signals suggest a concern fits:
- Multiple models or controllers genuinely share the same behavior, not just similar-looking code.
- The behavior maps to a clear domain concept you can name in one word, such as
Archivable,Sluggable, orSearchable. - The shared behavior stays focused: scopes, validations, callbacks, and methods that all serve that one concept.
- The concern needs little knowledge of the classes that include it, ideally one or two documented requirements like a column name.
If you can describe the concern in a single sentence without using the word "and," it is probably cohesive.
When Rails Concerns Are a Bad Choice
Concerns can make a Rails application harder to understand when they are used for the wrong reasons. The most common problems are:
- Using a concern only to make a large model look smaller. The code is moved, not simplified.
- Grouping unrelated methods into one module because they happen to live in the same model.
- Putting complex business workflows inside a concern.
- Creating hidden dependencies, where a concern quietly calls methods that only some models define.
- Using concerns in place of proper object design.
- Creating a concern included by only one class, with no real sharing and no clear concept behind it.
- Building "God concerns" that hold dozens of methods and several responsibilities.
A Concern That Should Not Exist
Here is a concern that looks tidy but hides a problem:
# app/models/concerns/order_processing.rb
module OrderProcessing
extend ActiveSupport::Concern
def process!
charge_card
reserve_inventory
send_confirmation_email
notify_warehouse
end
private
def charge_card
PaymentGateway.charge(customer.payment_method, total_cents)
end
def reserve_inventory
line_items.each { |item| item.product.decrement!(:stock, item.quantity) }
end
def send_confirmation_email
OrderMailer.confirmation(self).deliver_later
end
def notify_warehouse
WarehouseApi.notify(id)
end
end
This concern is only included by Order, so nothing is shared. It mixes payment, inventory, email, and an external API in one place. It also depends on customer, total_cents, and line_items without saying so, and it makes Order appear simple while hiding a long workflow behind include OrderProcessing.
A Better Alternative
A multi-step business workflow with external services is a job for a service object, which has a clear name, explicit inputs, and one public method:
# app/services/orders/checkout.rb
module Orders
class Checkout
def initialize(order)
@order = order
end
def call
Order.transaction do
charge_card
reserve_inventory
end
send_confirmation_email
notify_warehouse
end
private
attr_reader :order
def charge_card
PaymentGateway.charge(order.customer.payment_method, order.total_cents)
end
def reserve_inventory
order.line_items.each { |item| item.product.decrement!(:stock, item.quantity) }
end
def send_confirmation_email
OrderMailer.confirmation(order).deliver_later
end
def notify_warehouse
WarehouseApi.notify(order.id)
end
end
end
Now the workflow is easy to find, easy to test in isolation, and easy to extend. If you are new to this pattern, Ruby on Rails service objects explained covers it in depth.
How Concerns Become Spaghetti Architecture
The risk compounds as concerns multiply. When a model includes eight concerns, understanding one method may require opening several files, and concerns start calling each other's methods through the shared model instance. Behavior depends on load order, naming collisions appear, and nobody can say where a given callback is defined. That tangle of implicit connections is what people mean when they call overused concerns spaghetti architecture.
Concerns vs Other Approaches
Concerns are one option among several. Choosing well depends on what kind of problem you have.
Concerns vs inheritance. Inheritance models an "is a" relationship and gives a class exactly one parent. Concerns model "can do" behavior, and a class can include many. Use inheritance when the classes truly are specializations of a base type. Use a concern when unrelated classes share a capability.
Concerns vs service objects. Concerns add state-related behavior to an object. Service objects perform an operation, often across several objects. If the code is a verb with steps (checkout, import, publish), a service object is usually the better fit.
Concerns vs plain Ruby modules. If the shared code is pure Ruby and does not need included or class_methods, a plain module is enough. Reach for ActiveSupport::Concern when you need class-level macros or want to manage dependencies between modules.
Concerns vs decorators or presenters. Formatting and display logic, such as full_name_with_title or a currency label, does not belong in a model concern. A decorator or presenter keeps view-specific code out of your domain models.
Naming and Organizing Rails Concerns
Clear names make concerns easier to trust. Prefer adjectives or capability names that describe the behavior, such as Archivable, Sluggable, Searchable, or Publishable. Avoid vague names like Helpers, Utils, or PostStuff, which invite unrelated methods.
For location, shared model concerns go in app/models/concerns and shared controller concerns in app/controllers/concerns. Both are autoload paths, so the file name must match the constant name, for example archivable.rb for Archivable. If that naming rule feels strict, how Rails autoloading works with Zeitwerk explains why.
Some teams also place concerns that belong to a single model in a namespace, such as app/models/post/publishable.rb defining Post::Publishable. This is a reasonable way to signal that a concern is specific to one model, but apply the same test: it should still be cohesive and have a clear name.
Testing Rails Concerns
Test a shared concern once, then run that test against every model that includes it. With RSpec, shared examples work well:
# spec/support/shared_examples/archivable.rb
RSpec.shared_examples "archivable" do
let(:record) { create(described_class.name.underscore.to_sym) }
it "archives the record" do
record.archive!
expect(record).to be_archived
expect(described_class.archived).to include(record)
end
it "restores an archived record" do
record.archive!
record.unarchive!
expect(record).not_to be_archived
expect(described_class.active).to include(record)
end
end
# spec/models/post_spec.rb
RSpec.describe Post do
it_behaves_like "archivable"
end
Minitest users can get the same result by putting the tests in a module and including it in each model's test class. Either way, you verify the concern's contract against the real models that use it, and you avoid copying the same tests into every model spec. Also keep a few tests for model-specific behavior in each model's own spec.
How to Recognize When a Concern Should Become Another Object
A concern is worth reconsidering when you notice these signs:
- It has grown past a handful of focused methods.
- It talks to external services, sends emails, or coordinates several models.
- Its methods need many arguments or rely on several attributes of the including class.
- You find yourself testing it only through one specific model.
- Understanding the model requires reading the concern line by line.
When that happens, extract the behavior into a service object, a query object, a form object, or a plain Ruby class with explicit dependencies. A good rule is that state and simple capabilities can stay in concerns, while workflows and coordination should become objects.
Best Practices for Maintaining Rails Concerns
- Keep each concern to one idea. If you cannot name it with a single concept, split it.
- Make requirements explicit. Document required columns or methods, or expose an overridable hook like
slug_source. - Avoid concerns that depend on other concerns. If you must, declare the dependency by including the other concern inside it.
- Do not hide business workflows. Keep multi-step operations in service objects.
- Prefer duplication over a wrong abstraction. Two similar blocks of code do not make a shared concept.
- Review concerns periodically. Behavior that was shared a year ago may now belong to one model, or deserve its own class.
Conclusion
Rails Concerns are a practical way to share focused, cohesive behavior between models or controllers, and ActiveSupport::Concern makes that sharing clean through included and class_methods. They work best when they express one clear idea with a small, explicit contract.
They should not become a dumping ground for unrelated code or a replacement for good application architecture. When a concern starts holding workflows, hidden dependencies, or too many responsibilities, move that behavior into a better-designed object.