← Todos los posts

Service objects en Rails: cuándo ayudan y cuándo son solo indirection

TL;DR

  • Los service objects resuelven fat models y controladores con lógica de negocio — ese es el problema real
  • Hay múltiples formas: callable objects (call), command pattern, interactors con resultado — elige según la complejidad
  • Un módulo con un método a veces es suficiente — no todo necesita ser una clase
  • El anti-patrón: un laberinto de clases CreateUserService, UpdateUserService, DeleteUserService donde cada una tiene un solo método y no hay cohesión
  • La pregunta clave: ¿se puede testear en aislamiento? Si sí, el service object está haciendo su trabajo

La comunidad Rails lleva una década peleando sobre service objects y ambos bandos tienen razón a veces. El bando pro: “los modelos se ponen gordos y los controllers mezclan lógica de negocio”. El bando anti: “ahora tengo 60 clases *Service de una sola línea que nadie puede navegar”. Ambos han visto código de prod que les da la razón.

Aquí mi take después de 10 años: la herramienta no es el problema. El problema es no saber cuándo usarla.

El problema que service objects resuelven

Fat model:

class Order < ApplicationRecord
  belongs_to :user
  has_many :items

  # La lógica de negocio está mezclada con ActiveRecord
  def checkout(payment_params)
    validate_items_in_stock!

    charge = Stripe::Charge.create(
      amount: total_in_cents,
      currency: 'usd',
      source: payment_params[:token],
      customer: user.stripe_customer_id,
    )

    update!(status: :paid, stripe_charge_id: charge.id)
    items.each { |item| item.product.decrement!(:stock, item.quantity) }
    OrderMailer.confirmation(self).deliver_later
    LoyaltyProgram.award_points(user, total)

    charge
  rescue Stripe::CardError => e
    update!(status: :failed)
    raise
  end
end

El modelo Order ahora sabe de Stripe, de inventario, de emails, y de loyalty points. Testear esto requiere stubear todo. Agregar una nueva acción al checkout requiere entender todo el método.

El service object extrae esa lógica:

# El modelo queda limpio
class Order < ApplicationRecord
  belongs_to :user
  has_many :items

  def total_in_cents
    items.sum { |item| item.quantity * item.unit_price_cents }
  end

  def validate_items_in_stock!
    items.each do |item|
      raise InsufficientStock unless item.product.stock >= item.quantity
    end
  end
end

# La lógica de negocio vive en el service
class OrderCheckoutService
  def initialize(order, payment_params)
    @order = order
    @payment_params = payment_params
  end

  def call
    @order.validate_items_in_stock!
    charge = process_payment
    complete_order(charge)
    send_notifications
    charge
  end

  private

  def process_payment
    Stripe::Charge.create(
      amount: @order.total_in_cents,
      currency: 'usd',
      source: @payment_params[:token],
      customer: @order.user.stripe_customer_id,
    )
  rescue Stripe::CardError
    @order.update!(status: :failed)
    raise
  end

  def complete_order(charge)
    @order.update!(status: :paid, stripe_charge_id: charge.id)
    @order.items.each { |item| item.product.decrement!(:stock, item.quantity) }
  end

  def send_notifications
    OrderMailer.confirmation(@order).deliver_later
    LoyaltyProgram.award_points(@order.user, @order.total)
  end
end
# El controller queda limpio
class OrdersController < ApplicationController
  def checkout
    result = OrderCheckoutService.new(@order, payment_params).call
    redirect_to order_path(@order), notice: 'Pago procesado'
  rescue Stripe::CardError => e
    redirect_to cart_path, alert: "Error de pago: #{e.message}"
  rescue InsufficientStock
    redirect_to cart_path, alert: 'Algunos productos están agotados'
  end
end

Las formas de implementarlo

Callable object — el más simple

class UserRegistrationService
  def self.call(params)
    new(params).call
  end

  def initialize(params)
    @params = params
  end

  def call
    user = User.create!(@params)
    WelcomeMailer.send_welcome(user).deliver_later
    user
  end
end

# Uso
UserRegistrationService.call(user_params)

El método de clase call como delegador es una convención popular — te permite llamar UserRegistrationService.(params) usando la sintaxis de callable de Ruby.

Con resultado explícito — para manejar éxito/fallo

class OrderCheckoutService
  Result = Struct.new(:success?, :order, :error, keyword_init: true)

  def call
    @order.validate_items_in_stock!
    charge = process_payment
    complete_order(charge)

    Result.new(success?: true, order: @order)
  rescue Stripe::CardError => e
    Result.new(success?: false, order: @order, error: e.message)
  rescue InsufficientStock => e
    Result.new(success?: false, order: @order, error: e.message)
  end
end

# En el controller — más explícito que rescuear excepciones
result = OrderCheckoutService.new(@order, payment_params).call

if result.success?
  redirect_to order_path(result.order)
else
  flash[:alert] = result.error
  render :checkout
end

Este patrón de resultado explícito es útil cuando quieres que el controller maneje múltiples tipos de fallo sin rescuear excepciones.

Interactors (la gema)

# Gemfile
gem 'interactor'

class CheckoutOrder
  include Interactor

  def call
    order = context.order

    charge_card
    update_inventory
    send_confirmation

    context.receipt = generate_receipt
  rescue Stripe::CardError => e
    context.fail!(message: "Pago rechazado: #{e.message}")
  end

  private

  def charge_card
    context.charge = Stripe::Charge.create(...)
  end
end

# Organizar en cadenas
class PlaceOrder
  include Interactor::Organizer
  organize CheckoutOrder, UpdateInventory, SendConfirmationEmail
  # Si alguno falla, los anteriores hacen rollback
end

result = PlaceOrder.call(order: @order, payment_params: payment_params)
result.success?  # => true/false
result.message   # => mensaje de error si falló

Interactor es útil cuando tienes flujos complejos con múltiples pasos y necesitas rollback si alguno falla. Para flujos simples, es overhead.

Cuándo un módulo es suficiente

No todo necesita una clase. Si la “lógica de negocio” es una función pura sin estado:

# MAL — service object innecesario para algo sin estado
class PriceCalculatorService
  def initialize(items, discount_code)
    @items = items
    @discount_code = discount_code
  end

  def call
    subtotal = @items.sum(&:price)
    discount = DiscountCode.find_by(code: @discount_code)&.percentage || 0
    subtotal * (1 - discount / 100.0)
  end
end

# BIEN — módulo con método, sin estado
module PriceCalculator
  def self.calculate(items, discount_code: nil)
    subtotal = items.sum(&:price)
    discount = DiscountCode.find_by(code: discount_code)&.percentage || 0
    subtotal * (1 - discount / 100.0)
  end
end

PriceCalculator.calculate(@order.items, discount_code: params[:code])

La distinción: si el “service” no tiene estado (no guarda variables de instancia entre llamadas), probablemente es un módulo.

El anti-patrón: service objects como indirection vacía

# Esto no ayuda a nadie
class CreateUserService
  def initialize(params)
    @params = params
  end

  def call
    User.create!(@params)  # ← esto es todo, sin lógica adicional
  end
end

# Si esto es todo lo que hace, escríbelo directo en el controller
User.create!(user_params)

Un service object que es solo un wrapper de una sola operación de ActiveRecord no agrega valor — agrega una capa de indirection que alguien tiene que navegar cuando lee el código. El código “limpio” que hace más difícil seguir el flujo no es más limpio.

La pregunta para decidir: ¿este service object tiene lógica de negocio real, o solo está llamando a otro método? Si es lo segundo, no lo necesitas.

Testing: donde los service objects muestran su valor

RSpec.describe OrderCheckoutService do
  describe '#call' do
    let(:user) { create(:user, :with_stripe_customer) }
    let(:order) { create(:order, :with_items, user: user) }

    before do
      stub_stripe_charge(amount: order.total_in_cents)
    end

    it 'marks order as paid' do
      described_class.new(order, token: 'tok_visa').call
      expect(order.reload.status).to eq('paid')
    end

    it 'decrements product stock' do
      product = order.items.first.product
      expect {
        described_class.new(order, token: 'tok_visa').call
      }.to change { product.reload.stock }.by(-order.items.first.quantity)
    end

    it 'enqueues confirmation email' do
      expect {
        described_class.new(order, token: 'tok_visa').call
      }.to have_enqueued_mail(OrderMailer, :confirmation)
    end

    context 'when card is declined' do
      before { stub_stripe_charge_failure }

      it 'marks order as failed' do
        expect { described_class.new(order, token: 'tok_fail').call }.to raise_error(Stripe::CardError)
        expect(order.reload.status).to eq('failed')
      end
    end
  end
end

El test del service object no necesita saber nada del controller. Prueba la lógica de negocio de forma aislada. Eso es el valor real.


Los service objects son una herramienta, no una religión ni una señal de madurez arquitectónica. Úsalos cuando tienes lógica de negocio compleja que mezcla múltiples responsabilidades y necesitas testarla en aislamiento. Evítalos cuando son solo wrappers de una operación simple.

Y si tu app tiene 50 clases *Service de 5 líneas cada una que nadie puede navegar — no ganaste arquitectura limpia, ganaste indirection sin beneficio. El código “limpio” que hace más difícil seguir el flujo no es más limpio.