Skip to content
← All posts
5 min read Dawid Skłodowski

Service objects: taming fat Rails models

Skinny controllers pushed all the logic into fat models, and now they're unmanageable. Service objects are the most popular fix — here is how to use them well, when they help, and how they turn into a worse mess if you overdo it.

Rails taught a generation of developers “skinny controllers, fat models”, and it was good advice — right up until the models got too fat. A mature Rails app reliably grows a User model that is two thousand lines long: persistence, validations, callbacks, business rules, notification logic, payment logic, reporting helpers, all piled into one class because “it’s about a user”. That class is impossible to hold in your head, terrifying to change, and slow to test. Service objects are the most popular answer to this problem, and a good one — but only if you understand what they are actually for. Here is how we use them, and the ways they go wrong.

The problem: where does business logic go?

Rails gives you a clear home for two kinds of code: persistence and data rules go in the model, request/response handling goes in the controller. What it conspicuously does not give you is a home for business processes — the multi-step operations that are the actual point of your application. “Place an order” is not a property of the Order record; it is a process that creates an order, charges a card, decrements stock, sends a confirmation, and maybe notifies a warehouse. Stuff that into the model and it bloats; stuff it into the controller and it cannot be reused or tested in isolation. There is no obvious place, so it sprawls.

A service object is just a plain Ruby class that gives that process a home. Nothing more exotic than that.

The basic shape

A service object is a PORO (plain old Ruby object) named for the action it performs, with one public method that performs it:

class PlaceOrder
  def initialize(cart, payment_method)
    @cart = cart
    @payment_method = payment_method
  end

  def call
    ActiveRecord::Base.transaction do
      order = create_order
      charge_payment(order)
      decrement_stock(order)
      order
    end
  end

  private

  attr_reader :cart, :payment_method

  def create_order
    Order.create!(line_items: cart.line_items, total: cart.total)
  end

  def charge_payment(order)
    PaymentGateway.charge(order.total, payment_method)
  end

  def decrement_stock(order)
    order.line_items.each { |li| li.product.decrement!(:stock, li.quantity) }
  end
end

The controller becomes genuinely skinny — it gathers input, calls the service, and renders a result:

def create
  order = PlaceOrder.new(current_cart, params[:payment_method]).call
  redirect_to order
rescue PaymentError => e
  redirect_to cart_path, alert: e.message
end

The conventions that have settled around this: name the class as a verb phrase (PlaceOrder, CancelSubscription, ImportCsv), expose a single public method (call is idiomatic), keep everything else private, and let the constructor take the collaborators. A common nicety is a class method so callers write PlaceOrder.call(cart, pm).

Why this is better

Three concrete wins, not just tidiness:

  • The business process has a name and a place. “How do we place an order?” has a single answer: read PlaceOrder. The logic is not scattered across a model callback, a controller, and an observer.
  • It is testable in isolation. You can unit-test PlaceOrder directly with a cart and a payment method — no controller, no HTTP, no view. Fast tests for the logic that matters most.
  • The model goes back on a diet. Order becomes responsible for being an order — its data and invariants — not for the entire choreography of ordering. The multi-model dance lives in the service that coordinates them.

Transactions and failure are first-class

Service objects are the natural home for transaction boundaries, and that is one of their best features. A business process that touches several records usually needs to be all-or-nothing — and the service is exactly the right scope to wrap in a transaction block, so a failure halfway through rolls everything back. The service also gives you one clear place to define how the process fails: raise a domain error, or return a result object the caller inspects. We tend to prefer an explicit result for expected failures over exceptions-as-control-flow:

Result = Struct.new(:success?, :order, :error)

def call
  order = nil
  ActiveRecord::Base.transaction do
    order = create_order
    charge_payment(order)
    decrement_stock(order)
  end
  Result.new(true, order, nil)
rescue PaymentError => e
  Result.new(false, nil, e.message)
end

The caller does result = PlaceOrder.call(...); if result.success? — failure is part of the interface, not a surprise that escapes upward.

Where it goes wrong

Service objects are easy to cargo-cult, and overdone they produce a codebase that is worse than the fat model you were fleeing. The failure modes we have learned to watch for:

  • One service per controller action, reflexively. If UpdateUserName just calls user.update(name: ...), you have added a layer of indirection that buys nothing. Service objects are for processes — multiple steps, multiple objects, real coordination. A one-line wrapper around a model method is pure ceremony; let the controller call the model.
  • Anemic models. Push everything out into services and your models become bags of database columns with no behaviour, while a parallel universe of service classes holds all the logic. That is not object-oriented design; it is procedural code in Ruby costume. Behaviour that genuinely belongs to a single record (a method over its own attributes) should stay on the model.
  • God services. A PlaceOrder that grows to 400 lines has just become the fat model again, relocated. When a service gets big, decompose it into smaller services it calls, not into more private methods.
  • Shared mutable state and unclear inputs. Keep services stateless beyond their constructor args, with explicit inputs and one clear output. A service that reaches into globals or mutates its inputs is hard to reason about as the thing it promised to be: a function with a name.

Verdict

Service objects are the right tool for a real and specific problem: business processes that span multiple models and have nowhere natural to live in vanilla Rails. Used for that — a named class, a single call, a transaction boundary, a clear result — they make an app dramatically easier to understand, test, and change, and they keep your models focused on being models. The discipline is knowing the boundary: a service object is for a process, not for every action. Reach for one when logic spans objects and steps; leave the model to do what only it can. Get that judgment right and the fat-model problem simply stops happening.