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:
- Extract the service while the callbacks still exist (the service temporarily duplicates them; guard with an idempotency check like
return if user.stripe_id?). - 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
- Migrate call sites one by one, watching the warning disappear from the logs.
- 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.