banner

Service objects are a proven design pattern in Ruby for encapsulating complex business logic. They help keep your controllers and models lean, promoting maintainability and clarity in your applications.

This post explores how to implement service objects effectively, ensuring your Ruby codebase remains modular and easy to extend.


What Are Service Objects?

Service objects are plain Ruby objects (POROs) that encapsulate a specific piece of business logic. Instead of cluttering your models and controllers, service objects focus on a single responsibility, adhering to the Single Responsibility Principle (SRP) of clean architecture.

Benefits of Service Objects:

  • Decouple business logic from models and controllers.
  • Improve testability with isolated logic.
  • Enhance readability by dividing complex workflows into smaller, manageable pieces.

When to Use Service Objects

Use service objects when:

  • A piece of logic doesn’t belong directly in a model or controller.
  • You have a workflow that spans multiple models or external services.
  • You want to improve the readability and maintainability of your code.

Examples include:

  • Payment processing
  • File uploads
  • Background job orchestration
  • API integrations

Anatomy of a Service Object

A service object typically consists of:

  • Initialization: Accept required inputs as arguments.
  • Execution: A public method (often call) that performs the logic.
  • Error Handling: Clear handling of exceptions or errors.

Here’s a simple structure:

class MyService
def initialize(arg1, arg2)
@arg1 = arg1
@arg2 = arg2
end

def call
# Business logic here
end
end

Best Practices for Implementing Service Objects

1. Focus on a Single Responsibility

Service objects should encapsulate only one piece of business logic. If you find yourself adding multiple responsibilities, it’s a sign that the service object should be split.

Example: User Registration

class UserRegistrationService
def initialize(user_params)
@user_params = user_params
end

def call
user = User.create!(@user_params)
send_welcome_email(user)
user
end

private

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

2. Use a Consistent Interface

Define a standard method (call) for executing the service. This ensures consistency and makes chaining service objects easier.

result = MyService.new(arg1, arg2).call

3. Handle Errors Gracefully

Use exceptions or custom error classes to handle failures cleanly.

class MyService
def call
raise CustomError, "Something went wrong" unless valid_condition?

    # Logic here
end
end

4. Avoid Dependency on External State

Service objects should be self-contained. Avoid accessing global variables or external dependencies directly.

Instead, pass all dependencies through the initializer:

class PaymentProcessingService
def initialize(order, payment_gateway)
@order = order
@payment_gateway = payment_gateway
end

def call
@payment_gateway.charge(@order.total)
end
end

5. Test Service Objects Independently

Isolated logic makes service objects highly testable. Write unit tests for service objects to validate their behavior without coupling to the rest of the application.


Advanced Service Object Patterns

1. Returning Results with a Service Result Object

Using a result object provides clarity when communicating success or failure.

class Result
attr_reader :data, :error

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

def success?
@success
end
end

class UserRegistrationService
def call
user = User.create(@user_params)
return Result.new(success: false, error: "Validation failed") unless user.persisted?

    send_welcome_email(user)
    Result.new(success: true, data: user)
end
end

2. Using Dry-Rb for Service Objects

The dry-rb ecosystem offers gems like dry-monads to simplify service object implementation with monads like Result.

class UserRegistrationService
include Dry::Monads[:result]

def call(user_params)
user = User.create(user_params)
return Failure(:validation_failed) unless user.persisted?

    send_welcome_email(user)
    Success(user)
end
end

3. Composing Service Objects

Chain multiple service objects together to handle complex workflows.

class CheckoutService
def call(order_params)
order = CreateOrderService.new(order_params).call
ProcessPaymentService.new(order).call
SendConfirmationEmailService.new(order).call
end
end

Common Pitfalls to Avoid

  • Overloading Responsibilities: Avoid turning service objects into “god objects” by cramming multiple workflows into one class.
  • Skipping Tests: Service objects are easy to test—leverage this advantage.
  • Skipping Documentation: Document the purpose and interface of each service object for better team collaboration.

Conclusion

Service objects in Ruby provide a clean and modular way to handle business logic. By adhering to the best practices outlined here, you can ensure your codebase remains maintainable, scalable, and testable. Whether you’re building a small application or scaling for enterprise, service objects are an invaluable tool in your Ruby arsenal.