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.

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.

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.

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