Refactoring Fat ActiveRecord Models — The Cuts That Actually Work

by Arif Ikhsanudin, Backend Developer

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

Auckland Backend Developers Cost NZ$130K and the Market Has Maybe 200 Senior Candidates — Here Is the Fix

You've talked to every recruiter in Auckland. They all send you the same five people. Three of them aren't looking.

Read more

The Evolving Role of a Tech Lead With Modern Tools

Modern development tools are transforming how tech leads do their work. From code review automation to team collaboration, the role is shifting—but not disappearing.

Read more

NULL in SQL Does Not Mean What You Think It Means

NULL represents the absence of a value, not zero, not an empty string, and not false — its three-valued logic and propagation rules produce query results that are consistently surprising to developers who treat it as a regular value.

Read more

Why Clear Acceptance Criteria Matters in Software Projects

The small detail that quietly determines whether your project ships smoothly or turns into endless back-and-forth

Read more