Architecture

Refactoring Fat Models in Rails Using Service Objects

BLUF (Bottom Line Up Front): The “thin controllers, fat models” paradigm in legacy Rails apps leads to unmaintainable ActiveRecord classes bloated with external API calls, email triggers, and complex domain logic. The architectural fix is extracting this domain logic into Plain Old Ruby Objects (POROs) called Service Objects, restoring the Single Responsibility Principle.

Phase 1: The Fat Model Anti-Pattern

Glossary entry: Fat Models (God Objects).

When an ActiveRecord model manages persistence, validations, associations, and business logic simultaneously, it becomes impossible to test in isolation.

Synthetic Engineering Context: The God Object

Consider a User model that handles registration, Stripe customer creation, and welcome emails.

# The Bad Code: Fat Model
class User < ApplicationRecord
  after_create :create_stripe_customer
  after_create :send_welcome_email

  def create_stripe_customer
    # External API call hiding inside the model
    customer = Stripe::Customer.create(email: email)
    update_column(:stripe_id, customer.id)
  end

  def send_welcome_email
    UserMailer.welcome(self).deliver_later
  end
end

Every time you run a simple unit test for a user validation, the test suite attempts to hit the Stripe API or requires complex mocking.

Where to Start in a 2,000-Line Model

Do not refactor top-to-bottom. Rank the extraction candidates with data:

# Complexity: which methods hurt the most
flog app/models/user.rb | head -20

# Churn: which files change most often (change-prone + complex = extract first)
git log --format="" --name-only --since="1 year ago" | sort | uniq -c | sort -rg | head

Start with logic that is both high-complexity and high-churn. A gnarly method nobody has touched in four years can wait; the pricing calculation that changes every sprint and triggers three callbacks cannot. Also list every callback (User._save_callbacks.map(&:filter) in a console) - callbacks with side effects (mailers, HTTP, other models) are your extraction targets; pure data normalization like strip_whitespace can stay.

Pin the Behavior First

Legacy fat models rarely have tests around their side effects. Before moving anything, write characterization tests that assert what the code does today, including the accidental parts:

# spec/models/user_registration_characterization_spec.rb
it "creates a Stripe customer and sends exactly one welcome email" do
  stripe = stub_request(:post, %r{api.stripe.com/v1/customers})
    .to_return(body: { id: "cus_123" }.to_json)

  expect { User.create!(email: "a@b.com") }
    .to have_enqueued_mail(UserMailer, :welcome).once
  expect(stripe).to have_been_requested
end

WebMock (or VCR) matters here: the point is to discover every hidden network call the callbacks make. When a characterization test surprises you, that surprise is precisely the coupling you are about to untangle.

Phase 2: The Service Object Extraction

A Service Object encapsulates a single business action. It takes inputs, performs the steps, and returns a result.

Execution: Creating the Service

Extract the side effects from the User model into a dedicated UserRegistrationService.

# app/services/user_registration_service.rb
class UserRegistrationService
  attr_reader :user_params

  def initialize(user_params)
    @user_params = user_params
  end

  def call
    ActiveRecord::Base.transaction do
      user = User.create!(user_params)
      
      # Explicit execution, no hidden callbacks
      customer = Stripe::Customer.create(email: user.email)
      user.update!(stripe_id: customer.id)
      
      UserMailer.welcome(user).deliver_later
      
      user
    end
  rescue Stripe::StripeError => e
    # Centralized error handling
    Rails.logger.error("Stripe failure: #{e.message}")
    false
  end
end

Execution: The Thin Controller

The controller now delegates the action to the service object.

# app/controllers/users_controller.rb
class UsersController < ApplicationController
  def create
    user = UserRegistrationService.new(user_params).call

    if user
      redirect_to dashboard_path, notice: 'Registration successful.'
    else
      redirect_to root_path, alert: 'Registration failed.'
    end
  end
end

The User model is now stripped of external dependencies, making it a pure data persistence layer.

Execution: Migrating Call Sites Without Breaking Production

The dangerous moment is between removing the callbacks and updating every place that calls User.create! directly (console scripts, rake tasks, admin panels, other services). Removing after_create :create_stripe_customer silently breaks all of them at once. The safe sequence:

  1. Extract the service while the callbacks still exist (the service temporarily duplicates them; guard with an idempotency check like return if user.stripe_id?).
  2. Instrument the old path instead of deleting it, so production tells you where the remaining call sites are:
# Transitional callback: find legacy call sites before removing
after_create :report_legacy_creation_path

def report_legacy_creation_path
  return if Thread.current[:via_registration_service]
  Rails.logger.warn("Legacy User.create! call site: #{caller_locations(1, 5).map(&:to_s)}")
end
  1. Migrate call sites one by one, watching the warning disappear from the logs.
  2. Only then delete the callbacks and the transitional instrumentation.

This log-driven approach beats grepping alone, because metaprogrammed and gem-initiated creations (admin gems, imports, factories in forgotten rake tasks) do not show up in a grep for User.create.

Measuring the Refactor

Refactoring without numbers invites endless bikeshedding. Track two metrics per pull request: the model’s flog total and its line count. A 2,000-line model with a flog score of 900 dropping to 600 lines and flog 250 over a quarter is defensible progress you can show a non-technical stakeholder; “the code feels cleaner” is not.

Phase 3: Next Steps & Risk Mitigation

While Service Objects clean up models, creating services that instantiate other services can quickly lead to a “Service Object Hell” where logic is scattered across dozens of poorly named files. Establishing strict naming conventions (e.g., verb-first: RegisterUser) is critical. When a workflow grows past three or four sequential steps with branching failure paths, evaluate service objects vs dry-transaction before inventing an ad-hoc orchestration layer.

Need Help Stabilizing Your Legacy App? Untangling a 5000-line ActiveRecord model requires precision refactoring and test coverage. Our team at USEO specializes in extracting complex domain logic into testable, modular architectures.

Contact us for a Technical Debt Audit