DEV Community

Alexander Shagov
Alexander Shagov

Posted on

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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
katafrakt profile image
Paweł Świątkowski

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.

Collapse
 
alexander_shagov profile image
Alexander Shagov

Thanks for the comment, Pawel

I think sometimes it's better to step out of line and prepare the service beforehand

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.

Collapse
 
ben profile image
Ben Halpern

Very good post. I definitely like service objects, but 100% agree with there needing to be a why

Collapse
 
alexander_shagov profile image
Alexander Shagov

Thanks @ben !