Skip to content
ExponaBLOG
RUBY ON RAILS · DAY 043 / 100

Concerns in Rails: Ruby on Rails lessons from production

Share code between models or controllers.

Expona Admin7 min read

Introduction

When your Rails application starts to grow beyond a single use case, you'll quickly notice that models and controllers repeat the same logic across different contexts. Concerns in Rails: Ruby on Rails lessons from production

What Are ActiveSupport::Concern Modules?

A concern is a module that uses Rails' ActiveSupport::Concern to encapsulate shared behavior. Rather than duplicating code across models or controllers, you define the behavior once in a concern and include it wherever needed. At its core, a concern is a container for mixins that get mixed into your classes.

Here's the simplest example:

ruby
module Publishable
  extend ActiveSupport::Concern

  included do
    scope :published, -> { where(published_at: ..Time.current) }
    scope :drafts, -> { where(published_at: nil) }
  end

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

  def draft?
    published_at.nil?
  end
end

class Article < ApplicationRecord
  include Publishable
end

class BlogPost < ApplicationRecord
  include Publishable
end

Without concerns, you'd either duplicate these methods in both models or use inheritance (which doesn't always fit your domain). With ActiveSupport::Concern, both Article and BlogPost gain publish!, draft?, and the two scopes—without duplication.

When Concerns Solve Real Problems

Concerns shine when you have genuine cross-cutting behavior shared by multiple models or controllers. In production, we've found three scenarios where concerns earned their place:

Audit and Timestamp Logic

Most applications need to track who changed what and when. Rather than repeat this logic across models:

ruby
module Auditable
  extend ActiveSupport::Concern

  included do
    has_many :audit_logs, as: :auditable, dependent: :destroy
    before_save :record_changes
  end

  private

  def record_changes
    AuditLog.create(
      auditable: self,
      user_id: Current.user_id,
      changed_fields: saved_changes.keys,
      previous_values: saved_changes.transform_values(&:first)
    )
  end
end

class User < ApplicationRecord
  include Auditable
end

class Invoice < ApplicationRecord
  include Auditable
end

Now both models automatically log changes without repeating the callback logic.

Soft Delete Patterns

Soft deletes—marking records as deleted rather than removing them—are common in business applications. A concern centralizes this:

ruby
module SoftDeletable
  extend ActiveSupport::Concern

  included do
    scope :active, -> { where(deleted_at: nil) }
    scope :deleted, -> { where.not(deleted_at: nil) }
    default_scope { active }
  end

  def soft_delete
    update(deleted_at: Time.current)
  end

  def restore
    update(deleted_at: nil)
  end
end

class Customer < ApplicationRecord
  include SoftDeletable
end

class Order < ApplicationRecord
  include SoftDeletable
end

Every model that includes SoftDeletable automatically excludes deleted records in queries and gains restore functionality.

Permission and Authorization Checks

Controllers often repeat authorization patterns. A concern can standardize this:

ruby
module Authorizable
  extend ActiveSupport::Concern

  included do
    before_action :authorize_user, only: [:edit, :update, :destroy]
  end

  private

  def authorize_user
    resource = instance_variable_get("@#{controller_name.singularize}")
    return if resource.user_id == current_user.id
    redirect_to root_path, alert: "Not authorized"
  end
end

class PostsController < ApplicationController
  include Authorizable
end

class CommentsController < ApplicationController
  include Authorizable
end

Both controllers now enforce that users can only edit their own resources.

Concerns Are for Cross-Cutting Behavior

The key word here is cross-cutting. A concern should solve a horizontal problem that spans multiple, unrelated classes. If the behavior is specific to your domain model, it belongs in the model itself, not a concern.

Ask yourself: "Would multiple unrelated classes benefit from this behavior?" If the answer is yes, a concern is appropriate. If the behavior applies only to one or two closely related classes, or if it's core to your business logic, keep it in the model.

For example, a Publishable concern makes sense because articles, blog posts, documents, and resources might all need publishing logic. But logic specific to how an invoice calculates tax should stay in the Invoice model—it's not a cross-cutting concern; it's core business logic.

Avoid Hiding Complexity

The biggest risk with concerns is that they can hide complexity behind innocent-looking includes. Your models become lean, but the actual behavior is scattered across multiple files. When a teammate inherits your code, they see include Publishable and have to jump to another file to understand what happens.

In production, we've seen concerns grow into bloated catch-alls where unrelated logic accumulates:

ruby
module CommonBehavior
  extend ActiveSupport::Concern

  included do
    scope :active, -> { where(active: true) }
    scope :recent, -> { order(created_at: :desc) }
    before_save :normalize_email
    before_create :generate_token
    after_update :invalidate_cache
    before_destroy :log_deletion
  end

  # ... 50 more methods ...
end

This is an anti-pattern. When a concern does too much, it becomes harder to test, harder to reason about, and harder to reuse. Instead, create multiple focused concerns or rethink whether the behavior belongs in a concern at all.

Keeping Concerns Maintainable

Follow these guardrails:

  • One responsibility per concern. A concern should do one thing well. If you're listing five unrelated behaviors, split it into five concerns.
  • Make dependencies explicit. If a concern relies on specific attributes or associations, document them in a comment or assertion.
  • Test concerns independently. Write tests for the concern itself, not just the models that include it, so the behavior is clearly specified.
  • Limit the scope of callbacks. Callbacks in concerns can be magical and hard to trace. When possible, use explicit method calls instead.

Name by Capability, Not Implementation

Name your concerns after the capability they provide, not the implementation detail. This keeps the abstraction clear and makes the code more readable.

Good names:

  • Publishable – provides publishing capability
  • Trackable – tracks changes over time
  • Versionable – maintains version history
  • Rateable – can be rated by users
  • Commentable – can receive comments

Poor names:

  • TimestampedBehavior – too implementation-focused
  • CommonStuff – says nothing about what it does
  • HelperMethods – suggests it's just dumping ground for code
  • ModuleMixin – redundant and unclear

When someone reads class Article < ApplicationRecord; include Publishable; end, they should immediately understand that articles can be published. That's the power of naming by capability.

Common Mistakes

Mistake 1: Using Concerns to Hide Inheritance

If you have a natural class hierarchy, use inheritance. Concerns shouldn't replace single-table inheritance or polymorphism:

ruby
# Wrong: using concerns to simulate inheritance
module VehicleBehavior
  def start_engine
    # ...
  end
end

class Car < ApplicationRecord
  include VehicleBehavior
end

# Right: use inheritance
class Vehicle < ApplicationRecord
end

class Car < Vehicle
end

Mistake 2: Concerns with Too Many Dependencies

If a concern requires five specific attributes, ten associations, and three callbacks to work, it's not reusable—it's inflexible:

ruby
# Wrong: concern assumes specific structure
module SomeLogic
  def calculate_total
    items.joins(:price).sum(&:amount) +
    discount.percentage +
    tax_rate.value # assumes very specific relationships
  end
end

# Right: make dependencies explicit
module Totallable
  extend ActiveSupport::Concern

  included do
    validates :total_amount, presence: true
  end

  def recalculate_total
    # Expects the including class to implement item_amount and taxes
    self.total_amount = item_amount + taxes
  end
end

Mistake 3: Mixing Concerns with Configuration

Don't use concerns to configure how models behave. Use options or separate classes:

ruby
# Wrong: configuration logic in a concern
module ConfigurableSoftDelete
  extend ActiveSupport::Concern

  included do
    if some_config_flag
      scope :active, -> { where(deleted_at: nil) }
    else
      scope :active, -> { where(archived: false) }
    end
  end
end

# Right: explicit configuration
class Invoice < ApplicationRecord
  include SoftDeletable

  # Or use a class-level configuration if needed
  soft_delete strategy: :timestamps
end

Key Takeaways

  • Use ActiveSupport::Concern to share horizontal, cross-cutting behavior across unrelated models or controllers—not to hide domain-specific logic.
  • A concern should encapsulate one capability. If you're mixing audit logging, soft deletes, and versioning in one concern, split them apart.
  • Name concerns by what they enable, not how they work. Publishable is clearer than PublishingMixin.
  • Avoid callback overload. Concerns heavy with before_save, after_create, and around_destroy become magical and hard to debug in production.
  • Test concerns independently. Write specs for the concern module itself so the behavior is crystal clear and not buried in model tests.
  • When in doubt, keep the logic in the model. A concern should solve a real problem; if you're uncertain whether it does, it probably shouldn't exist.

ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles