It’s all too easy for ActiveRecord models to balloon into monolithic “God objects.” You start by adding a validation here, a callback there, maybe a little business logic in a class method—and before you know it, your User or Order model is hundreds of lines long, has dozens of dependencies, and is terrifying to test or extend.

In this post we’ll explore how to tame “fat models” by extracting business logic into plain-old Ruby service classes—sometimes called service objects—and organizing them in a service-oriented architecture. We’ll walk through a concrete example of refactoring a bloated model into focused, single-responsibility components, showing the before-and-after code and discussing the benefits for maintainability, testability, and overall code clarity.

The Problem: A Bloated ActiveRecord Model

Imagine you have an Order model in a Rails e-commerce app. Over time, you’ve added validations, price-calculations, notification callbacks, payment integrations, and more—directly in the model:

# app/models/order.rb
class Order < ApplicationRecord
  belongs_to :user
  has_many   :line_items, dependent: :destroy

  validate :must_have_at_least_one_item
  before_save :calculate_totals
  after_create :send_confirmation_email, :charge_credit_card

  def total_price
    line_items.sum { |li| li.quantity * li.unit_price }
  end

  def calculate_totals
    self.subtotal = total_price
    self.tax      = subtotal * 0.08
    self.total    = subtotal + tax + shipping_cost
  end

  def send_confirmation_email
    NotificationMailer.order_confirmation(self).deliver_later
  end

  def charge_credit_card
    if payment_token.present?
      response = Stripe::Charge.create(
        amount:    (total * 100).to_i,
        currency:  "usd",
        source:    payment_token,
        metadata:  { order_id: id }
      )
      update!(stripe_charge_id: response.id)
    else
      errors.add(:base, "No payment token provided")
      raise ActiveRecord::RecordInvalid, self
    end
  end

  private

  def must_have_at_least_one_item
    errors.add(:base, "Order must have at least one line item") if line_items.empty?
  end
end

This model is doing everything: data validation, business calculations, external API calls, mailing, error handling. Tests for Order become slow and brittle, as you must stub Stripe, mailers, potentially clear side effects—and every change feels risky.

The Solution: Service-Oriented Architecture

The core idea is: keep your models thin—they should manage persistence concerns (relationships, validations, scopes) but delegate business workflows to dedicated service classes. Each service class:

  • Is a plain Ruby object (PORO).
  • Has a single responsibility.
  • Exposes a clear interface, e.g. a .call class method.
  • Lives under app/services.

Directory Structure

app/
├── models/
│   └── order.rb       # only relations & basic persistence
└── services/
    ├── order/
    │   ├── calculate_totals.rb
    │   ├── send_confirmation_email.rb
    │   └── charge_credit_card.rb
    └── create_order.rb

Step 1: Extracting calculate_totals

Move pricing logic into its own service:

# app/services/order/calculate_totals.rb
module Order
  class CalculateTotals
    def initialize(order)
      @order = order
    end

    def call
      @order.subtotal = @order.line_items.sum { |li| li.quantity * li.unit_price }
      @order.tax      = @order.subtotal * tax_rate
      @order.total    = @order.subtotal + @order.tax + @order.shipping_cost
      @order
    end

    private

    def tax_rate
      0.08
    end
  end
end

In your model, simply invoke:

# app/models/order.rb
class Order < ApplicationRecord
  before_save -> { Order::CalculateTotals.new(self).call }

  # ... relations & validations only ...
end

Step 2: Extracting Notifications and Payments

Similarly, pull the mailer and payment handling out:

# app/services/order/send_confirmation_email.rb
module Order
  class SendConfirmationEmail
    def initialize(order)
      @order = order
    end

    def call
      NotificationMailer.order_confirmation(@order).deliver_later
    end
  end
end
# app/services/order/charge_credit_card.rb
module Order
  class ChargeCreditCard
    def initialize(order, token:)
      @order = order
      @token = token
    end

    def call
      raise ArgumentError, "No payment token provided" if @token.blank?

      charge = Stripe::Charge.create(
        amount:   (@order.total * 100).to_i,
        currency: "usd",
        source:   @token,
        metadata: { order_id: @order.id }
      )
      @order.update!(stripe_charge_id: charge.id)
    rescue Stripe::StripeError => e
      Rails.logger.error("Charge failed: #{e.message}")
      raise
    end
  end
end

And in Order:

# app/models/order.rb
class Order < ApplicationRecord
  after_create do
    Order::SendConfirmationEmail.new(self).call
    Order::ChargeCreditCard.new(self, token: payment_token).call
  end
end

Step 3: Orchestrating with a “CreateOrder” Service

Rather than sprinkling callbacks in the model, you can build a single entry point to orchestrate the entire workflow:

# app/services/create_order.rb
class CreateOrder
  Result = Struct.new(:order, :success?, :errors)

  def initialize(user:, line_items:, payment_token:)
    @user          = user
    @line_items    = line_items
    @payment_token = payment_token
  end

  def call
    order = build_order
    if order.save
      Order::CalculateTotals.new(order).call
      order.save!
      Order::ChargeCreditCard.new(order, token: @payment_token).call
      Order::SendConfirmationEmail.new(order).call
      Result.new(order, true, nil)
    else
      Result.new(order, false, order.errors.full_messages)
    end
  rescue => e
    Result.new(order, false, [e.message])
  end

  private

  def build_order
    Order.new(user: @user).tap do |o|
      @line_items.each { |attrs| o.line_items.build(attrs) }
      o.payment_token = @payment_token
    end
  end
end

Then in your controller:

# app/controllers/orders_controller.rb
def create
  service = CreateOrder.new(
    user:           current_user,
    line_items:     order_params[:line_items],
    payment_token:  order_params[:payment_token]
  )
  result = service.call

  if result.success?
    redirect_to result.order, notice: "Order created!"
  else
    flash.now[:alert] = result.errors.join(", ")
    render :new
  end
end

Benefits of This Approach

  1. Single Responsibility Each class does one thing—no more juggling mailers, payments, and persistence in a single model.

  2. Improved Testability You can write focused unit tests for Order::ChargeCreditCard with minimal setup, stubbing only Stripe. The CreateOrder orchestrator can be tested end-to-end without worrying about every callback firing unexpectedly.

  3. Clearer Workflow The CreateOrder service reads like a script of your business process. It’s obvious in what order you calculate totals, charge the card, and send an email.

  4. Easier Maintenance Need to change the tax rate? Open app/services/order/calculate_totals.rb. Want to swap Stripe for another gateway? Replace Order::ChargeCreditCard payload with your new API client—your model remains untouched.

  5. Runtime Flexibility You can instantiate and call services anywhere: from controllers, background jobs, or console scripts, without dragging in the full ActiveRecord lifecycle.

Conclusion

Refactoring fat models into a service-oriented architecture doesn’t require a radical rewrite—just a disciplined extraction of responsibilities and a clear folder structure under app/services. You’ll end up with thin models, better-organized business logic, and faster, more reliable tests. Next time your Rails model approaches “God object” territory, remember: a small service object can save you a world of pain.

Happy refactoring! 🚀