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) # ..

Jan 15, 2025 - 11:22
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 < 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.