Skip to main content
Technology & EngineeringRuby Rails389 lines

Concerns Modules

ActiveSupport::Concern patterns, module design, and code organization strategies for maintainable Rails applications.

Quick Summary18 lines
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 lines
Paste into your CLAUDE.md or agent config

Concerns 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_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.

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

Get CLI access →