Rails ActiveSupport Concern: Simplifying your model logics

08 Jun 2025 - Gagan Shrestha

Master Your Models: A Deep Dive into Rails ActiveSupport::Concern

If you’ve been working with Rails for a while, you’ve likely encountered the infamous “fat model” problem. As an application grows, models can become bloated with business logic, callbacks, and methods that make them difficult to read, test, and maintain.

One of the best ways to combat this is by extracting related, reusable pieces of functionality into modules. In the Rails world, the gold standard for this is ActiveSupport::Concern.

But what is it, exactly? And how is it better than just using a plain old Ruby module? Let’s dive in.

The Problem: Sharing Behavior Across Models

Imagine we’re building a content management system. We have an Article model and a NewsItem model. Both of these can be published, drafted, or scheduled to be published in the future.

This “publishable” behavior would include:

Without a shared module, we’d have to duplicate this logic in both Article.rb and NewsItem.rb. This is a clear violation of the DRY (Don’t Repeat Yourself) principle.

The “Old Way”: Plain Ruby Mixins

Before ActiveSupport::Concern became the standard, developers used a common pattern with standard Ruby modules to solve this. It works, but it’s a bit clunky.

Let’s create a Publishable module the “old way.”

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
# app/models/concerns/publishable_old.rb

module PublishableOld
  # The self.included hook is called when this module is included in a class.
  def self.included(base)
    # 'base' is the class that includes this module (e.g., Article)

    # Add class methods to the base class
    base.extend(ClassMethods)

    # Add associations, validations, etc. to the base class
    base.class_eval do
      validates :published_at, presence: true, if: :scheduled?

      # We need a status column for this example
      # enum status: { draft: 0, published: 1, scheduled: 2 }
    end
  end

  # A nested module to hold the class methods
  module ClassMethods
    def published
      where("published_at <= ?", Time.current)
    end

    def scheduled
      where("published_at > ?", Time.current)
    end
  end

  # Instance methods are defined directly in the module
  def published?
    published_at.present? && published_at <= Time.current
  end

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

  private

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

To use it, we would include it in our models:

1
2
3
4
5
6
7
8
9
# app/models/article.rb
class Article < ApplicationRecord
  include PublishableOld
end

# app/models/news_item.rb
class NewsItem < ApplicationRecord
  include PublishableOld
end

What’s the Problem Here?

This pattern works, but it has some drawbacks:

The “New Way”: The Elegance of ActiveSupport::Concern

ActiveSupport::Concern was created to clean up this exact pattern. It’s a module that, when extended, gives you a much cleaner and more declarative syntax for building mixins.

Let’s refactor our Publishable module using the Rails way.

First, create the file: app/models/concerns/publishable.rb. Rails is configured to automatically load files from this directory.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
# app/models/concerns/publishable.rb

require 'active_support/concern'

module Publishable
  extend ActiveSupport::Concern

  # Code inside the 'included' block is evaluated in the context
  # of the class that includes this concern. This is the perfect
  # place for validations, associations, and other macros.
  included do
    validates :published_at, presence: true, if: :scheduled?

    scope :published, -> { where("published_at <= ?", Time.current) }
    scope :scheduled, -> { where("published_at > ?", Time.current) }
  end

  # The 'class_methods' block is a shortcut. Any methods defined
  # here will be added as class methods to the including class.
  # Note: Scopes defined in the 'included' block are often cleaner.
  # This block is great for other types of class methods.
  # class_methods do
  #   def total_published
  #     published.count
  #   end
  # end

  # Instance methods are defined just like in a regular module.
  def published?
    published_at.present? && published_at <= Time.current
  end

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

  private

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

Now, our models look exactly the same, but they are much cleaner under the hood.

1
2
3
4
5
6
7
8
9
# app/models/article.rb
class Article < ApplicationRecord
  include Publishable
end

# app/models/news_item.rb
class NewsItem < ApplicationRecord
  include Publishable
end

Why is This So Much Better?

Putting It All Together: A Full Example

Let’s see it in action.

1. Create a migration:

1
rails g migration AddPublishedAtToArticlesAndNewsItems published_at:datetime

This will create a migration file. You can modify it to add the column to both tables.

1
2
3
4
5
6
7
# db/migrate/YYYYMMDDHHMMSS_add_published_at_to_articles_and_news_items.rb
class AddPublishedAtToArticlesAndNewsItems < ActiveRecord::Migration[7.0]
  def change
    add_column :articles, :published_at, :datetime
    add_column :news_items, :published_at, :datetime
  end
end

Run rails db:migrate.

2. Create the concern

Create app/models/concerns/publishable.rb (as shown above).

3. Include the concern in your models

Update article.rb and news_item.rb (as shown above).

4. Use it in the console:

Open the Rails console with rails c.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# Create a few records
# A published article
Article.create(title: "Hello Rails", content: "...", published_at: 1.day.ago)

# A scheduled article
Article.create(title: "Upcoming Features", content: "...", published_at: 1.week.from_now)

# A draft article (no published_at)
draft_article = Article.create(title: "Work in Progress", content: "...")

# A published news item
NewsItem.create(headline: "Big News!", published_at: Time.current)

# Check our scopes
Article.published.count
#=> 1
Article.scheduled.count
#=> 1
NewsItem.published.count
#=> 1

# Use the instance methods
draft_article.published?
#=> false

draft_article.publish!
#=> true (updates the record in the DB)

draft_article.published?
#=> true

Article.published.count
#=> 2

When to Use a Concern (and When Not To)

Concerns are powerful, but they aren’t a silver bullet for all code organization problems.

Use a concern when:

Consider alternatives (like Service Objects) when:

Concerns modify and query the state of a single object, while Service Objects orchestrate complex operations.

Conclusion

ActiveSupport::Concern is a fundamental tool in the modern Rails developer’s toolkit. It provides a clean, readable, and idiomatic way to organize your code, adhere to the DRY principle, and keep your models from becoming unmanageable. By understanding how it improves upon the “old way” of Ruby mixins, you can better appreciate its elegance and use it effectively in your projects.

Go forth and refactor those fat models!