If you're a Rails developer, you've probably seen (or written) code that looks like this:
class UserService
def self.create(params)
user = User.new(params)
if user.save
UserMailer.welcome_email(user).deliver_later
user
else
false
end
end
end
# In your controller
def create
@user = UserService.create(user_params)
if @user
redirect_to @user, notice: 'User was successfully created.'
else
render :new
end
end
Look familiar? We've all been there. We've all written these service objects because "that's what good Rails developers do." But let me ask you something: What value did that service layer actually add?
We've been told that service layers:
- Separate business logic from models
- Make code more testable
- Keep controllers thin
- Follow single responsibility principle
When Your Service is Just Extra Typing
Many Rails codebases are littered with service objects that do nothing but proxy method calls to ActiveRecord models. Let's look at a real-world example:
class CommentService
def self.create_for_post(post, user, content)
Comment.create(
post: post,
user: user,
content: content
)
end
end
What did we gain here? Nothing except an extra file to maintain and an additional layer to jump through while debugging. The service is literally just passing through parameters to Comment.create. Even worse, we've actually lost functionality compared to working with the model directly - we no longer have access to ActiveRecord's rich API for handling validation errors and callbacks.
When You Actually Need a Service Layer
Let's be clear: Service objects aren't always bad. (In fact I've written a separate article on rethinking service objects: https://dev.to/alexander_shagov/ruby-on-rails-rethinking-service-objects-4l0b)
1. Orchestrating Complex Operations
module Orders
class ProcessingWorkflow
include Dry::Transaction
step :start_processing
step :process_payment
step :allocate_inventory
step :create_shipping_label
step :send_confirmation
step :complete_processing
private
def start_processing(input)
order = input[:order]
order.update!(status: 'processing')
Success(input)
rescue => e
Failure([:processing_failed, e.message])
end
def process_payment(input)
order = input[:order]
payment = Payments::Gateway.new.charge(
amount: order.total,
token: order.payment_token
)
Success(input.merge(payment: payment))
rescue => e
Failure([:payment_failed, e.message])
end
def allocate_inventory(input)
# ...
end
def create_shipping_label(input)
# ...
end
def send_confirmation(input)
OrderMailer.confirmation(input[:order]).deliver_later
Success(input)
rescue => e
Failure([:notification_failed, e.message])
end
def complete_processing(input)
order = input[:order]
order.update!(status: 'processed')
Success(input)
rescue => e
Failure([:completion_failed, e.message])
end
end
end
2. Handling External Services
module Subscriptions
class StripeWorkflow
include Dry::Transaction
step :validate_subscription
step :create_stripe_customer
step :create_stripe_subscription
step :update_user_records
private
def validate_subscription(input)
contract = Subscriptions::ValidationContract.new
result = contract.call(input)
result.success? ? Success(input) : Failure([:validation_failed, result.errors])
end
def create_stripe_customer(input)
# ... stripe code
end
def create_stripe_subscription(input)
# ... stripe code
end
def update_user_records(input)
user = input[:user]
user.update!(
stripe_customer_id: input[:customer].id,
stripe_subscription_id: input[:subscription].id,
subscription_status: input[:subscription].status
)
Success(input)
rescue => e
Failure([:record_update_failed, e.message])
end
end
end
3. Complex Business Rules
module Loans
class ApplicationProcess
include Dry::Transaction
step :validate_application
step :check_credit_score
step :evaluate_debt_ratio
step :calculate_risk_score
step :determine_approval
step :process_result
private
def validate_application(input)
contract = Loans::ApplicationContract.new
result = contract.call(input)
result.success? ? Success(input) : Failure([:validation_failed, result.errors])
end
def check_credit_score(input)
application = input[:application]
if application.credit_score < 600
Failure([:credit_score_too_low, "Credit score below minimum requirement"])
else
Success(input)
end
end
def evaluate_debt_ratio(input)
calculator = Loans::DebtRatioCalculator.new(input[:application])
ratio = calculator.compute
if ratio > 0.43
Failure([:debt_ratio_too_high, "Debt-to-income ratio exceeds maximum"])
else
Success(input.merge(debt_ratio: ratio))
end
end
def calculate_risk_score(input)
# ...
end
def determine_approval(input)
# ...
end
def process_result(input)
application = input[:application]
if input[:approved]
rate_calculator = Loans::InterestRateCalculator.new(
application: application,
risk_score: input[:risk_score]
)
application.update!(
status: 'approved',
interest_rate: rate_calculator.compute,
approval_date: Time.current
)
LoanMailer.approval_notice(application).deliver_later
else
application.update!(status: 'rejected')
LoanMailer.rejection_notice(application).deliver_later
end
Success(input)
rescue => e
Failure([:processing_failed, e.message])
end
end
end
The examples above represent what I consider valid uses of a service layer - or more accurately, business processes. They demonstrate clear cases where abstraction adds real value: complex workflows, external service integration, and domain-rich business rules and etc.
But the key takeaway isn't just about when to use these patterns - it's about questioning our default approaches to architecture. Too often, we reach for service objects because "that's how it's done" or because we've read that "fat models are bad." Instead, we should:
- Start simple - directly in models and controllers
- Let complexity guide abstraction - not the other way around
- Think in terms of processes and workflows rather than generic "services"
- Question established patterns - just because everyone's doing it doesn't make it right for your specific case
! More importantly, if you're part of a large team, establish and agree on a unified approach first. Having half your codebase using traditional service objects and the other half using process-oriented workflows will likely create more problems than it solves. Architectural decisions should be team decisions, backed by clear conventions and documentation.
Remember: Every layer of abstraction is a trade-off. Make sure you're getting enough value to justify the complexity cost.
Top comments (4)
I agree, in principle.
However, in my experience, context of the team matters. In you
CommentsService
example, just having a service that's a straightforward proxy to a model method does not a lot of sense. Now. But it's easy to imagine, that there will come email notifications about new comments and checking for spam before adding a new comment.If you have a strong team, they will recognize that this is the point when you should extract a new service and put it there. If you have a mediocre team or bad management giving unrealistic deadlines, devs practicing least-resistance development methodology will not find a readily-available place to put this new logic, so they will put it in the controller or, God forbid, in model callbacks. This is why I think sometimes it's better to step out of line and prepare the service beforehand, even if it's just a pass-through.
Thanks for the comment, Pawel
For sure, if it's clear that a place in the codebase will likely be extended with additional business logic, preparing beforehand makes perfect sense. But this comment comes from your experience - it's not a blind choice but a calculated one, we can often "feel" when a place is prone to growth. Also true it makes sense to "prepare the ground" for inexperienced team so that they don't start doing the messy stuff automatically just because there's no abstraction in place.
A lot of variables here for sure.
Very good post. I definitely like service objects, but 100% agree with there needing to be a why
Thanks @ben !