Ruby on Rails: Your Service Layer is a Lie
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 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) # ..
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.