Concerns Modules
ActiveSupport::Concern patterns, module design, and code organization strategies for maintainable Rails applications.
You are an expert in Ruby module design and Rails Concerns for organizing shared behavior across models, controllers, and services. ## Key Points - A concern should represent a single, cohesive behavior (e.g., `Sluggable`, `Searchable`, `SoftDeletable`). - Name concerns after the capability they provide, using adjectives (`-able`, `-ible`) when appropriate. - Avoid concerns that depend on specific column names without documenting the contract. - Prefer explicit `class_methods` blocks over `module ClassMethods` for clarity. - Keep controller concerns focused on cross-cutting concerns (authentication, pagination, error handling), not business logic. - Extract complex business logic into service objects or plain Ruby classes rather than model concerns. - Document the expected interface (required columns, methods) that including classes must provide. - **Concern soup**: Splitting a model into many concerns just to reduce file length does not improve design. If the pieces are only used by one model, they may be better left inline. - **Hidden dependencies**: Concerns that call methods defined in other concerns or the including class without documenting the contract create fragile coupling. - **God concerns**: A single concern that does too many things (validation + callbacks + scopes + instance methods). Split it. - **Using concerns to avoid proper OOP**: Sometimes what you need is a service object, a decorator, or a composed class -- not a mixin. - **Circular dependencies**: Concern A including Concern B which depends on Concern A. Keep the dependency graph acyclic.
skilldb get ruby-rails-skills/Concerns ModulesFull skill: 389 linesConcerns and Module Patterns — Ruby on Rails
You are an expert in Ruby module design and Rails Concerns for organizing shared behavior across models, controllers, and services.
Overview
Rails Concerns (built on ActiveSupport::Concern) provide a clean pattern for extracting shared behavior into reusable modules. When used well, they reduce duplication and improve readability. When overused, they scatter logic and create hidden dependencies. Understanding when and how to apply module patterns is key to sustainable Rails architecture.
Core Philosophy
Concerns and modules in Rails exist to share behavior, not to hide complexity. The best concerns extract a single, cohesive capability that genuinely belongs to multiple models or controllers. When you include Sluggable or Searchable, the name alone communicates what behavior is being added. If you cannot name a concern after a clear capability, it probably should not be a concern.
Ruby's module system is powerful, but power demands discipline. The goal is not to make every model file short — it is to make every piece of behavior discoverable and testable. Moving 200 lines from a model into a concern that only that model includes does not improve your architecture; it just scatters the code across two files. The question to ask is always: "Does this behavior belong to more than one class, and can I describe it in a single word or phrase?"
Service objects, decorators, and plain Ruby classes are equally valid tools for organizing code. Concerns are the right choice when you are mixing in shared behavior with access to the host class's internals (callbacks, scopes, associations). For standalone business logic that orchestrates multiple models, a service object is almost always clearer.
Anti-Patterns
-
Concern Soup: Splitting a single model into five or six concerns purely to reduce file length. If each concern is only included in one model, you have not improved cohesion — you have just scattered related code across multiple files and made it harder to follow.
-
Hidden Interface Contracts: Writing concerns that silently assume the including class has specific columns, methods, or associations without documenting or enforcing those requirements. This creates fragile, implicit coupling that breaks unexpectedly when the concern is included elsewhere.
-
The Kitchen-Sink Concern: Building a single concern that handles validation, callbacks, scopes, class methods, and instance methods for loosely related functionality. If a concern does more than one thing, split it or reconsider whether a concern is the right abstraction.
-
Replacing OOP with Mixins: Using concerns as a substitute for proper object-oriented design. When you need to coordinate multiple models, apply business rules, or manage a workflow, a service object or command pattern is more explicit and testable than a module mixed into a model.
-
Circular Concern Dependencies: Concern A including Concern B which references methods defined in Concern A. This creates a dependency cycle that is nearly impossible to reason about and will break as soon as someone changes either concern in isolation.
Core Concepts
ActiveSupport::Concern Basics
# app/models/concerns/sluggable.rb
module Sluggable
extend ActiveSupport::Concern
included do
before_validation :generate_slug, on: :create
validates :slug, presence: true, uniqueness: true
end
class_methods do
def find_by_slug!(slug)
find_by!(slug: slug)
end
end
def to_param
slug
end
private
def generate_slug
base = slug_source.parameterize
self.slug = base
counter = 1
while self.class.exists?(slug: self.slug)
self.slug = "#{base}-#{counter}"
counter += 1
end
end
def slug_source
respond_to?(:title) ? title : name
end
end
# Usage
class Post < ApplicationRecord
include Sluggable
end
class Category < ApplicationRecord
include Sluggable
private
def slug_source
name
end
end
Concern with Configuration
# app/models/concerns/searchable.rb
module Searchable
extend ActiveSupport::Concern
class_methods do
def searchable_by(*columns)
@searchable_columns = columns
scope :search, ->(query) {
return all if query.blank?
conditions = columns.map { |col| "#{col} ILIKE :q" }.join(" OR ")
where(conditions, q: "%#{sanitize_sql_like(query)}%")
}
end
def searchable_columns
@searchable_columns || []
end
end
end
class Product < ApplicationRecord
include Searchable
searchable_by :name, :description, :sku
end
# Usage
Product.search("widget")
Implementation Patterns
Model Concerns
# app/models/concerns/trackable.rb
module Trackable
extend ActiveSupport::Concern
included do
has_many :activity_logs, as: :trackable, dependent: :destroy
after_create :log_creation
after_update :log_changes
end
private
def log_creation
activity_logs.create!(
action: "created",
metadata: { attributes: attributes.except("id", "created_at", "updated_at") }
)
end
def log_changes
return unless saved_changes.any?
activity_logs.create!(
action: "updated",
metadata: { changes: saved_changes.except("updated_at") }
)
end
end
# app/models/concerns/soft_deletable.rb
module SoftDeletable
extend ActiveSupport::Concern
included do
scope :kept, -> { where(discarded_at: nil) }
scope :discarded, -> { where.not(discarded_at: nil) }
default_scope { kept }
end
def discard
update(discarded_at: Time.current)
end
def undiscard
update(discarded_at: nil)
end
def discarded?
discarded_at.present?
end
end
Controller Concerns
# app/controllers/concerns/paginatable.rb
module Paginatable
extend ActiveSupport::Concern
private
def page
(params[:page] || 1).to_i
end
def per_page
[(params[:per_page] || 25).to_i, 100].min
end
def paginate(relation)
relation.page(page).per(per_page)
end
def pagination_headers(collection)
response.headers["X-Total-Count"] = collection.total_count.to_s
response.headers["X-Total-Pages"] = collection.total_pages.to_s
response.headers["X-Current-Page"] = collection.current_page.to_s
response.headers["X-Per-Page"] = collection.limit_value.to_s
end
end
# app/controllers/concerns/error_handling.rb
module ErrorHandling
extend ActiveSupport::Concern
included do
rescue_from ActiveRecord::RecordNotFound do |e|
render json: { error: "Not found" }, status: :not_found
end
rescue_from ActiveRecord::RecordInvalid do |e|
render json: {
error: "Validation failed",
details: e.record.errors.messages
}, status: :unprocessable_entity
end
rescue_from ActionController::ParameterMissing do |e|
render json: { error: "Missing parameter: #{e.param}" }, status: :bad_request
end
end
end
Service Objects (Plain Modules and Classes)
# app/services/order_creator.rb
class OrderCreator
include ActiveModel::Validations
attr_reader :order
validates :items, presence: true
def initialize(user:, items:, coupon_code: nil)
@user = user
@items = items
@coupon_code = coupon_code
end
def call
return failure(errors.full_messages) unless valid?
ActiveRecord::Base.transaction do
@order = @user.orders.create!(status: :pending)
create_line_items
apply_coupon if @coupon_code.present?
calculate_totals
@order.save!
end
success(@order)
rescue ActiveRecord::RecordInvalid => e
failure(e.record.errors.full_messages)
end
private
def create_line_items
@items.each do |item|
@order.line_items.create!(
product_id: item[:product_id],
quantity: item[:quantity],
unit_price: Product.find(item[:product_id]).price
)
end
end
def apply_coupon
coupon = Coupon.active.find_by!(code: @coupon_code)
@order.update!(coupon: coupon, discount: coupon.calculate_discount(@order))
end
def calculate_totals
subtotal = @order.line_items.sum { |li| li.unit_price * li.quantity }
@order.update!(subtotal: subtotal, total: subtotal - (@order.discount || 0))
end
def success(data)
OpenStruct.new(success?: true, data: data, errors: [])
end
def failure(errors)
OpenStruct.new(success?: false, data: nil, errors: Array(errors))
end
end
Composable Query Modules
# app/models/concerns/filterable.rb
module Filterable
extend ActiveSupport::Concern
class_methods do
def filter_by(params)
results = all
params.each do |key, value|
next if value.blank?
results = results.public_send("filter_by_#{key}", value) if respond_to?("filter_by_#{key}")
end
results
end
end
end
class Product < ApplicationRecord
include Filterable
scope :filter_by_category, ->(id) { where(category_id: id) }
scope :filter_by_min_price, ->(price) { where("price >= ?", price) }
scope :filter_by_max_price, ->(price) { where("price <= ?", price) }
scope :filter_by_in_stock, ->(val) { where("stock_count > 0") if val == "true" }
end
# Usage in controller
Product.filter_by(params.permit(:category, :min_price, :max_price, :in_stock))
Module Mixin vs Inheritance
# When behavior is shared across unrelated classes, use a module:
module Publishable
extend ActiveSupport::Concern
included do
scope :published, -> { where.not(published_at: nil) }
scope :draft, -> { where(published_at: nil) }
end
def publish!
update!(published_at: Time.current)
end
def published?
published_at.present?
end
end
# When classes share identity and database structure, use STI:
class Event < ApplicationRecord
# base class
end
class Webinar < Event
# inherits table and behavior
end
class Workshop < Event
# inherits table and behavior
end
Best Practices
- A concern should represent a single, cohesive behavior (e.g.,
Sluggable,Searchable,SoftDeletable). - Name concerns after the capability they provide, using adjectives (
-able,-ible) when appropriate. - Avoid concerns that depend on specific column names without documenting the contract.
- Prefer explicit
class_methodsblocks overmodule ClassMethodsfor clarity. - Keep controller concerns focused on cross-cutting concerns (authentication, pagination, error handling), not business logic.
- Extract complex business logic into service objects or plain Ruby classes rather than model concerns.
- Document the expected interface (required columns, methods) that including classes must provide.
Common Pitfalls
- Concern soup: Splitting a model into many concerns just to reduce file length does not improve design. If the pieces are only used by one model, they may be better left inline.
- Hidden dependencies: Concerns that call methods defined in other concerns or the including class without documenting the contract create fragile coupling.
- God concerns: A single concern that does too many things (validation + callbacks + scopes + instance methods). Split it.
- Using concerns to avoid proper OOP: Sometimes what you need is a service object, a decorator, or a composed class -- not a mixin.
- Circular dependencies: Concern A including Concern B which depends on Concern A. Keep the dependency graph acyclic.
- Testing concerns in isolation is hard: Test the behavior through the including class, not the module directly. This validates the real integration.
Install this skill directly: skilldb add ruby-rails-skills
Related Skills
Active Record
ActiveRecord query patterns, associations, validations, callbacks, and performance optimization for Rails applications.
API Mode
Building JSON APIs with Rails API mode, serialization, versioning, authentication, and rate limiting.
Deployment
Deploying Rails applications with Kamal, Docker, and production best practices for infrastructure and operations.
Hotwire Turbo
Hotwire and Turbo Drive, Frames, and Streams for building reactive Rails frontends without heavy JavaScript.
Sidekiq
Background job processing with Sidekiq, including job design, error handling, queues, and performance tuning in Rails.
Stimulus
Stimulus.js controller patterns for adding interactive behavior to server-rendered Rails HTML.