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 !