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.

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.
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.
# 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
endEl 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:
# 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
endEste Result soporta encadenamiento con on_success y on_failure, expone códigos de error estructurados, y requiere cero dependencias. Los controladores lo consumen limpiamente:
# 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
endLos 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:
| Estilo | Ejemplo | Cuándo usarlo |
|---|---|---|
| Frase verbal | CreateAccount, SendInvoice, RefundPayment | Comandos que cambian estado |
| Sustantivo con -or | AccountCreator, InvoiceSender, PaymentRefunder | Menos 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:
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.rbEsta 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:
# 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
endEstos 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:
# 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
endCada 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:
# 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
endEvitando 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:
# 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
endLa 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.
¿Sabrías detectar el bug en Ruby on Rails?
Un fragmento real, un bug oculto, un intento al día. Sin cuenta para probar.

Escrito por
Anthony Fillion-MailletFundador 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
Compartir
Artículos relacionados

Jobs en segundo plano en Rails 2026: Sidekiq vs Good Job y preguntas de entrevista
Comparación completa entre Sidekiq, Good Job y Solid Queue para tareas asíncronas en Rails, con las preguntas técnicas más frecuentes en entrevistas.

Preguntas de entrevista Ruby on Rails: Top 25 en 2026
Las 25 preguntas de entrevista Ruby on Rails más solicitadas. Arquitectura MVC, Active Record, migraciones, testing RSpec, APIs REST con respuestas detalladas y ejemplos de código.

API GraphQL con Rails en 2026: graphql-ruby, Subscriptions y Preguntas de Entrevista
Construir una API GraphQL lista para produccion con Rails 8 y graphql-ruby. Diseno de esquema, mutaciones, subscriptions con ActionCable y preparacion para entrevistas.