As apps grow, where to put logic determines whether the codebase stays pleasant or turns into fat models and controllers.
A concern is a module using ActiveSupport::Concern, kept in app/models/concerns or app/controllers/concerns. It packages a trait that several classes share, such as Taggable, Searchable or Archivable:
module Archivable
extend ActiveSupport::Concern
included do
scope :archived, -> { where.not(archived_at: nil) }
end
class_methods do
def archive_all = update_all(archived_at: Time.current)
end
def archive! = update!(archived_at: Time.current)
endincluded do runs in the including class (for scopes, associations, validations and callbacks), class_methods adds class methods, and plain methods become instance methods. Concerns can depend on other concerns, and Concern resolves the include order for you.
Concerns are good for genuine, cohesive roles. They are bad as a way to hide a 2,000-line model in five files.
Beyond models and controllers, Rails is happy with plain Ruby objects in app/models or new directories under app/ (all autoloaded):
- Domain objects (POROs) for concepts without a table, e.g.
Invoice::Calculator. - Form objects with
ActiveModel::ModelandActiveModel::Attributesfor forms that span several models or have no table, getting validations andform_withsupport. - Service or command objects for multi-step workflows (
Checkout.new(cart, user).call), when the work doesn't clearly belong to one model. - Query objects for complex reporting queries.
Prefer rich models first; extract only when a class has several unrelated responsibilities.
Example
module Archivable
extend ActiveSupport::Concern
included do
scope :archived, -> { where.not(archived_at: nil) }
end
class_methods do
def archive_older_than(age) = where(created_at: ...age.ago).update_all(archived_at: Time.current)
end
def archive! = update!(archived_at: Time.current)
end
class Article < ApplicationRecord; include Archivable; end
class Signup # form object spanning User + Account
include ActiveModel::Model
attr_accessor :email_address, :company
validates :email_address, :company, presence: true
def save
return false unless valid?
ActiveRecord::Base.transaction do
account = Account.create!(name: company)
account.users.create!(email_address:, password: SecureRandom.base58(24))
end
end
endRemember
- ActiveSupport::Concern: included do for class-level macros, class_methods for class methods
- Use concerns for cohesive shared roles, not to hide oversized classes
- POROs, form objects and service objects live happily in app/ and are autoloaded
Watch out for
- Splitting one bloated model into many single-use concerns, which spreads complexity instead of reducing it.