Skip to content
Jhumur
All posts

7 min read

Service Objects in Rails: Where Business Logic Actually Belongs

Ruby on RailsArchitectureDesign Patterns

Rails gives you models, controllers and views. For a while that is enough. Then a feature arrives that does not belong to any single model — placing an order charges a card, reserves stock, sends two emails and writes an audit entry — and you have to decide where it goes.

Put it in the controller and it becomes untestable and unreusable. Put it in the model and Order starts knowing about payment gateways. Both roads end in the same place: a file nobody wants to open.

The symptom, in both directions

Here is the controller version. It works, and it is the wrong shape:

class OrdersController < ApplicationController
  def create
    @order = current_user.orders.build(order_params)
 
    ActiveRecord::Base.transaction do
      @order.save!
 
      charge = Stripe::Charge.create(
        amount: (@order.total * 100).to_i,
        currency: "aud",
        customer: current_user.stripe_customer_id,
      )
      @order.update!(charge_id: charge.id, status: "paid")
 
      @order.line_items.each do |item|
        item.product.decrement!(:stock_quantity, item.quantity)
      end
 
      OrderMailer.confirmation(@order).deliver_later
      AdminMailer.new_order(@order).deliver_later
      AuditLog.create!(user: current_user, action: "order.created", subject: @order)
    end
 
    redirect_to @order, notice: "Thanks for your order!"
  rescue Stripe::CardError => e
    @order.update(status: "payment_failed")
    flash.now[:alert] = e.message
    render :new, status: :unprocessable_entity
  end
end

To test the stock decrement you have to issue an HTTP request. To reuse any of this from a Rake task or an admin action, you copy it.

Moving the whole block into Order#place! does not fix much. The model gets shorter methods but a wider job: it now depends on Stripe, on two mailers, and on the audit log. Its unit tests need the payment gateway stubbed to check a validation.

The real problem is that this behaviour is not a property of an Order record. It is a process that involves one. That process needs its own object.

A service object that earns its place

# app/services/orders/place.rb
module Orders
  class Place
    Result = Data.define(:success?, :order, :error)
 
    def initialize(user:, params:, payment_gateway: PaymentGateway.new)
      @user = user
      @params = params
      @payment_gateway = payment_gateway
    end
 
    def call
      order = @user.orders.build(@params)
      return failure(order, order.errors.full_messages.to_sentence) unless order.valid?
 
      ActiveRecord::Base.transaction do
        order.save!
        charge = charge_for(order)
        order.update!(charge_id: charge.id, status: "paid")
        reserve_stock(order)
        record_audit(order)
      end
 
      deliver_notifications(order)
      Result.new(success?: true, order: order, error: nil)
    rescue PaymentGateway::Declined => e
      order.update(status: "payment_failed")
      failure(order, e.message)
    end
 
    private
 
    def charge_for(order)
      @payment_gateway.charge(
        amount_cents: (order.total * 100).to_i,
        currency: "aud",
        customer_id: @user.stripe_customer_id,
      )
    end
 
    def reserve_stock(order)
      order.line_items.each do |item|
        item.product.reserve!(item.quantity)
      end
    end
 
    def record_audit(order)
      AuditLog.create!(user: @user, action: "order.created", subject: order)
    end
 
    def deliver_notifications(order)
      OrderMailer.confirmation(order).deliver_later
      AdminMailer.new_order(order).deliver_later
    end
 
    def failure(order, message)
      Result.new(success?: false, order: order, error: message)
    end
  end
end

The controller becomes what a controller should be — HTTP in, HTTP out:

class OrdersController < ApplicationController
  def create
    result = Orders::Place.new(user: current_user, params: order_params).call
 
    if result.success?
      redirect_to result.order, notice: "Thanks for your order!"
    else
      @order = result.order
      flash.now[:alert] = result.error
      render :new, status: :unprocessable_entity
    end
  end
end

Four decisions in there are worth calling out, because they are what separate a useful service from a class with a call method.

Return a result, not a boolean

A service that returns true/false forces the caller to go looking for the error. One that raises makes the happy path awkward and pushes control flow into rescue. A small result object carries both outcomes explicitly:

Result = Data.define(:success?, :order, :error)

Data.define (Ruby 3.2+) gives you an immutable value object for free. Before that, Struct.new(..., keyword_init: true) does the same job.

Inject the things you cannot control

payment_gateway: defaults to the real implementation and accepts a fake. That single keyword argument is the difference between a test that hits the network and one that does not:

RSpec.describe Orders::Place do
  let(:gateway) { instance_double(PaymentGateway) }
  let(:user) { create(:user, :with_stripe_customer) }
  let(:product) { create(:product, stock_quantity: 5) }
  let(:params) { { line_items_attributes: [{ product_id: product.id, quantity: 2 }] } }
 
  subject(:place) { described_class.new(user: user, params: params, payment_gateway: gateway) }
 
  context "when the charge succeeds" do
    before do
      allow(gateway).to receive(:charge).and_return(double(id: "ch_123"))
    end
 
    it "marks the order paid and reserves stock" do
      result = place.call
 
      expect(result).to be_success
      expect(result.order).to be_paid
      expect(product.reload.stock_quantity).to eq(3)
    end
  end
 
  context "when the card is declined" do
    before do
      allow(gateway).to receive(:charge).and_raise(PaymentGateway::Declined, "Card declined")
    end
 
    it "leaves stock untouched and reports the error" do
      result = place.call
 
      expect(result).not_to be_success
      expect(result.error).to eq("Card declined")
      expect(product.reload.stock_quantity).to eq(5)
    end
  end
end

No HTTP request, no controller, and the transaction rollback is genuinely verified.

Keep side effects outside the transaction

deliver_notifications runs after the transaction block closes, and that ordering is deliberate. deliver_later enqueues a job; if the job backend is Redis-backed and the transaction later rolls back, the job still runs and emails a customer about an order that does not exist. Anything that touches the outside world belongs after the commit.

Rails 7.2 added ActiveRecord::Base.transaction(&:after_commit) hooks that make this explicit, and after_commit_everywhere covers earlier versions. Either way, the principle holds: commit first, notify second.

Let the model keep its own invariants

item.product.reserve!(item.quantity) is a model method, not inlined decrement!:

class Product < ApplicationRecord
  class InsufficientStock < StandardError; end
 
  def reserve!(quantity)
    with_lock do
      raise InsufficientStock, "Only #{stock_quantity} left" if quantity > stock_quantity
      update!(stock_quantity: stock_quantity - quantity)
    end
  end
end

Stock never going negative is a rule about a product, and it should hold no matter who calls it. Extracting a service layer does not mean models become empty data bags — it means they keep the rules that are genuinely theirs and lose the orchestration that is not.

When not to reach for a service

The pattern is easy to over-apply. A service object is not an improvement when:

It wraps a single Active Record call. Users::Create that does User.create!(params) adds a file and a layer of indirection for nothing. Call the model.

The logic belongs to one model. Formatting, derived attributes, state predicates — those are methods. If it reads only that model's own data, it stays on the model.

The behaviour is shared across models. That is a concern, not a service:

# app/models/concerns/archivable.rb
module Archivable
  extend ActiveSupport::Concern
 
  included do
    scope :archived, -> { where.not(archived_at: nil) }
    scope :live, -> { where(archived_at: nil) }
  end
 
  def archive!
    update!(archived_at: Time.current)
  end
 
  def archived?
    archived_at.present?
  end
end

It is really a query. A method that builds a complex SELECT is a query object, and it reads better as a scope or a dedicated class returning a relation:

# app/queries/orders/needing_fulfilment.rb
module Orders
  class NeedingFulfilment
    def initialize(relation = Order.all)
      @relation = relation
    end
 
    def call
      @relation
        .where(status: "paid")
        .where(shipped_at: nil)
        .where(created_at: ..3.days.ago)
        .includes(:user, line_items: :product)
        .order(:created_at)
    end
  end
end

Returning a relation rather than an array matters — the caller can still paginate or add conditions.

Conventions that keep the layer navigable

Once you have twenty services, structure matters more than the individual classes.

Namespace by domain, name by verb. Orders::Place, Orders::Refund, Subscriptions::Cancel. Reading app/services should tell you what the application does:

app/services/
├── orders/
│   ├── place.rb
│   ├── refund.rb
│   └── cancel.rb
├── subscriptions/
│   ├── activate.rb
│   └── cancel.rb
└── imports/
    └── process_csv.rb

Avoid OrderService — a class named after a noun with "Service" appended attracts unrelated methods until it is the fat model you just escaped.

One public method. call is the convention. If a service needs two entry points, it is two services.

A consistent result type. Pick one shape and use it everywhere, so callers do not have to remember which service raises and which returns. A shared base class is enough:

# app/services/application_service.rb
class ApplicationService
  Result = Data.define(:success?, :value, :error) do
    def self.success(value) = new(success?: true, value: value, error: nil)
    def self.failure(error, value: nil) = new(success?: false, value: value, error: error)
  end
 
  def self.call(...) = new(...).call
end

Do not compose services by calling them from each other. Two services that each open a transaction and each fire notifications will surprise you. If a process needs several steps, orchestrate them in one service and keep the steps as private methods, or model the sequence explicitly.

The rule underneath all of it

Put logic in a service object when it coordinates more than one collaborator and does not belong to any of them. Leave it on the model when it protects that model's own invariants. Leave it in the controller only if it is about HTTP.

That is not a Rails-specific insight, and it is not a new one. But it is the distinction that keeps an application legible at year three, and it is much cheaper to apply while the file is still short.