Implementing Service Objects in Ruby - Best Practices
Learn how to use service objects in Ruby to keep your codebase clean, maintainable, and modular.
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.