/ Tags: RAILS / Categories: RAILS

Service Objects in Rails — Keeping Controllers Thin Without Losing Your Mind

Fat models, skinny controllers was the mantra for years. Then models got fat and we realized that was wrong too. Business logic crammed into ActiveRecord models means models responsible for database persistence, validations, callbacks, associations, and complex domain logic — all at once. Service objects are the practical answer: plain Ruby classes that hold one piece of business logic, called from controllers, background jobs, or other services. Simple idea. Worth understanding how to do it well.

What a Service Object Is


A service object is a plain Ruby class that encapsulates a single business operation. It typically:

  • Takes its dependencies through the constructor
  • Exposes one public method (commonly call)
  • Returns a result that signals success or failure
  • Has no knowledge of HTTP, views, or controller infrastructure

That’s it. No gem required, no base class needed. The pattern is the value.

Example:

# app/services/register_user.rb
class RegisterUser
  def initialize(params)
    @params = params
  end

  def call
    user = User.new(@params)

    if user.save
      send_welcome_email(user)
      track_signup(user)
      Result.new(success: true, user: user)
    else
      Result.new(success: false, errors: user.errors)
    end
  end

  private

  def send_welcome_email(user)
    UserMailer.welcome_email(user).deliver_later
  end

  def track_signup(user)
    Analytics.track(user_id: user.id, event: "signup")
  end

  Result = Struct.new(:success, :user, :errors, keyword_init: true) do
    def success? = success
  end
end

The controller becomes thin:

Example:

# app/controllers/registrations_controller.rb
class RegistrationsController < ApplicationController
  def create
    result = RegisterUser.new(registration_params).call

    if result.success?
      redirect_to dashboard_path, notice: "Welcome!"
    else
      @errors = result.errors
      render :new, status: :unprocessable_entity
    end
  end

  private

  def registration_params
    params.require(:user).permit(:name, :email, :password)
  end
end

The controller handles HTTP: parse params, call the service, render or redirect. The service handles the business logic: create the user, send email, track event. Each has one job.

The Result Object Pattern


Returning a rich result object rather than a boolean or raising exceptions makes the caller’s code cleaner and more explicit:

Example:

class ProcessPayment
  Result = Struct.new(:success, :charge_id, :error_message, keyword_init: true) do
    def success? = success
    def failure? = !success
  end

  def initialize(user:, amount:, payment_method_id:)
    @user = user
    @amount = amount
    @payment_method_id = payment_method_id
  end

  def call
    charge = Stripe::Charge.create(
      amount: (@amount * 100).to_i,
      currency: "usd",
      customer: @user.stripe_customer_id,
      payment_method: @payment_method_id
    )

    Result.new(success: true, charge_id: charge.id)
  rescue Stripe::CardError => e
    Result.new(success: false, error_message: e.message)
  end
end

Example:

# In controller or background job
result = ProcessPayment.new(
  user: current_user,
  amount: cart.total,
  payment_method_id: params[:payment_method_id]
).call

if result.success?
  Order.create!(charge_id: result.charge_id, user: current_user)
  redirect_to confirmation_path
else
  flash[:error] = result.error_message
  render :checkout
end

The result object makes both outcomes explicit and keeps exception handling in the service where it belongs.

File Organization


Services live in app/services/. Rails autoloads this directory, so no require statements needed.

For larger apps, namespace by domain:

app/services/
  users/
    register_user.rb
    update_profile.rb
    deactivate_account.rb
  payments/
    process_payment.rb
    issue_refund.rb
  notifications/
    send_digest.rb

Example:

# Namespaced
module Payments
  class ProcessPayment
    # ...
  end
end

result = Payments::ProcessPayment.new(...).call

Namespacing keeps app/services/ navigable as the app grows and groups related operations logically.

Testing Services


Services are easy to test because they’re plain Ruby. No HTTP setup, no controller stack, no request/response cycle:

Example:

# spec/services/register_user_spec.rb
RSpec.describe RegisterUser do
  describe "#call" do
    context "with valid params" do
      let(:params) { { name: "Alice", email: "[email protected]", password: "secret123" } }

      it "creates a user" do
        expect { RegisterUser.new(params).call }.to change(User, :count).by(1)
      end

      it "returns a successful result" do
        result = RegisterUser.new(params).call
        expect(result).to be_success
        expect(result.user.email).to eq("[email protected]")
      end

      it "sends a welcome email" do
        expect { RegisterUser.new(params).call }
          .to have_enqueued_mail(UserMailer, :welcome_email)
      end
    end

    context "with invalid params" do
      let(:params) { { name: "", email: "not-an-email", password: "x" } }

      it "returns a failure result" do
        result = RegisterUser.new(params).call
        expect(result).to be_failure
        expect(result.errors).not_to be_empty
      end
    end
  end
end

Unit tests on services run fast. No database when you don’t need it. No mocking of the HTTP stack.

Pro-Tip: Keep services focused on one operation — resist the temptation to add update_and_notify and update_without_notify to the same class. When a service needs two modes, it’s two services. The “one public method” rule enforces this naturally. If you find yourself adding flags or switches to change what call does, that’s the signal to split.

What Doesn’t Belong in a Service


Services aren’t a dumping ground for everything that doesn’t fit controllers or models. Query logic belongs in query objects or scopes. Formatting logic belongs in presenters or serializers. Decorating records belongs in decorators.

Service objects are specifically for orchestrating — coordinating multiple operations (save a record, send an email, fire an event, call an API) into a single business operation. If your service is doing one thing that could be a model method, put it in the model.

Conclusion


Service objects solve a real architectural problem without requiring a framework or complex abstraction. They’re plain Ruby classes. They have one job. They’re easy to test, easy to find, and easy to change without touching controllers or models. Start using them when you notice controllers doing more than parsing params and routing to views, or when models are accumulating methods that don’t relate to persistence. The bar is low; the payoff in maintainability is real.

FAQs


Q1: Should every action have a service object?
No. Simple CRUD that maps directly to a model — create a record, update it, delete it — often doesn’t need a service. The signal for a service is multiple operations that need to succeed or fail together, or orchestration of external calls alongside database writes.

Q2: Should I use a gem like dry-monads or interactor for service objects?
Those gems add structure (Result monads, hooks, pipelines) that’s valuable in large codebases. For most apps, plain Ruby classes with a Result struct are sufficient and easier to understand without the gem overhead. Reach for dry-monads when the result handling becomes complex or when you want do notation for chaining fallible operations.

Q3: What’s the difference between a service object and a command object?
Effectively nothing in Rails practice. “Command” implies the Command pattern (with undo/redo semantics); “service” implies domain operations. Most Rails developers use both terms interchangeably for plain Ruby operation classes. Pick one name and be consistent in your codebase.

Q4: Can service objects call other service objects?
Yes, and this is often correct. A PlaceOrder service might call ProcessPayment, UpdateInventory, and SendOrderConfirmation. Compose at the orchestration layer. Just be careful about depth — three levels of service nesting is usually a sign the design needs rethinking.

Q5: Where do I put shared logic that multiple services need?
Extract it to a module and include it, or to a plain Ruby class that services instantiate. Don’t create a BaseService class with shared behavior — inheritance for code reuse in services typically creates more coupling than it’s worth.

cdrrazan

Rajan Bhattarai

Full Stack Software Developer! 💻 🏡 Grad. Student, MCS. 🎓 Class of '23. GitKraken Ambassador 🇳🇵 2021/22. Works with Ruby / Rails. Photography when no coding. Also tweets a lot at TW / @cdrrazan!

Read More