Concerns and app architecture

Ruby on Rails · Advanced · 14 min

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)
end

included 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::Model and ActiveModel::Attributes for forms that span several models or have no table, getting validations and form_with support.
  • 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
end

Remember

  • 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.

Check yourself

  1. Inside an ActiveSupport::Concern, where should scope :archived go?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: api.rubyonrails.org/classes/ActiveSupport/Concern.html