May 15, 2024
•Last updated October 20, 2026
Rails Concerns vs. Modules: When to Use Each
A Rails concern is a Ruby module. It isn't a separate kind of object or an alternative to Ruby's module system.
The useful comparison is between a plain Ruby module and a module that uses ActiveSupport::Concern. Both can share behavior. A concern gives you a convenient way to package instance methods, class methods, and setup that belongs on the including class.
So when should you reach for each?
- Use a plain module when you only need to share methods or group related code under a name.
- Use
ActiveSupport::Concernwhen a mixin also needs to declare things such as scopes, validations, or callbacks, or coordinate other concerns. - Keep the code in its original class if extracting it would make the behavior harder to follow.
Let's build the same feature both ways so the difference is easier to see.
What is a Ruby module?
Ruby modules have two common jobs: providing a namespace and sharing behavior through mixins. You don't need Rails for either one. The Ruby documentation on modules covers both.
For a small mixin, ordinary include is enough:
module Greeting
def greeting
"Hello!"
end
end
class Person
include Greeting
end
Person.new.greeting # => "Hello!"
greeting becomes available to instances of Person. There's no setup to run and no class-level API to add, so ActiveSupport::Concern would add little here.
include, extend, and module methods
These three forms do different things:
include Greetingmakes the module's instance methods available to instances of the including class.extend Greetingadds those methods to the receiving object itself. Inside a class definition, that gives you class methods.def self.greetinginside a module defines a method on that module object. Including the module doesn't copy that method onto the including class.
For example, using the same Greeting module:
class Welcome
extend Greeting
end
Welcome.greeting # => "Hello!"
Ruby describes this behavior in Module and Object#extend. Keeping these distinctions straight makes concerns much less mysterious.
The same feature with a plain module
Suppose Article and Announcement both need to be archived without deleting their records.
Our small feature will provide:
- An
activescope for records that haven't been archived. - An
archive!instance method. - An
archived_beforeclass method for querying older archived records.
Both database tables need a nullable archived_at datetime column. The examples also assume each model has a title attribute. A module doesn't create these database columns for you.
Here's the plain-module version:
# app/models/concerns/archivable.rb
module Archivable
def self.included(base)
base.extend ClassMethods
base.scope :active, -> { where(archived_at: nil) }
end
def archive!
update!(archived_at: Time.current)
end
module ClassMethods
def archived_before(cutoff)
where("archived_at < ?", cutoff)
end
end
end
Then include it in each model:
# app/models/article.rb
class Article < ApplicationRecord
include Archivable
end
# app/models/announcement.rb
class Announcement < ApplicationRecord
include Archivable
end
Ruby calls self.included(base) when the module is included. Here, base is the receiving model. We use that hook to add the scope and extend the model with ClassMethods.
ClassMethods isn't a special Ruby keyword. It's a nested module we've chosen to name that way, and base.extend ClassMethods is what connects it to the model.
Notice that a plain module can live in app/models/concerns. The folder name doesn't change how Ruby includes it.
The same feature with ActiveSupport::Concern
Now replace the contents of archivable.rb with this version. These are alternatives, not two definitions to keep in the same application.
# app/models/concerns/archivable.rb
module Archivable
extend ActiveSupport::Concern
included do
scope :active, -> { where(archived_at: nil) }
end
def archive!
update!(archived_at: Time.current)
end
class_methods do
def archived_before(cutoff)
where("archived_at < ?", cutoff)
end
end
end
The model definitions stay the same. So does the public API:
article = Article.create!(title: "Understanding concerns")
article.archive!
Article.active
Article.archived_before(1.week.ago)
Announcement.active
The concern removes the manual wiring. ActiveSupport::Concern handles attaching class methods and running the inclusion block against the receiving class.
It doesn't turn this feature into a standalone object. Archivable still expects an Active Record model with an archived_at column. Those dependencies are part of its contract.
The queries return Active Record relations, so you can chain them with other query methods. For more on that behavior, see the Rails guide to scopes.
This doesn't hide archived records everywhere. Article.all still returns them; use Article.active where you want the filter.
What does included do do?
The syntax is included do, not included_do.
Put class-level declarations here: scope, validates, belongs_to, or before_validation, for example. The block runs in the receiving class's context when the concern is included. It doesn't run every time you instantiate a record.
Keep ordinary instance methods outside the block. Put methods you want to call on the model class inside class_methods do.
Use one included do block per concern. Defining separate inclusion blocks in the same concern raises ActiveSupport::Concern::MultipleIncludedBlocks.
How concern dependencies work
A concern can include another concern.
For example, an optional Restorable feature could build on the concern version of Archivable:
# app/models/concerns/restorable.rb
module Restorable
extend ActiveSupport::Concern
include Archivable
def restore!
update!(archived_at: nil)
end
end
To use it, change Article to include Restorable instead:
# app/models/article.rb
class Article < ApplicationRecord
include Restorable
end
Article now gets both archive! and restore!, along with active and archived_before.
The important detail is where setup happens. ActiveSupport::Concern arranges for the dependency's inclusion block and class methods to reach the final host, Article. With an ordinary self.included hook, including one module inside another invokes that hook against the intermediate module instead.
That distinction matters when the setup expects Active Record methods such as scope.
Dependency support is useful, but it isn't a reason to build a long chain of concerns. In this example, putting restore! directly in Archivable would also be a sensible choice if every archived model should be restorable.
Use a plain module for standalone utilities
Not every shared method needs to be mixed into a model or controller.
Consider a date formatter:
# app/lib/date_formatter.rb
module DateFormatter
def self.format_date(date)
date.strftime("%Y-%m-%d")
end
def self.format_time(time)
time.strftime("%H:%M:%S")
end
end
Call the methods directly on the module:
DateFormatter.format_date(Date.new(2026, 10, 20))
# => "2026-10-20"
DateFormatter.format_time(Time.utc(2026, 10, 20, 9, 30, 0))
# => "09:30:00"
There is no need to add include DateFormatter to ApplicationController. These are singleton methods on DateFormatter, not instance methods intended for inclusion.
The formatter also doesn't depend on a host model's attributes, callbacks, or associations. That makes a plain module a straightforward fit.
For user-facing dates in a Rails app, consider Rails' localization helpers before building your own formatter. This example is deliberately limited to fixed output formats.
Where should Rails concerns and modules live?
Use paths that make the code's role easy to find:
- Model concerns:
app/models/concerns/archivable.rb - Controller concerns:
app/controllers/concerns/... - Application-specific utility modules: an appropriate application directory, such as
app/lib/date_formatter.rb
Under Rails' normal Zeitwerk configuration, app/models/concerns/archivable.rb defines Archivable, not Concerns::Archivable.
If you create app/lib, restart your Rails server and console. Application subdirectories are discovered when the application boots. The app/lib directory in this example is different from the top-level lib directory, whose autoloading depends on your configuration.
Match filenames to constants, and use the Rails autoloading guide when you customize this structure. You can check naming and loading with:
bin/rails zeitwerk:check
Names such as Archivable, Searchable, and Sortable can help describe a capability. An able or ing suffix is not a Rails requirement, though. A specific name is more useful than forcing every module into the same naming pattern.
When a concern makes the code worse
Moving 200 lines out of a model doesn't automatically make that model easier to understand.
A concern is helpful when its name explains a cohesive capability. Archivable tells you something about what a record can do. A file called ArticleMethods mostly tells you where some methods were moved.
Before extracting one, ask:
- What does the host need to provide? Make required columns, methods, and associations clear.
- Do these methods belong together? Avoid grouping unrelated behavior just because it makes the original file shorter.
- Is sharing actually useful? Similar-looking code can still represent different business rules.
- Would a separate object be clearer? A multi-step import or checkout workflow may be easier to follow as a collaborating object with an explicit entry point.
- Will the behavior be easy to trace? Several concerns defining the same method, or registering overlapping callbacks, can make changes surprisingly risky.
A concern can organize one class even without reuse. That's a design choice, not an error. Just weigh the benefit of the name and grouping against the cost of opening another file to understand the class.
Keep small, obvious behavior where it is until an extraction earns its place.
Test the behavior on the receiving model
A concern that uses Active Record should be tested with a model that includes it. Testing that a module exists won't catch a broken scope or a missing database column.
For the Article example, a model test could look like this:
# test/models/article_test.rb
require "test_helper"
class ArticleTest < ActiveSupport::TestCase
test "archiving persists a timestamp and excludes the article from active" do
article = Article.create!(title: "Understanding concerns")
assert Article.active.exists?(article.id)
article.archive!
assert_not_nil article.reload.archived_at
assert_not Article.active.exists?(article.id)
end
test "archived_before excludes newer and unarchived articles" do
cutoff = Time.zone.parse("2026-10-20 12:00:00")
older = Article.create!(
title: "Older",
archived_at: cutoff - 1.day
)
newer = Article.create!(
title: "Newer",
archived_at: cutoff + 1.day
)
active = Article.create!(title: "Active")
result = Article.archived_before(cutoff)
assert result.exists?(older.id)
assert_not result.exists?(newer.id)
assert_not result.exists?(active.id)
end
end
Run it with:
bin/rails test test/models/article_test.rb
Adapt the record attributes to your model's validations. If several models include the concern, cover their integration too, particularly where callbacks or associations differ. If you add Restorable, test that restoring persists a nil timestamp and returns the record to the active scope.
The Rails testing guide explains the model-test setup in more detail.
Which should you choose?
Start with the smallest tool that expresses the behavior clearly.
A plain module is enough for a straightforward mixin or a utility you call directly. Use ActiveSupport::Concern when a mixin needs Rails declarations, class methods, or coordinated dependencies and its syntax makes those pieces easier to read.
The goal isn't to use concerns everywhere. It's to make the next change easier to understand.
Keep reading
- Ruby on Rails Routing
- A Beginner's Guide to Ruby on Rails Callbacks
- New to Rails? Check out Hello Rails for a guided introduction.
Categories
Collection
Part of the Ruby on Rails collection
Products and courses
-
Hello Hotwire
A course on Hotwire + Ruby on Rails.
-
Hello Rails
A course for newcomers to Ruby on Rails.
-
Rails UI
UI templates and components for Rails.