35+ Years Experience Netherlands Based ⚡ Fast Response Times Ruby on Rails Experts AI-Powered Development Fixed Pricing Available Senior Architects Dutch & English 35+ Years Experience Netherlands Based ⚡ Fast Response Times Ruby on Rails Experts AI-Powered Development Fixed Pricing Available Senior Architects Dutch & English
Rails Service Objects: Patterns That Actually Work in Production

Rails Service Objects: Patterns That Actually Work in Production

TTB Software
rails
How to build Rails service objects that stay maintainable. Real patterns, naming conventions, error handling, and testing strategies from production codebases.

Your Rails controllers are getting fat. Your models know too much. You’ve read that service objects are the answer, but every blog post shows a different pattern and none of them address what happens when your service needs to call another service that calls a third service and something fails halfway through.

Here’s what actually works after running service objects in production Rails apps (Rails 7.1+ and 8.0) for years.

What a Service Object Actually Is

A service object is a plain Ruby class responsible for a single business operation. It takes input, does one thing, and returns a result. That’s it.

The key word is operation, not thing. UserCreator is a service. UserHelper is not — that’s just a junk drawer with a class name.

# app/services/users/create.rb
module Users
  class Create
    def initialize(params:, invited_by: nil)
      @params = params
      @invited_by = invited_by
    end

    def call
      user = User.new(@params)

      ActiveRecord::Base.transaction do
        user.save!
        Onboarding::SendWelcomeEmail.new(user: user).call
        track_referral(user) if @invited_by
      end

      Result.success(user)
    rescue ActiveRecord::RecordInvalid => e
      Result.failure(e.record.errors)
    end

    private

    def track_referral(user)
      Referrals::Track.new(referrer: @invited_by, referred: user).call
    end
  end
end

This handles user creation, welcome email, and referral tracking in one transaction. The controller stays thin:

class UsersController < ApplicationController
  def create
    result = Users::Create.new(params: user_params).call

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

The Result Object Pattern

Every service needs to communicate success or failure. Returning true/false works until you need error messages. Raising exceptions works until you need to handle expected failures gracefully. A simple result object handles both:

# app/services/result.rb
class Result
  attr_reader :value, :errors

  def initialize(success:, value: nil, errors: nil)
    @success = success
    @value = value
    @errors = errors
  end

  def success? = @success
  def failure? = !@success

  def self.success(value = nil)
    new(success: true, value: value)
  end

  def self.failure(errors)
    new(success: false, errors: Array(errors))
  end
end

Thirty lines. No gems. You own the code. I’ve seen teams install dry-monads or interactor for this and spend more time learning the gem API than writing business logic.

If you do want a gem, dry-monads is solid for teams comfortable with functional patterns. But start simple. You can always add complexity when you actually need it.

Naming and File Organization

Put services in app/services/, namespaced by domain:

app/services/
├── result.rb
├── users/
│   ├── create.rb
│   ├── deactivate.rb
│   └── merge_accounts.rb
├── orders/
│   ├── place.rb
│   ├── cancel.rb
│   └── refund.rb
└── reports/
    ├── generate_monthly.rb
    └── export_csv.rb

Name services with verbs, not nouns. Orders::Place, not OrderPlacer. Users::Deactivate, not UserDeactivationService. The Service suffix is redundant — everything in app/services/ is a service.

Rails autoloading picks up app/services/ by default in Rails 7+ with Zeitwerk. No configuration needed.

When to Extract a Service Object

Not everything needs a service. Here’s the decision framework I use:

Extract to a service when:

  • The operation involves multiple models or external systems
  • The logic doesn’t belong to any single model
  • The same operation is triggered from multiple places (controller, background job, console)
  • The operation has complex error handling or rollback logic

Keep it in the model when:

  • It’s a single-model validation or callback
  • It’s a query scope
  • It’s a simple calculation on model attributes

Keep it in the controller when:

  • It’s just parameter filtering and a single model save
  • There’s no business logic beyond CRUD

The worst service objects I’ve seen are ones that wrap User.create(params) in a class. That’s not abstraction — it’s indirection.

Error Handling That Doesn’t Collapse

The tricky part is service composition — when services call other services. Here’s what goes wrong and how to fix it.

Problem: Exception Cascading

# This breaks badly when SendWelcomeEmail raises
module Users
  class Create
    def call
      ActiveRecord::Base.transaction do
        user.save!
        Onboarding::SendWelcomeEmail.new(user: user).call  # raises!
      end  # transaction rolls back
      Result.success(user)
    rescue StandardError => e
      Result.failure(e.message)  # swallows everything
    end
  end
end

Rescuing StandardError swallows bugs. Rescuing nothing means callers deal with raw exceptions. The fix:

def call
  user = User.new(@params)

  ActiveRecord::Base.transaction do
    user.save!
    email_result = Onboarding::SendWelcomeEmail.new(user: user).call
    raise ActiveRecord::Rollback unless email_result.success?
  end

  if user.persisted?
    Result.success(user)
  else
    Result.failure("User creation failed — email delivery issue")
  end
rescue ActiveRecord::RecordInvalid => e
  Result.failure(e.record.errors.full_messages)
end

Only rescue exceptions you expect. Let bugs bubble up. Use ActiveRecord::Rollback for controlled transaction failures.

Problem: Non-Critical Failures Blocking Critical Operations

Sometimes a service calls another service that isn’t essential. A welcome email failing shouldn’t prevent user creation:

def call
  user = User.new(@params)

  ActiveRecord::Base.transaction do
    user.save!
  end

  # Outside the transaction — non-critical
  begin
    Onboarding::SendWelcomeEmail.new(user: user).call
  rescue => e
    Rails.logger.error("Welcome email failed for user #{user.id}: #{e.message}")
    ErrorTracker.notify(e)
  end

  Result.success(user)
rescue ActiveRecord::RecordInvalid => e
  Result.failure(e.record.errors.full_messages)
end

Or better — push non-critical work to a background job:

def call
  user = User.new(@params)
  user.save!
  OnboardingMailer.welcome(user).deliver_later
  Result.success(user)
rescue ActiveRecord::RecordInvalid => e
  Result.failure(e.record.errors.full_messages)
end

Testing Service Objects

Services are easy to test because they’re plain Ruby. No controller context, no view rendering:

# test/services/users/create_test.rb
require "test_helper"

class Users::CreateTest < ActiveSupport::TestCase
  test "creates user with valid params" do
    result = Users::Create.new(
      params: { email: "test@example.com", name: "Test User", password: "secure123" }
    ).call

    assert result.success?
    assert_equal "test@example.com", result.value.email
  end

  test "returns failure with invalid params" do
    result = Users::Create.new(params: { email: "" }).call

    assert result.failure?
    assert_includes result.errors.first, "Email"
  end

  test "rolls back everything on failure" do
    assert_no_difference "User.count" do
      Users::Create.new(params: { email: "bad" }).call
    end
  end

  test "tracks referral when invited_by is present" do
    referrer = users(:alice)

    assert_difference "Referral.count" do
      Users::Create.new(
        params: { email: "new@example.com", name: "New", password: "secure123" },
        invited_by: referrer
      ).call
    end
  end
end

Test the public interface: call with inputs, assert on the result. Don’t test private methods directly.

The .call Convention

You’ll see three patterns in the wild:

# Instance method (my preference)
Users::Create.new(params: params).call

# Class method delegating to instance
Users::Create.call(params: params)

# Proc-style with .()
Users::Create.(params: params)

I use instance methods because constructor injection makes dependencies explicit and testable. The class method shorthand works fine too — pick one and be consistent across your codebase.

If you want the class method sugar:

module Callable
  extend ActiveSupport::Concern

  class_methods do
    def call(...)
      new(...).call
    end
  end
end

Include it in your services and you get both options.

Real-World Complexity: Order Processing

Here’s a production-style service that handles a multi-step order flow:

# app/services/orders/place.rb
module Orders
  class Place
    def initialize(cart:, payment_method:, user:)
      @cart = cart
      @payment_method = payment_method
      @user = user
    end

    def call
      return Result.failure("Cart is empty") if @cart.empty?
      return Result.failure("Insufficient inventory") unless inventory_available?

      order = nil

      ActiveRecord::Base.transaction do
        order = build_order
        order.save!
        reserve_inventory!(order)
        charge = process_payment!(order)
        order.update!(payment_reference: charge.id)
      end

      # Post-transaction side effects
      OrderMailer.confirmation(order).deliver_later
      Analytics.track("order_placed", order_id: order.id, total: order.total)

      Result.success(order)
    rescue Payments::CardDeclined => e
      Result.failure("Payment declined: #{e.message}")
    rescue Inventory::ReservationFailed => e
      Result.failure("Item unavailable: #{e.message}")
    rescue ActiveRecord::RecordInvalid => e
      Result.failure(e.record.errors.full_messages)
    end

    private

    def inventory_available?
      @cart.line_items.all? { |item| item.product.stock >= item.quantity }
    end

    def build_order
      Order.new(
        user: @user,
        line_items: @cart.line_items.map(&:dup),
        total: @cart.total,
        status: :pending
      )
    end

    def reserve_inventory!(order)
      order.line_items.each do |item|
        Inventory::Reserve.new(product: item.product, quantity: item.quantity).call
      end
    end

    def process_payment!(order)
      Payments::Charge.new(
        amount: order.total,
        payment_method: @payment_method,
        description: "Order ##{order.id}"
      ).call
    end
  end
end

Each step is its own method or service. The transaction wraps the critical path. Side effects (email, analytics) happen after the transaction commits. Specific exceptions map to user-facing error messages.

Common Mistakes

The God Service. A service that does ten things isn’t a service — it’s a procedure wearing a class costume. If your call method is over 30 lines, break it up.

Testing internals. Don’t mock reserve_inventory! and process_payment! in isolation. Test the service end-to-end. Use VCR or WebMock for external APIs, but test the integration.

Returning mixed types. If call sometimes returns a User, sometimes returns nil, and sometimes raises — pick a pattern. The Result object eliminates this entire class of bugs.

Skipping the transaction. If your service writes to multiple tables, wrap it in a transaction. I’ve seen production databases with orphaned records because someone forgot this.

FAQ

Should I use a gem like Interactor or Trailblazer for service objects?

For most Rails apps, plain Ruby classes with a Result object are enough. Interactor adds a context object and hooks (before/after/around) that create coupling between steps. Trailblazer is a full architectural framework — useful if you’re building something complex, but heavy for typical CRUD apps. Start with plain classes. Introduce a gem only when you hit specific pain that the gem solves.

How do service objects relate to Rails concerns?

They solve different problems. Concerns share behavior across models (like Taggable or Searchable). Service objects encapsulate business operations that span multiple models or external systems. A concern shouldn’t call a service, and a service shouldn’t include a concern. Keep them separate.

Should service objects be called from model callbacks?

No. Model callbacks should handle model-internal consistency (setting defaults, maintaining counter caches). Calling services from callbacks creates hidden side effects that make debugging painful and testing fragile. Trigger services from controllers or background jobs where the intent is explicit.

How do I handle authorization in service objects?

Pass the current user as an argument and check permissions early:

def call
  return Result.failure("Not authorized") unless @user.can_manage_orders?
  # ... rest of the operation
end

Don’t reach for Current.user inside services — it creates an implicit dependency that breaks in background jobs and tests. Explicit arguments are always easier to reason about.

What’s the difference between a service object and a form object?

A form object handles input validation and coercion for a specific form. A service object handles business logic for a specific operation. They can work together: the controller passes validated form data to a service. If your service is doing a lot of validation, extract a form object using ActiveModel::Model.

T

About the Author

Roger Heykoop is a senior Ruby on Rails developer with 19+ years of Rails experience and 35+ years in software development. He specializes in Rails modernization, performance optimization, and AI-assisted development.

Get in Touch

Share this article

Need Expert Rails Development?

Let's discuss how we can help you build or modernize your Rails application with 19+ years of expertise

Schedule a Free Consultation