Andy from Webcrunch

Subscribe for email updates:

Portrait of Andy Leverenz
Andy Leverenz

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::Concern when 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 Greeting makes the module's instance methods available to instances of the including class.
  • extend Greeting adds those methods to the receiving object itself. Inside a class definition, that gives you class methods.
  • def self.greeting inside 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 active scope for records that haven't been archived.
  • An archive! instance method.
  • An archived_before class 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:

  1. What does the host need to provide? Make required columns, methods, and associations clear.
  2. Do these methods belong together? Avoid grouping unrelated behavior just because it makes the original file shorter.
  3. Is sharing actually useful? Similar-looking code can still represent different business rules.
  4. 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.
  5. 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

Link this article
Est. reading time: 10 minutes
Stats: 5,972 views

Categories

Collection

Part of the Ruby on Rails collection

Products and courses