Service objects are a cornerstone of maintainable Ruby applications. They encapsulate business logic, reduce model and controller bloat, and make your codebase easier to test and scale. But without thoughtful implementation, they can become just another source of chaos.

This guide walks you through best practices for designing service objects that are clean, efficient, and maintainable. Whether you’re new to service objects or refining your approach, these tips will help you level up.

What Are Service Objects?

Service objects are Plain Old Ruby Objects (POROs) that encapsulate a single, specific piece of business logic. They handle complex workflows that might otherwise clutter controllers, models, or views.

Imagine you need to create a user, send a welcome email, and notify an admin. Instead of dumping that logic into a controller or model, you can use a service object like CreateUser to orchestrate these steps.

Why Use Service Objects?

  • Single Responsibility Principle: Keeps your code modular by isolating logic in its own class.
  • Testability: Makes it easier to write unit tests for specific business logic.
  • Readability: Simplifies controllers and models, making your app easier to understand.
  • Reusability: Encourages reusing logic across different parts of your app.

Best Practices for Service Objects

Let’s dive into actionable best practices that will help you design robust service objects.

Use Intuitive Naming

Choose descriptive, action-oriented names that reflect what the service does. This makes it easy to understand its purpose at a glance.

  • Good: CreateUser, SendWelcomeEmail, ProcessPayment
  • Bad: UserService, GeneralService

Organize by Context

Group service objects by their domain or context. This keeps your codebase organized and easy to navigate.

For example, in a directory structure:

app/services/
  users/
    create_user.rb
    update_user_profile.rb
  orders/
    process_order.rb
    cancel_order.rb

This structure makes it easy to locate related services, especially in larger projects.

Implement the call Method

The call method should be the primary entry point for executing the service. It provides a clear interface and makes services easy to use.

class CreateUser
  def initialize(params)
    @params = params
  end

  def call
    ActiveRecord::Base.transaction do
      user = create_user
      send_welcome_email(user)
      user
    end
  end

  private

  def create_user
    User.create!(@params)
  end

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

Usage:

user = CreateUser.new(params).call

Keep the Constructor Simple

The initialize method should only accept the parameters necessary for the service to do its job. Avoid passing in objects that can be fetched within the service.

Bad:

def initialize(user)
  @user = user
end

Good:

def initialize(user_id)
  @user = User.find(user_id)
end

This keeps the service focused and avoids unexpected behavior when objects are modified outside the service.

Expose Information with Readers

Use attr_reader to expose information about the operation’s result or status to the outside world. This ensures you don’t inadvertently expose internal state or logic.

class CreateUser
  attr_reader :user, :errors

  def initialize(params)
    @params = params
    @errors = []
  end

  def call
    @user = User.create(@params)
    unless @user.persisted?
      @errors = @user.errors.full_messages
    end
    @user.persisted?
  end
end

Break Logic into Private Methods

Each step of the process should have its own private method. This keeps the call method clean and allows you to reuse logic within the service.

def call
  ActiveRecord::Base.transaction do
    validate_input
    user = create_user
    notify_admin(user)
    user
  end
end

private

def validate_input
  raise "Invalid input" unless @params[:email]
end

def create_user
  User.create!(@params)
end

def notify_admin(user)
  AdminMailer.new_user_notification(user).deliver_now
end

Use Transactions for Multi-Record Operations

If your service modifies multiple database records, wrap the operations in a transaction to ensure atomicity.

def call
  ActiveRecord::Base.transaction do
    order = create_order
    charge_customer(order)
    order
  end
rescue StandardError => e
  Rails.logger.error(e.message)
  false
end

This ensures that if any step fails, the entire operation rolls back, maintaining data integrity.

Organize related services into modules to reflect the domain they operate in. This avoids clutter and makes the purpose of each service clear.

module Users
  class Create
    # ...
  end

  class UpdateProfile
    # ...
  end
end

You can then invoke them with clear, namespaced calls:

Users::Create.new(params).call

Implement a valid? Method

Add a valid? method to check if the service is ready to execute. This is especially useful for validating inputs before running expensive or irreversible operations.

class CreateUser
  attr_reader :errors

  def initialize(params)
    @params = params
    @errors = []
  end

  def valid?
    validate_email
    errors.empty?
  end

  def call
    return false unless valid?

    User.create!(@params)
  end

  private

  def validate_email
    if @params[:email].blank?
      @errors << "Email is required"
    elsif User.exists?(email: @params[:email])
      @errors << "Email already taken"
    end
  end
end

Final Thoughts

Service objects are a fantastic way to bring clarity and structure to your Ruby applications, but only if you use them thoughtfully. By following these best practices—clear naming, clean organization, a call method, simple constructors, and breaking down logic into private methods—you can create service objects that are powerful, reusable, and easy to maintain.

Now it’s your turn! What best practices do you use for service objects? Share your thoughts in the mailing list or reach out with your questions! 🚀

Have comments or want to discuss this topic?

Send an email to ~bounga/public-inbox@lists.sr.ht