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
A concern is a module that uses Rails' Here's the simplest example: Without concerns, you'd either duplicate these methods in both models or use inheritance (which doesn't always fit your domain). With 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: Most applications need to track who changed what and when. Rather than repeat this logic across models: Now both models automatically log changes without repeating the callback logic. Soft deletes—marking records as deleted rather than removing them—are common in business applications. A concern centralizes this: Every model that includes Controllers often repeat authorization patterns. A concern can standardize this: Both controllers now enforce that users can only edit their own resources. 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 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 In production, we've seen concerns grow into bloated catch-alls where unrelated logic accumulates: 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. Follow these guardrails: 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: Poor names: When someone reads If you have a natural class hierarchy, use inheritance. Concerns shouldn't replace single-table inheritance or polymorphism: If a concern requires five specific attributes, ten associations, and three callbacks to work, it's not reusable—it's inflexible: Don't use concerns to configure how models behave. Use options or separate classes:What Are ActiveSupport::Concern Modules?
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.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
endActiveSupport::Concern, both Article and BlogPost gain publish!, draft?, and the two scopes—without duplication.When Concerns Solve Real Problems
Audit and Timestamp Logic
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
endSoft Delete Patterns
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
endSoftDeletable automatically excludes deleted records in queries and gains restore functionality.Permission and Authorization Checks
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
endConcerns Are for Cross-Cutting Behavior
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
include Publishable and have to jump to another file to understand what happens.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 ...
endKeeping Concerns Maintainable
Name by Capability, Not Implementation
Publishable – provides publishing capabilityTrackable – tracks changes over timeVersionable – maintains version historyRateable – can be rated by usersCommentable – can receive comments
TimestampedBehavior – too implementation-focusedCommonStuff – says nothing about what it doesHelperMethods – suggests it's just dumping ground for codeModuleMixin – redundant and unclearclass 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
# 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
endMistake 2: Concerns with Too Many Dependencies
# 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
endMistake 3: Mixing Concerns with Configuration
# 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
endKey Takeaways
Publishable is clearer than PublishingMixin.before_save, after_create, and around_destroy become magical and hard to debug in production.






Comments
Loading comments…