Service Objects in Rails 8

As Rails applications grow, controllers become bloated and models turn into thousand-line monsters. The solution isn't a new framework—it's a pattern that Rails developers have refined over years: service objects. This guide covers battle-tested patterns for building service objects that remain maintainable as applications scale.

The Problem: Fat Controllers and Bloated Models

Consider a user registration flow that needs to create a user, send a welcome email, set up default preferences, notify the sales team, and track analytics. Stuffing all of this into a controller action or model callback creates code that's difficult to test, impossible to reuse, and painful to modify.

Service objects solve this by encapsulating a single business operation into a dedicated class. Each service has one job, accepts explicit inputs, and returns predictable outputs.

The Basic Service Object Pattern

A well-structured service object follows a simple contract: it has a single public method, accepts dependencies explicitly, and returns a result that indicates success or failure.

# app/services/application_service.rb
class ApplicationService
  def self.call(...)
    new(...).call
  end

  def call
    raise NotImplementedError, "#{self.class} must implement #call"
  end

  private

  def success(data = nil)
    Result.new(success: true, data: data, error: nil)
  end

  def failure(error)
    Result.new(success: false, data: nil, error: error)
  end

  Result = Data.define(:success, :data, :error) do
    def success? = success
    def failure? = !success
  end
end

This base class uses Ruby 3.2+'s Data.define for an immutable result object. The .call class method provides a clean interface without needing to manually instantiate services throughout the codebase.

Implementing a Real Service: User Registration

Here's how a user registration service looks using this pattern:

# app/services/users/registration_service.rb
module Users
  class RegistrationService < ApplicationService
    def initialize(params:, ip_address: nil)
      @params = params
      @ip_address = ip_address
    end

    def call
      user = User.new(user_params)

      ActiveRecord::Base.transaction do
        unless user.save
          return failure(user.errors.full_messages.join(", "))
        end

        create_default_preferences(user)
        schedule_welcome_email(user)
        notify_sales_team(user) if user.company_email?
        track_signup(user)
      end

      success(user)
    rescue ActiveRecord::RecordInvalid => e
      failure(e.message)
    rescue StandardError => e
      Rails.error.report(e)
      failure("Registration failed. Please try again.")
    end

    private

    attr_reader :params, :ip_address

    def user_params
      params.slice(:email, :password, :password_confirmation, :name)
    end

    def create_default_preferences(user)
      UserPreference.create!(
        user: user,
        email_notifications: true,
        timezone: "UTC"
      )
    end

    def schedule_welcome_email(user)
      UserMailer.welcome(user).deliver_later(wait: 5.minutes)
    end

    def notify_sales_team(user)
      SalesNotificationJob.perform_later(user_id: user.id)
    end

    def track_signup(user)
      Analytics::TrackEventJob.perform_later(
        event: "user_signed_up",
        user_id: user.id,
        properties: { ip_address: ip_address }
      )
    end
  end
end

The controller becomes remarkably simple:

# app/controllers/registrations_controller.rb
class RegistrationsController < ApplicationController
  def create
    result = Users::RegistrationService.call(
      params: registration_params,
      ip_address: request.remote_ip
    )

    if result.success?
      sign_in(result.data)
      redirect_to dashboard_path, notice: "Welcome aboard!"
    else
      flash.now[:alert] = result.error
      render :new, status: :unprocessable_entity
    end
  end

  private

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

Composing Services for Complex Operations

Real business logic often requires orchestrating multiple operations. Rather than creating monolithic services, compose smaller services together:

# app/services/orders/checkout_service.rb
module Orders
  class CheckoutService < ApplicationService
    def initialize(cart:, payment_params:, user:)
      @cart = cart
      @payment_params = payment_params
      @user = user
    end

    def call
      return failure("Cart is empty") if cart.empty?

      ActiveRecord::Base.transaction do
        order = create_order
        return failure(order.errors.full_messages.join(", ")) unless order.persisted?

        payment_result = process_payment(order)
        return payment_result if payment_result.failure?

        inventory_result = reserve_inventory(order)
        if inventory_result.failure?
          refund_payment(payment_result.data)
          return inventory_result
        end

        finalize_order(order, payment_result.data)
        success(order.reload)
      end
    rescue StandardError => e
      Rails.error.report(e)
      failure("Checkout failed. Your card was not charged.")
    end

    private

    attr_reader :cart, :payment_params, :user

    def create_order
      Order.create(
        user: user,
        line_items_attributes: cart.items.map(&:to_line_item_params),
        total_cents: cart.total_cents
      )
    end

    def process_payment(order)
      Payments::ProcessService.call(
        order: order,
        payment_params: payment_params
      )
    end

    def reserve_inventory(order)
      Inventory::ReservationService.call(order: order)
    end

    def refund_payment(payment)
      Payments::RefundService.call(payment: payment)
    end

    def finalize_order(order, payment)
      order.update!(payment: payment, status: :confirmed)
      OrderConfirmationJob.perform_later(order_id: order.id)
      cart.clear!
    end
  end
end

Each sub-service (Payments::ProcessService, Inventory::ReservationService) follows the same pattern, making them independently testable and reusable across different contexts.

Testing Service Objects

Service objects shine in testing because they have explicit inputs and outputs with no hidden dependencies:

# spec/services/users/registration_service_spec.rb
RSpec.describe Users::RegistrationService do
  describe ".call" do
    let(:valid_params) do
      {
        email: "[email protected]",
        password: "secure_password_123",
        password_confirmation: "secure_password_123",
        name: "Test User"
      }
    end

    context "with valid params" do
      it "creates a user and returns success" do
        result = described_class.call(params: valid_params)

        expect(result).to be_success
        expect(result.data).to be_a(User)
        expect(result.data).to be_persisted
      end

      it "creates default preferences" do
        expect {
          described_class.call(params: valid_params)
        }.to change(UserPreference, :count).by(1)
      end

      it "schedules a welcome email" do
        expect {
          described_class.call(params: valid_params)
        }.to have_enqueued_mail(UserMailer, :welcome)
      end
    end

    context "with invalid params" do
      let(:invalid_params) { valid_params.merge(email: "invalid") }

      it "returns failure with error message" do
        result = described_class.call(params: invalid_params)

        expect(result).to be_failure
        expect(result.error).to include("Email")
      end

      it "does not create any records" do
        expect {
          described_class.call(params: invalid_params)
        }.not_to change(User, :count)
      end
    end
  end
end

Common Mistakes to Avoid

Avoid services that do too much. If a service name includes "and" (like CreateUserAndSendEmailAndNotifyAdmin), it's doing too much. Break it into smaller, composable services.

Don't hide dependencies. Pass collaborators explicitly rather than instantiating them inside the service. This makes testing straightforward and dependencies visible.

Always return a result object. Returning true/false or raising exceptions for flow control makes calling code fragile. The result object pattern provides consistent handling across all services.

Keep services stateless. Services should not maintain state between calls. Each .call invocation should be independent.

Organizing Services in Rails 8

A clean directory structure keeps services discoverable:

app/services/
├── application_service.rb
├── users/
│   ├── registration_service.rb
│   ├── password_reset_service.rb
│   └── profile_update_service.rb
├── orders/
│   ├── checkout_service.rb
│   └── cancellation_service.rb
├── payments/
│   ├── process_service.rb
│   └── refund_service.rb
└── inventory/
    └── reservation_service.rb

Rails 8 autoloads everything in app/ subdirectories, so no configuration is needed. The namespace modules (like Users, Orders) keep related services grouped and prevent naming collisions.

Summary

Service objects provide a proven pattern for managing complex business logic in Rails applications. By following a consistent structure—single public method, explicit inputs, result objects—services become easy to write, test, and maintain. Start extracting services when controller actions exceed a dozen lines or when model callbacks grow unwieldy. The initial investment in structure pays dividends as applications scale.

10 claps
← Back to Blog