---
title: "RR003 – Extract Business Logic into Service Objects"
impact: high
impactDescription: "Fat models and fat controllers are untestable, hard to reuse, and prone to circular dependencies. Service objects isolate business logic into a single callable unit."
tags: [ruby, rails, architecture, service-layer]
---

# RR003 – Extract Business Logic into Service Objects

## Rule

Business logic that involves more than a single model update, an external API call, or conditional branching belongs in a dedicated service object under `app/services/`, not in models or controllers.

## Why

- Fat models accumulate unrelated behaviour, making them hard to test in isolation.
- Fat controllers mix HTTP concerns (params, redirects) with domain rules.
- Service objects are plain Ruby objects (POROs) — no Rails magic needed to test them.

## Wrong

```ruby
# Fat controller: business logic mixed with HTTP handling
class OrdersController < ApplicationController
  def create
    @order = Order.new(order_params)
    @order.total = @order.items.sum(&:price) * (1 - current_user.discount)
    if @order.save
      Stripe::Charge.create(amount: @order.total_cents, ...)
      OrderMailer.confirmation(@order).deliver_later
      redirect_to @order
    else
      render :new
    end
  end
end

# Fat model: calling external APIs from ActiveRecord callbacks
class Order < ApplicationRecord
  after_create :charge_card
  def charge_card
    Stripe::Charge.create(...)
  end
end
```

## Correct

```ruby
# app/services/place_order_service.rb
class PlaceOrderService
  Result = Struct.new(:order, :success?, :error, keyword_init: true)

  def initialize(user:, params:, payment_gateway: Stripe)
    @user = user
    @params = params
    @payment_gateway = payment_gateway
  end

  def call
    order = build_order
    return Result.new(order: order, success?: false, error: order.errors) unless order.save

    charge_payment(order)
    OrderMailer.confirmation(order).deliver_later
    Result.new(order: order, success?: true)
  rescue PaymentError => e
    order.destroy
    Result.new(success?: false, error: e.message)
  end

  private

  def build_order
    Order.new(**@params, total: calculate_total, user: @user)
  end
  # ...
end

# Controller stays thin
class OrdersController < ApplicationController
  def create
    result = PlaceOrderService.new(user: current_user, params: order_params).call
    if result.success?
      redirect_to result.order
    else
      @order = result.order
      render :new
    end
  end
end
```

## Notes

- Service objects should be callable via `#call` (standard Ruby convention, works with `Proc#call` duck-typing).
- Return a `Result` struct or use `dry-monads` for explicit success/failure signalling.
- Place in `app/services/` — Rails autoloads from this directory by default.
- Keep one public method (`call`) — additional extraction goes into private methods.
