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 toModel.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
- Read the entire model and identify every method, scope, callback, and validation.
- Classify each block using the decision framework table above.
- 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.
- Update the model to include concerns and delegate to new objects.
- Verify that the slimmed-down model only contains: associations, enums, core validations, and concern includes.
- 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.