active-record-model-best-practices

star 1

Best practices for Ruby on Rails models, splitting code into well-organized, maintainable code. Use when a model exceeds ~100 lines, has mixed responsibilities, or when the user asks to refactor, extract, clean up, or organize a Rails model. Applies patterns: concerns, service objects, query objects, form objects, and value objects.

CodySwannGT By CodySwannGT schedule Updated 3/1/2026

name: active-record-model-best-practices description: Best practices for Ruby on Rails models, splitting code into well-organized, maintainable code. Use when a model exceeds ~100 lines, has mixed responsibilities, or when the user asks to refactor, extract, clean up, or organize a Rails model. Applies patterns: concerns, service objects, query objects, form objects, and value objects.

Rails Model Refactoring

When refactoring a Rails model, analyze the file and extract code into the appropriate pattern based on what the code does. The model itself should only contain associations, enums, basic validations, and concern includes.

Decision Framework

Read the model file and classify each block of code:

Code type Extract to Location
Related scopes + simple methods sharing a theme Concern app/models/concerns/
Business logic, multi-step operations, callbacks with side effects Service object app/services/
Complex queries, multi-join scopes, reporting queries Query object app/queries/
Context-specific validations (e.g. registration vs admin update) Form object app/forms/
Domain concepts beyond a primitive (money, coordinates, scores) Value object app/models/
Associations, enums, core validations, simple scopes Keep on model

Patterns

Concerns

Use for grouping related scopes, validations, callbacks, and simple instance methods that share a single theme. Name the concern after the capability it provides.

# app/models/concerns/searchable.rb
module Searchable
  extend ActiveSupport::Concern

  included do
    scope :search, ->(query) { where("name ILIKE ?", "%#{query}%") }
  end

  def matching_terms(query)
    name.scan(/#{Regexp.escape(query)}/i)
  end
end

Service Objects

Use for business logic, orchestration of multiple models, and anything triggered by a user action that involves more than a simple CRUD operation. Follow the single-responsibility principle — one service, one operation.

# app/services/players/calculate_stats.rb
module Players
  class CalculateStats
    def initialize(player)
      @player = player
    end

    def call
      # complex logic here
    end
  end
end

Conventions:

  • Namespace under the model name: Players::CalculateStats
  • Single public method: call
  • Accept dependencies via initialize
  • Return a result or raise a domain-specific error

Query Objects

Use for complex database queries that involve joins, subqueries, CTEs, or multi-condition filtering that would clutter a model with scopes.

# app/queries/players/free_agent_query.rb
module Players
  class FreeAgentQuery
    def initialize(relation = Player.all)
      @relation = relation
    end

    def call(filters = {})
      @relation
        .where(contract_status: :expired)
        .where("age < ?", filters[:max_age])
        .joins(:stats)
        .order(war: :desc)
    end
  end
end

Conventions:

  • Accept a base relation in initialize (default to Model.all)
  • Return an ActiveRecord relation so it remains chainable
  • Single public method: call

Form Objects

Use when validations only apply in specific contexts, or when a form spans multiple models.

# app/forms/player_registration_form.rb
class PlayerRegistrationForm
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :name, :string
  attribute :email, :string
  attribute :team_id, :integer
  attribute :position, :string

  validates :name, :email, :position, presence: true
  validates :email, format: { with: URI::MailTo::EMAIL_REGEXP }

  def save
    return false unless valid?
    Player.create!(attributes)
  end
end

Value Objects

Use for domain concepts that deserve their own identity beyond a raw primitive.

# app/models/batting_average.rb
class BattingAverage
  include Comparable

  def initialize(hits, at_bats)
    @hits = hits
    @at_bats = at_bats
  end

  def value
    return 0.0 if @at_bats.zero?
    (@hits.to_f / @at_bats).round(3)
  end

  def elite?
    value >= 0.300
  end

  def <=>(other)
    value <=> other.value
  end
end

Refactoring Process

  1. Read the entire model and identify every method, scope, callback, and validation.
  2. Classify each block using the decision framework table above.
  3. Extract in order: value objects first, then query objects, then service objects, then concerns. Do concerns last because some may become unnecessary after other extractions.
  4. Update the model to include concerns and delegate to new objects.
  5. Verify that the slimmed-down model only contains: associations, enums, core validations, and concern includes.
  6. Create or update tests for each extracted class. Each new class gets its own spec file mirroring the source path.

What NOT to Do

  • Don't extract everything — simple one-line scopes and basic validations belong on the model.
  • Don't create a class for trivial logic just to hit a line count target.
  • Don't use concerns as junk drawers — each concern should have a clear, single theme.
  • Don't break ActiveRecord conventions (e.g. don't move associations into concerns).
  • Don't introduce callback-heavy service objects — prefer explicit invocation over implicit hooks.
Install via CLI
npx skills add https://github.com/CodySwannGT/lisa --skill active-record-model-best-practices
Repository Details
star Stars 1
call_split Forks 3
navigation Branch main
article Path SKILL.md
More from Creator