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.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

Ruby Performance Tips I Learned the Hard Way on a Production System

Most Ruby performance advice is synthetic benchmark folklore. These are patterns that caused measurable production problems — and the specific changes that fixed them.

Read more

Why MVC Is Not Enough for Complex Backend Systems

MVC is great for small apps, but when your backend starts juggling caching, queues, and multiple APIs, it quickly shows its limits.

Read more

ActiveRecord Query Patterns That Actually Scale

ActiveRecord makes simple queries trivial and complex queries dangerous. These are the patterns that remain correct under load — and the common ones that quietly fall apart at scale.

Read more

Spring Security in Practice — Authentication, Authorization, and the Filters That Run on Every Request

Spring Security is comprehensive and opaque until you understand its filter chain model. Here is how authentication and authorization actually work, how to configure each layer, and what runs on every request before your controller sees it.

Read more