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.
Group Related Services
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! 🚀
Share on
Twitter Facebook LinkedInHave comments or want to discuss this topic?
Send an email to ~bounga/public-inbox@lists.sr.ht