Service Objects en Rails 2026: Design Patterns, PORO y Preguntas de Entrevista Técnica

Los service objects de Rails extraen la lógica de negocio de controladores y modelos hacia clases Ruby simples. Esta guía cubre patrones de diseño, implementaciones PORO y las preguntas de entrevista técnica más frecuentes.

Service Objects de Rails y Patrones de Diseño PORO

Los service objects de Rails extraen la lógica de negocio de controladores y modelos hacia Plain Old Ruby Objects (POROs). Este patrón mantiene las aplicaciones Rails mantenibles a medida que crecen, y los reclutadores lo evalúan frecuentemente para medir la comprensión de arquitectura limpia de los candidatos.

Lo que los reclutadores esperan

Un service object encapsula una única operación de negocio. La clase tiene un solo método público (típicamente call), acepta dependencias a través de su constructor, y retorna un resultado predecible. Los candidatos que pueden articular por qué esto importa, no solo cómo implementarlo, se destacan.

Por Qué los Service Objects Resuelven los Problemas de Fat Model y Fat Controller

Rails incentiva colocar la lógica en algún lugar. Sin orientación, los equipos la empujan hacia los modelos ("fat models, skinny controllers") hasta que las clases ActiveRecord alcanzan miles de líneas. Otros la dejan en los controladores, haciendo las acciones imposibles de testear de forma aislada.

Los service objects ofrecen un tercer camino: la lógica de dominio reside en clases dedicadas que no dependen de nada de Rails excepto lo que explícitamente requieren. Este desacoplamiento hace el código testeable sin cargar el framework completo, portable a través de diferentes puntos de entrada (controladores, jobs en background, consola), y legible porque cada clase hace una sola cosa.

Las Rails Guides no prescriben service objects, pero la comunidad se ha estandarizado alrededor del patrón. El énfasis de Rails 8 en la simplicidad, con Solid Queue y Solid Cache reemplazando dependencias externas, hace los POROs aún más atractivos: menos gems, más Ruby puro.

Anatomía de un Service Object Listo para Producción

Un service object necesita tres cosas: un constructor que recibe dependencias, un único método público call, y un tipo de retorno que señale éxito o fallo.

ruby
# app/services/users/create_account.rb
module Users
  class CreateAccount
    def initialize(user_repo: User, mailer: UserMailer)
      @user_repo = user_repo
      @mailer = mailer
    end

    def call(params)
      user = @user_repo.new(params)
      return Result.failure(:validation_failed, user.errors) unless user.valid?

      user.save!
      @mailer.welcome_email(user).deliver_later
      Result.success(user)
    rescue ActiveRecord::RecordNotUnique
      Result.failure(:email_taken, "Email already registered")
    end
  end
end

El constructor acepta user_repo y mailer con valores por defecto. El código de producción usa los defaults; los tests inyectan mocks. El método call valida, persiste, envía un email, y envuelve cada resultado en un objeto Result.

Construyendo una Clase Result Mínima Sin Gems

Algunos equipos adoptan dry-monads inmediatamente. La gem es sólida, pero agrega una curva de aprendizaje. Una clase Result de 30 líneas cubre la mayoría de las necesidades:

ruby
# app/services/result.rb
class Result
  attr_reader :value, :error, :code

  def initialize(success:, value: nil, error: nil, code: nil)
    @success = success
    @value = value
    @error = error
    @code = code
  end

  def success? = @success
  def failure? = !@success

  def self.success(value) = new(success: true, value: value)
  def self.failure(code, error = nil) = new(success: false, code: code, error: error)

  def on_success
    yield(value) if success?
    self
  end

  def on_failure
    yield(code, error) if failure?
    self
  end
end

Este Result soporta encadenamiento con on_success y on_failure, expone códigos de error estructurados, y requiere cero dependencias. Los controladores lo consumen limpiamente:

ruby
# app/controllers/users_controller.rb
class UsersController < ApplicationController
  def create
    result = Users::CreateAccount.new.call(user_params)

    result
      .on_success { |user| redirect_to user, notice: "Account created" }
      .on_failure { |code, error| render :new, status: :unprocessable_entity }
  end

  private

  def user_params
    params.require(:user).permit(:email, :password, :name)
  end
end
Cuándo usar dry-monads

Los equipos que ya usan el ecosistema dry-rb, o aquellos que necesitan notación Do para encadenar múltiples operaciones, se benefician de dry-monads. La gem provee tipos Success, Failure y Maybe con soporte de pattern matching en Ruby 3.x.

Convenciones de Nomenclatura que Señalan la Intención

Dos convenciones dominan:

EstiloEjemploCuándo usarlo
Frase verbalCreateAccount, SendInvoice, RefundPaymentComandos que cambian estado
Sustantivo con -orAccountCreator, InvoiceSender, PaymentRefunderMenos común, algunos equipos lo prefieren

El estilo de frase verbal se lee naturalmente: Users::CreateAccount.new.call(params). El sufijo -or funciona pero agrega sílabas sin claridad.

La estructura de directorios también importa. Agrupar por dominio mantiene los servicios relacionados juntos:

text
app/services/
  users/
    create_account.rb
    reset_password.rb
    update_profile.rb
  orders/
    place_order.rb
    cancel_order.rb
    calculate_total.rb
  payments/
    process_payment.rb
    refund_payment.rb

Esta organización permite encontrar instantáneamente la lógica relacionada con un dominio. La alternativa, un directorio plano con prefijos (user_create_account.rb), se vuelve inmanejable más allá de 20 archivos.

Inyección de Dependencias para Tests Aislados

Los constructores de service objects aceptan colaboradores con valores por defecto. Esta técnica permite a los tests reemplazar dependencias lentas o con efectos secundarios:

ruby
# spec/services/users/create_account_spec.rb
RSpec.describe Users::CreateAccount do
  describe "#call" do
    let(:fake_repo) { class_double(User) }
    let(:fake_mailer) { class_double(UserMailer) }
    let(:service) { described_class.new(user_repo: fake_repo, mailer: fake_mailer) }

    context "with valid params" do
      let(:user) { instance_double(User, valid?: true, save!: true) }
      let(:mail) { double(deliver_later: true) }

      before do
        allow(fake_repo).to receive(:new).and_return(user)
        allow(fake_mailer).to receive(:welcome_email).and_return(mail)
      end

      it "returns success with the user" do
        result = service.call(email: "test@example.com")

        expect(result).to be_success
        expect(result.value).to eq(user)
      end

      it "sends the welcome email" do
        service.call(email: "test@example.com")

        expect(fake_mailer).to have_received(:welcome_email).with(user)
      end
    end

    context "with invalid params" do
      let(:user) { instance_double(User, valid?: false, errors: { email: ["invalid"] }) }

      before { allow(fake_repo).to receive(:new).and_return(user) }

      it "returns failure with validation errors" do
        result = service.call(email: "invalid")

        expect(result).to be_failure
        expect(result.code).to eq(:validation_failed)
      end
    end
  end
end

Estos tests se ejecutan en milisegundos porque nunca instancian modelos ActiveRecord reales ni envían emails. El código de producción usa los defaults, así que no se requiere configuración fuera de los tests.

Componiendo Múltiples Servicios para Operaciones Complejas

Las operaciones complejas encadenan múltiples servicios. Una transacción de e-commerce podría involucrar validar inventario, procesar pago y notificar envío. Componer estos servicios mantiene cada pieza testeable:

ruby
# app/services/orders/checkout.rb
module Orders
  class Checkout
    def initialize(
      inventory_checker: Inventory::CheckAvailability.new,
      payment_processor: Payments::ProcessPayment.new,
      notifier: Notifications::SendOrderConfirmation.new
    )
      @inventory_checker = inventory_checker
      @payment_processor = payment_processor
      @notifier = notifier
    end

    def call(order)
      inventory_result = @inventory_checker.call(order.items)
      return inventory_result if inventory_result.failure?

      payment_result = @payment_processor.call(order.payment_details)
      return payment_result if payment_result.failure?

      order.complete!
      @notifier.call(order)
      Result.success(order)
    end
  end
end

Cada servicio interno maneja su propia lógica y retorna un Result. El servicio padre retorna temprano si un paso falla, evitando callbacks anidados o manejo complejo de excepciones.

Preguntas Comunes de Entrevista Técnica

Los reclutadores exploran la comprensión de service objects a través de preguntas situacionales. Estos son los temas más frecuentes con respuestas sólidas:

Pregunta: ¿Cuándo debería usarse un service object en lugar de un método de modelo?

Usar un service object cuando la operación involucra múltiples modelos, tiene efectos secundarios (emails, APIs de terceros), o contiene lógica de negocio que no pertenece al ciclo de vida de un solo registro. Los métodos de modelo son apropiados para queries simples o validaciones específicas de ese registro.

Pregunta: ¿Cómo se testean service objects que interactúan con APIs externas?

Inyectar el cliente HTTP o wrapper de API a través del constructor. En tests, proveer un double que retorne respuestas predefinidas. Este diseño evita llamadas de red y permite testear caminos de error sin provocar fallas reales.

Pregunta: ¿Qué debería retornar un service object?

Un objeto tipado que representa éxito o fallo. Los enfoques comunes incluyen una clase Result personalizada, dry-monads, o incluso un struct simple con un flag booleano y un payload. La peor opción es retornar valores mixtos (a veces un modelo, a veces nil, a veces una excepción), porque los llamadores no pueden manejar los resultados consistentemente.

Pregunta: ¿Cómo manejan los service objects las transacciones de base de datos?

Envolver el bloque ActiveRecord::Base.transaction dentro del método call. Si múltiples servicios necesitan compartir una transacción, pasar el bloque de transacción como argumento o usar un servicio orquestador que maneje los límites de transacción:

ruby
# app/services/orders/process_with_transaction.rb
module Orders
  class ProcessWithTransaction
    def call(order)
      ActiveRecord::Base.transaction do
        Inventory::Reserve.new.call(order.items).tap { |r| raise ActiveRecord::Rollback if r.failure? }
        Payments::Charge.new.call(order).tap { |r| raise ActiveRecord::Rollback if r.failure? }
        order.confirm!
        Result.success(order)
      end
    rescue ActiveRecord::Rollback
      Result.failure(:transaction_failed)
    end
  end
end

Evitando Errores Comunes

Los equipos nuevos en service objects frecuentemente cometen estos errores:

Crear service objects para todo. No cada pieza de lógica necesita su propia clase. Un callback de modelo simple o un scope es a veces la respuesta correcta. La sobre-ingeniería fragmenta la lógica tanto que nadie puede seguir el flujo.

Exponer múltiples métodos públicos. Un service object debería tener un único método público, call. Múltiples métodos sugieren que la clase maneja múltiples responsabilidades y debería dividirse.

Ignorar el manejo de errores. Retornar nil o lanzar excepciones crudas fuerza a los llamadores a adivinar qué salió mal. El patrón Result hace los caminos de fallo explícitos y el código llamador más limpio.

Hardcodear dependencias. Llamar User.find o UserMailer.welcome_email directamente dentro de los métodos previene la sustitución en tests y acopla el servicio a clases concretas.

¿Listo para aprobar tus entrevistas de Ruby on Rails?

Practica con nuestros simuladores interactivos, flashcards y tests técnicos.

Organizando Service Objects en Aplicaciones Más Grandes

A medida que las aplicaciones crecen, los equipos necesitan convenciones adicionales:

Servicios con namespace por dominio. Users::CreateAccount y Billing::CreateInvoice mantienen los contextos separados. Los equipos construyendo arquitecturas modulares (como Packwerk) mapean naturalmente los namespaces a packages.

Clases base compartidas con precaución. Un ApplicationService que provee helpers de logging o atajos de transacción puede ayudar, pero la herencia se convierte en carga si la clase base crece demasiado. Preferir módulos o composición.

Consistencia del patrón Result. Elegir un estilo de Result y usarlo en todas partes. Mezclar dry-monads, clases Result personalizadas y retornos booleanos fuerza a los llamadores a verificar múltiples formatos.

Performance y Escalabilidad

Los service objects no agregan overhead significativo. Las llamadas a métodos de Ruby son rápidas, y las allocations de objetos para clases pequeñas son triviales. Los verdaderos costos de performance vienen de lo que los servicios hacen, no del patrón en sí.

Para operaciones de alta frecuencia, considerar:

Reutilización de instancias de servicio. En lugar de Users::CreateAccount.new.call(params) en un loop cerrado, instanciar una vez y llamar múltiples veces si el servicio es stateless.

Cacheo de dependencias. Si un servicio crea conexiones costosas (pools de base de datos, clientes HTTP), pasarlas desde el código llamador en lugar de crearlas frescas en cada invocación.

Background jobs para efectos secundarios. Mover emails, webhooks y otras operaciones lentas a jobs en background. El servicio se vuelve responsable de encolar el job, no de ejecutar el efecto secundario directamente.

Integración con Background Jobs de Rails 8

Solid Queue de Rails 8 funciona perfectamente con service objects. El job encapsula la llamada al servicio, manteniendo la lógica de negocio separada de la mecánica de encolamiento:

ruby
# app/jobs/process_order_job.rb
class ProcessOrderJob < ApplicationJob
  queue_as :default

  def perform(order_id)
    order = Order.find(order_id)
    result = Orders::Process.new.call(order)

    if result.failure?
      Rails.logger.error("Order processing failed: #{result.code}")
      raise result.error if result.code == :retriable_error
    end
  end
end

La lógica de retry del job maneja fallos transitorios. El service object permanece enfocado en procesar la orden, no en gestionar la cola.

Conclusión

Los service objects traen estructura y testeabilidad a las aplicaciones Rails sin introducir complejidad innecesaria. El patrón encapsula lógica de negocio en POROs que aceptan dependencias, retornan resultados estructurados, y hacen una sola cosa bien.

Los candidatos en entrevistas técnicas que pueden explicar no solo cómo implementar service objects, sino también por qué el patrón mejora la mantenibilidad, demuestran la madurez arquitectónica que los equipos buscan. Los ejemplos de código presentados aquí proveen una base para construir y discutir service objects en producción.

La clave es la consistencia: elegir convenciones de nomenclatura, adoptar un patrón Result, inyectar dependencias, y aplicar estas elecciones a través del codebase. Las aplicaciones Rails más mantenibles no son aquellas con las abstracciones más inteligentes, sino aquellas donde cada service object sigue las mismas reglas predecibles.

Reto diario

¿Sabrías detectar el bug en Ruby on Rails?

Un fragmento real, un bug oculto, un intento al día. Sin cuenta para probar.

Anthony Fillion-Maillet

Escrito por

Anthony Fillion-Maillet

Fundador de SharpSkill

Desarrollador fullstack desde hace más de 10 años. Dirige SharpSkill y responde por todo lo que se publica aquí.

Actualizado el 20 de agosto de 2026

Etiquetas

#rails
#ruby
#service-objects
#design-patterns
#poro
#arquitectura

Compartir

Artículos relacionados