Skip to content
ExponaBLOG
RUBY ON RAILS · DAY 042 / 100

Service objects in Rails: a hands-on guide for engineers

Where complex business logic lives.

Expona Admin6 min read

Service objects in Rails: a hands-on guide for engineers

Your Rails controller just grew from 20 lines to 80. The model callbacks are firing in ways nobody predicted. Business logic is scattered across three different files and the team is hesitant to touch any of it. This is where service objects become invaluable—they're not a Rails convention, but a proven pattern that keeps your codebase maintainable as workflows grow in complexity.

A service object is a plain Ruby class that encapsulates business logic, lives outside your model or controller, and typically handles a single responsibility. It's the middle ground between cramming logic into models and leaving it floating in controller actions. Over day 42 of a Rails development journey, we've reached the point where understanding service objects separates junior code from production-ready systems.

When to use—Workflows that touch many models

Service objects shine when you have a workflow that spans multiple models or integrates external systems. A classic example: processing an order. This isn't just creating an Order record. You need to:

  • Validate inventory across multiple SKUs
  • Calculate taxes and shipping
  • Reserve stock
  • Charge a payment processor
  • Send a confirmation email
  • Log the transaction for auditing

Each step touches a different part of your system. Without a service object, your controller becomes a 200-line god method, or you scatter logic across model callbacks that trigger unpredictably. A service object gives this workflow a single, testable home.

Consider another real-world scenario: bulk user imports. You're accepting a CSV, validating records, creating or updating users, assigning them to teams, and notifying relevant admins. This isn't model logic—it's orchestration. A service object handles it cleanly.

ruby
class ProcessOrderService
  def initialize(order_params, user)
    @order_params = order_params
    @user = user
  end

  def call
    order = Order.new(@order_params)
    
    return failure("Inventory unavailable") unless check_inventory
    return failure("Payment declined") unless process_payment(order)
    
    order.save!
    reserve_stock(order)
    send_confirmation_email(order)
    
    success(order)
  end

  private

  def check_inventory
    # Check stock across line items
  end

  def process_payment(order)
    # Call Stripe/payment gateway
  end
end

The controller then becomes simple:

ruby
def create
  result = ProcessOrderService.new(order_params, current_user).call
  
  if result.success?
    redirect_to result.data, notice: "Order placed"
  else
    render :new, alert: result.error
  end
end

Your controller doesn't need to know how payment is processed or inventory is checked. It just calls the service and handles the response. This makes the controller's intent crystal clear.

Shape—A class with a call method

The convention is straightforward: a service object is a class with a single public method, almost always named call. Some teams use execute or perform, but call is the Rails idiom and lets you invoke it as ServiceClass.new(...).call or even ServiceClass.call(...) if you add a class method.

The initialization method accepts the data your service needs. Dependencies like loggers, payment clients, or external APIs can be injected here or pulled from Rails environment. Here's a typical structure:

ruby
class SendInvitationService
  def initialize(event, recipient_email, invited_by_user)
    @event = event
    @recipient_email = recipient_email
    @invited_by_user = invited_by_user
  end

  def call
    # Orchestration happens here
    validate_recipient
    create_invitation
    send_email
    log_action
  end

  private

  def validate_recipient
    # Business logic
  end

  def create_invitation
    # Create or update records
  end

  def send_email
    # Queue a job or call mailer
  end

  def log_action
    # Audit trail
  end
end

Keep service classes focused. One responsibility per class. If your service object has 15 private methods, you've probably bundled too much. Extract smaller services and call them from your primary service instead.

Where should service files live? Create a app/services directory. Rails autoloads it by default. Name files to match the class: ProcessOrderService lives in app/services/process_order_service.rb. For large teams, nest them by domain: app/services/payments/charge_card_service.rb and app/services/inventory/reserve_stock_service.rb.

Return results—Success or failure objects

A service's return value matters. Returning true or false loses context about why something failed. Returning the object itself is ambiguous—did it succeed or not? The cleanest approach is a result object that encapsulates success, failure, and the data or error message.

You can build a simple result class:

ruby
class ServiceResult
  attr_reader :data, :error

  def initialize(success:, data: nil, error: nil)
    @success = success
    @data = data
    @error = error
  end

  def success?
    @success
  end

  def failure?
    !@success
  end
end

Then your service returns it:

ruby
class ProcessOrderService
  def call
    order = Order.new(@order_params)
    
    unless check_inventory
      return ServiceResult.new(success: false, error: "Inventory unavailable")
    end

    unless process_payment(order)
      return ServiceResult.new(success: false, error: "Payment declined")
    end

    order.save!
    ServiceResult.new(success: true, data: order)
  end

  private

  def success(data)
    ServiceResult.new(success: true, data: data)
  end

  def failure(error)
    ServiceResult.new(success: false, error: error)
  end
end

Many teams use gems like dry-monads or result to standardize this. But for most codebases, a lightweight result object is enough. The key is consistency: every service method always returns the same type of result. Your caller knows what to expect and can reliably check result.success? and access result.data or result.error.

This pattern also makes error handling explicit. Callers must decide how to handle failures—they can't accidentally ignore them like they might with a boolean return.

Do not overdo it—Simple logic belongs in models

Not everything needs a service object. If your logic is straightforward, belongs to a single model, and doesn't orchestrate across multiple systems, keep it in the model. Overusing services creates unnecessary indirection.

Ask yourself: Does this logic involve multiple models or external systems? If no, it probably belongs in the model as a method or scope. Calculating a user's age from their birthdate? Model method. Validating an email format? Model validation. Checking if an order is overdue? Model scope or instance method.

ruby
class User < ApplicationRecord
  def age
    Date.today.year - birthdate.year
  end

  def send_reminder
    # Simple: involves one model, one action
    ReminderMailer.send_later(self)
  end
end

But coordinating a complex user onboarding flow that creates accounts, sends emails, provisions API keys, and adds them to a CRM? That's a service:

ruby
class OnboardUserService
  def initialize(user_attributes, team)
    @user_attributes = user_attributes
    @team = team
  end

  def call
    user = User.create!(@user_attributes)
    @team.users << user
    provision_api_key(user)
    sync_to_crm(user)
    send_welcome_email(user)
    success(user)
  end
end

The line is fuzzy, but the principle is clear: services orchestrate; models encapsulate single-entity logic. As your codebase grows, this distinction keeps complexity manageable.

Common mistakes

  • Creating services for trivial logic. A service that only creates one record or runs one validation adds no value. It's overhead without benefit.
  • Making services stateful. Avoid instance variables that change during call. Keep your service predictable: initialize once, call once, get a result. If you need multiple steps with changing state, you're probably orchestrating multiple services, not writing one.
  • Putting all business logic in services and leaving models anemic. Models should still contain their domain logic. Services orchestrate between models, not replace them.
  • Services that don't return consistent results. If some paths return an object, others return true/false, and still others return nil, your caller code becomes defensive and ugly. Always return a result object.
  • Services that talk to too many external systems without clear boundaries. If a service calls five different APIs and has ten error paths, it's too big. Break it into smaller services, each handling one integration.

Key takeaways

  • Service objects encapsulate multi-model workflows and external integrations, keeping controllers and models clean.
  • Follow the convention: a class with an initialize and a call method, placed in app/services.
  • Always return a consistent result object that indicates success or failure with relevant data or error messages.
  • Use services for orchestration; keep simple, single-model logic in your models.
  • Test services independently, without instantiating controllers or running the full stack—they're fast to test and easy to reason about.
  • Name services after the action they perform: ProcessOrderService, SendInvitationService, SyncUserToCrmService.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles