Refactoring Fat ActiveRecord Models — The Cuts That Actually Work
by Eric Hanson, Backend Developer at Clean Systems Consulting
How models get fat
The pattern is consistent across codebases. A User model starts clean. Then it needs authentication logic. Then email formatting. Then a method that sends a welcome email. Then a scope that's almost a query but not quite. Then a callback that fires on save to update a counter somewhere. Then presentation helpers added because the view needed them and the model was already open.
Six months later the model is 600 lines, has 12 callbacks, and tests require loading the entire Rails environment to verify a string formatting method. Nobody wants to touch it because changing anything might trigger a callback they forgot about.
The cuts below address specific accumulation patterns. They're not all appropriate for every fat model — the right refactors depend on what kind of fat has accumulated.
Extract query objects
Scopes are fine for simple filtering. They become a problem when they're long, when they're composed from other scopes in ways that are hard to follow, or when the query logic needs to be tested independently:
# Before — scope chain in the model
class Order < ApplicationRecord
scope :billable_this_month, -> {
where(status: :completed)
.where(billed: false)
.where("completed_at >= ?", Time.current.beginning_of_month)
.joins(:user)
.where(users: { account_type: :paid })
.order(:completed_at)
}
end
Extract to a query object — a plain Ruby class that builds and returns the relation:
class BillableOrdersQuery
def initialize(relation = Order.all)
@relation = relation
end
def call
@relation
.where(status: :completed)
.where(billed: false)
.where(completed_at: Time.current.beginning_of_month..)
.joins(:user)
.merge(User.where(account_type: :paid))
.order(:completed_at)
end
end
# Usage
BillableOrdersQuery.new.call
BillableOrdersQuery.new(user.orders).call # composable with a starting relation
The injected relation argument is the critical detail. It makes the query object composable — you can scope it to a user's orders, a date range pre-filter, or a test fixture without modifying the class. It also makes it testable without hitting production data.
Keep the scope on the model as a thin wrapper if call sites already use it:
scope :billable_this_month, -> { BillableOrdersQuery.new(all).call }
Extract value objects for domain concepts
As covered in depth separately — the signal in a fat model is multiple columns that always travel together. amount and currency appearing in six methods together. latitude and longitude never used independently. These are unnamed domain concepts living as loose attributes:
# Before
def format_price
"#{currency} #{'%.2f' % amount}"
end
def convert_price_to(target_currency)
rate = ExchangeRates.fetch(currency, target_currency)
Money.new(amount * rate, target_currency)
end
Extract Money as a value object, compose it into the model with composed_of:
composed_of :price,
class_name: "Money",
mapping: [%w[amount amount], %w[currency currency]]
Now format_price and convert_price_to move to Money, the model loses two methods, and those methods gain a home where they can be tested without ActiveRecord.
Extract service objects for multi-step operations
The most common fat-model cut. Any method that touches more than one model, calls an external service, sends a notification, or has more than one meaningful failure path should not be on the model:
# Before — on User model
def complete_registration!
update!(registration_completed_at: Time.current)
create_default_workspace!
SubscriptionService.start_trial(self)
WelcomeMailer.send_welcome(self).deliver_later
Analytics.track("user.registered", user_id: id)
end
This method has five side effects, calls three external systems, and is untestable without loading all of them. Extract:
class CompleteUserRegistration
def self.call(...) = new(...).call
def initialize(user:)
@user = user
end
def call
user.update!(registration_completed_at: Time.current)
Workspace.create_default_for(user)
SubscriptionService.start_trial(user)
WelcomeMailer.send_welcome(user).deliver_later
Analytics.track("user.registered", user_id: user.id)
end
private
attr_reader :user
end
The model method becomes either a thin wrapper or is removed entirely, with call sites updated directly. The service object's dependencies are explicit and mockable.
The cut to avoid: extracting a service object that only wraps a single update! call. That's indirection without payoff.
Extract decorators for presentation logic
Presentation methods on models are a specific smell: methods that exist only to format output for a view or API response. They don't belong in the model and they don't belong in a service object either:
# Before — on User model
def display_name
"#{first_name} #{last_name}".strip.presence || email
end
def avatar_url
gravatar_url || ActionController::Base.helpers.asset_path("default_avatar.png")
end
def membership_badge
I18n.t("membership.#{membership_tier}.badge")
end
These are presentation concerns. Extract to a presenter:
class UserPresenter < SimpleDelegator
def display_name
"#{first_name} #{last_name}".strip.presence || email
end
def avatar_url
gravatar_url || helpers.asset_path("default_avatar.png")
end
def membership_badge
I18n.t("membership.#{membership_tier}.badge")
end
private
def helpers
ActionController::Base.helpers
end
end
In controllers and views: @user = UserPresenter.new(current_user). The model loses three methods. The presenter tests don't need a database.
Callbacks — the cut that requires the most care
Callbacks are usually the most dangerous fat. They're implicit, order-dependent, and frequently trigger in tests when you don't want them to. But removing a callback is not always a refactor — sometimes it's a behavioral change disguised as a refactor.
The specific callbacks worth cutting are those that reach outside the model's own state:
# These belong in a service object or observer, not here
after_create :send_welcome_email
after_save :sync_to_crm
after_update :invalidate_external_cache
Move each to a service object that explicitly orchestrates the operation. The test for the callback becomes a test on the service, where it's explicit rather than implicit.
Callbacks that modify the model's own state — before_validation :normalize_email, before_save :set_slug — are generally fine where they are. They're not reaching outside the model's boundary.
Callbacks that update associated records in the same database — after_save :update_user_stats — are in the middle. They're not external, but they're often better expressed as part of an explicit transaction in a service object where the intent is visible.
The mechanical test for a callback worth removing: can it be triggered from a test that's trying to do something unrelated? If create(:user) in a factory fires a callback that hits a third-party API, the callback doesn't belong there.
What doesn't work
Concerns. Rails concerns are modules — they mix methods into the model and make the model's method count smaller without making it less complex. User with include Authenticatable, include Notifiable, include Billable still has all those methods in scope; they're just harder to find. Concerns are appropriate for sharing behavior across unrelated models, not for decomposing a single fat model.
Delegating everything to a God service. Extracting every model method into one UserService class trades a fat model for a fat service and doesn't solve anything.
Decomposing without tests first. Every extraction assumes the original behavior is captured somewhere. If the fat model has low test coverage, write characterization tests — tests that document what the current behavior is, even if that behavior is wrong — before moving code. Refactoring untested code without characterization tests is rewriting, not refactoring.
The cut order that minimizes risk
Start with query objects — they're purely additive, the model behavior doesn't change, and existing call sites can keep working through a thin scope wrapper.
Next, value objects — extract domain concepts that are already implicitly grouped. composed_of makes this nearly mechanical.
Then, presentation — remove view-specific methods to a decorator. No functional behavior changes.
Finally, callbacks and multi-step operations — extract to service objects. This is where the behavioral risk lives, and it's where characterization tests pay off most.
Each extraction shrinks the model, reduces the scope of what tests need to load, and leaves a smaller, more cohesive object behind.