Ruby on Rails viene con todo lo que necesitas para crear un prototipo de tu aplicación rápidamente, pero cuando tu código base empieza a crecer, te encontrarás con escenarios en los que el mantra convencional de Modelo Gordo, Controlador Flaco se rompe. Cuando tu lógica de negocio no puede caber ni en un modelo ni en un controlador, es cuando los objetos de servicio entran en juego y nos permiten separar cada acción de negocio en su propio objeto Ruby.
En este artículo explicaré cuándo se necesita un objeto de servicio; cómo escribir objetos de servicio limpios y agruparlos para que los contribuyentes estén sanos; las estrictas reglas que impongo a mis objetos de servicio para vincularlos directamente a mi lógica de negocio; y cómo no convertir tus objetos de servicio en un vertedero para todo el código que no sabes qué hacer con él.
- ¿Por qué necesito objetos de servicio?
- ¿Qué son los objetos de servicio?
- Creando un objeto de servicio
- Añadir azúcar sintáctico para que los objetos de servicio de Rails apesten menos
- Agrupando objetos de servicio similares para la cordura
- Objetos de servicio para manejar las operaciones de la base de datos
- ¿Qué devuelvo de mi objeto de servicio?
- Devuelve true o false
- Devuelve un valor
- Responde con un Enum
- ¿No debería poner los objetos de servicio en lib/services en lugar de app/services?
- ¿Son los objetos de servicio una buena idea?
- ¿Cuándo no debo usar un objeto de servicio?
- Reglas para escribir buenos objetos de servicio
- Regla 1: Sólo un método público por objeto de servicio
- Regla 2: Nombra los objetos de servicio como los roles tontos de una empresa
- Regla 3: No crear objetos genéricos para realizar múltiples acciones
- Regla 4: Maneja las excepciones dentro del objeto de servicio
¿Por qué necesito objetos de servicio?
Prueba esto: ¿Qué haces cuando tu aplicación necesita tuitear el texto de params
?
Si has estado usando Rails vainilla hasta ahora, probablemente hayas hecho algo como esto:
class TweetController < ApplicationController def create send_tweet(params) end private def send_tweet(tweet) client = Twitter::REST::Client.new do |config| config.consumer_key = ENV config.consumer_secret = ENV config.access_token = ENV config.access_token_secret = ENV end client.update(tweet) endend
El problema aquí es que has añadido al menos diez líneas a tu controlador, pero realmente no pertenecen allí. Además, ¿qué pasa si quieres utilizar la misma funcionalidad en otro controlador? ¿Mueves esto a una preocupación? Espera, pero este código no pertenece realmente a los controladores en absoluto. ¿Por qué la API de Twitter no puede venir con un único objeto preparado para que yo lo llame?
La primera vez que hice esto, sentí que había hecho algo sucio. Mis controladores Rails, que hasta entonces habían sido maravillosamente esbeltos, habían empezado a engordar y no sabía qué hacer. Al final, arreglé mi controlador con un objeto de servicio.
Antes de empezar a leer este artículo, vamos a fingir:
- Esta aplicación maneja una cuenta de Twitter.
- The Rails Way significa “la forma convencional de hacer las cosas en Ruby on Rails” y el libro no existe.
- Soy un experto en Rails… que me dicen todos los días que lo soy, pero me cuesta creerlo, así que vamos a fingir que realmente lo soy.
¿Qué son los objetos de servicio?
Los objetos de servicio son Plain Old Ruby Objects (PORO) que están diseñados para ejecutar una única acción en tu lógica de dominio y hacerlo bien. Considera el ejemplo anterior: Nuestro método ya tiene la lógica para hacer una sola cosa, y es crear un tweet. ¿Qué pasaría si esta lógica estuviera encapsulada dentro de una sola clase Ruby que pudiéramos instanciar y llamar a un método? Algo así como:
tweet_creator = TweetCreator.new(params)tweet_creator.send_tweet# Later on in the article, we'll add syntactic sugar and shorten the above to:TweetCreator.call(params)
Esto es prácticamente todo; nuestro objeto de servicio TweetCreator
, una vez creado, puede ser llamado desde cualquier lugar, y haría esta única cosa muy bien.
Creando un objeto de servicio
Primero vamos a crear un nuevo TweetCreator
en una nueva carpeta llamada app/services
:
$ mkdir app/services && touch app/services/tweet_creator.rb
Y vamos a volcar toda nuestra lógica dentro de una nueva clase Ruby:
# app/services/tweet_creator.rbclass TweetCreator def initialize(message) @message = message end def send_tweet client = Twitter::REST::Client.new do |config| config.consumer_key = ENV config.consumer_secret = ENV config.access_token = ENV config.access_token_secret = ENV end client.update(@message) endend
Entonces puedes llamar a TweetCreator.new(params).send_tweet
en cualquier parte de tu aplicación, y funcionará. Rails cargará este objeto por arte de magia porque autocarga todo en app/
. Comprueba esto ejecutando:
$ rails cRunning via Spring preloader in process 12417Loading development environment (Rails 5.1.5) > puts ActiveSupport::Dependencies.autoload_paths.../Users/gilani/Sandbox/nazdeeq/app/services
¿Quieres saber más sobre cómo funciona autoload
? Lee la Guía de autocarga y recarga de constantes.
Añadir azúcar sintáctico para que los objetos de servicio de Rails apesten menos
Mira, esto parece genial en teoría, pero TweetCreator.new(params).send_tweet
es un bocado. Es demasiado verboso con palabras redundantes… muy parecido al HTML (¡ba-dum tiss!). En serio, sin embargo, ¿por qué la gente usa HTML cuando existe HAML? O incluso Slim. Supongo que ese es otro artículo para otro momento. Volviendo a la tarea que nos ocupa:
TweetCreator
es un bonito nombre de clase corto, pero el exceso de información sobre la instanciación del objeto y la llamada al método es demasiado largo. Si sólo hubiera precedencia en Ruby para llamar a algo y hacer que se ejecute inmediatamente con los parámetros dados… ¡oh, espera, la hay! Es Proc#call
.
Proccall
invoca el bloque, estableciendo los parámetros del bloque a los valores en params usando algo cercano a la semántica de llamada a un método. Devuelve el valor de la última expresión evaluada en el bloque.aproc = Proc.new {|scalar, values| values.map {|value| valuescalar } }aproc.call(9, 1, 2, 3) #=> aproc #=> aproc.(9, 1, 2, 3) #=> aproc.yield(9, 1, 2, 3) #=>
Documentación
Si esto te confunde, déjame explicarte. Un proc
puede ser call
editado para ejecutarse a sí mismo con los parámetros dados. Lo que significa que si TweetCreator
fuera un proc
, podríamos llamarlo con TweetCreator.call(message)
y el resultado sería equivalente a TweetCreator.new(params).call
, que se parece bastante a nuestro viejo y poco manejable TweetCreator.new(params).send_tweet
.
¡Así que vamos a hacer que nuestro objeto de servicio se comporte más como un proc
!
Primero, ya que probablemente queramos reutilizar este comportamiento en todos nuestros objetos de servicio, tomemos prestado de la manera de Rails y creemos una clase llamada ApplicationService
:
# app/services/application_service.rbclass ApplicationService def self.call(*args, &block) new(*args, &block).call endend
¿Has visto lo que he hecho ahí? He añadido un método de clase llamado call
que crea una nueva instancia de la clase con los argumentos o bloque que le pases, y llama a call
en la instancia. ¡Exactamente lo que queríamos! Lo último que hay que hacer es cambiar el nombre del método de nuestra clase TweetCreator
por el de call
, y hacer que la clase herede de ApplicationService
:
# app/services/tweet_creator.rbclass TweetCreator < ApplicationService attr_reader :message def initialize(message) @message = message end def call client = Twitter::REST::Client.new do |config| config.consumer_key = ENV config.consumer_secret = ENV config.access_token = ENV config.access_token_secret = ENV end client.update(@message) endend
Y finalmente, vamos a terminar llamando a nuestro objeto de servicio en el controlador:
class TweetController < ApplicationController def create TweetCreator.call(params) endend
Agrupando objetos de servicio similares para la cordura
El ejemplo anterior sólo tiene un objeto de servicio, pero en el mundo real, las cosas pueden complicarse más. Por ejemplo, ¿qué pasaría si tuvieras cientos de servicios, y la mitad de ellos fueran acciones de negocio relacionadas, por ejemplo, tener un servicio Follower
que siguiera a otra cuenta de Twitter? Sinceramente, me volvería loco si una carpeta contuviera 200 archivos de aspecto único, así que lo bueno es que hay otro patrón de Rails Way que podemos copiar, es decir, utilizar como inspiración: el namespacing.
Supongamos que nos han encargado crear un objeto de servicio que siga a otros perfiles de Twitter.
Miremos el nombre de nuestro objeto de servicio anterior: TweetCreator
. Suena como una persona, o al menos, un rol en una organización. Alguien que crea Tweets. Me gusta nombrar mis objetos de servicio como si fueran precisamente eso: roles en una organización. Siguiendo esta convención, llamaré a mi nuevo objeto ProfileFollower
.
Ahora, como soy el señor supremo de esta aplicación, voy a crear una posición de gerente en mi jerarquía de servicios y delegar la responsabilidad de estos dos servicios a esa posición. Llamaré a esta nueva posición de gestor TwitterManager
.
Dado que este gestor no hace nada más que gestionar, vamos a convertirlo en un módulo y anidar nuestros objetos de servicio bajo este módulo. Nuestra estructura de carpetas ahora se verá como:
services├── application_service.rb└── twitter_manager ├── profile_follower.rb └── tweet_creator.rb
Y nuestros objetos de servicio:
# services/twitter_manager/tweet_creator.rbmodule TwitterManager class TweetCreator < ApplicationService ... endend
# services/twitter_manager/profile_follower.rbmodule TwitterManager class ProfileFollower < ApplicationService ... endend
Y nuestras llamadas ahora se convertirán en TwitterManager::TweetCreator.call(arg)
, y TwitterManager::ProfileManager.call(arg)
.
Objetos de servicio para manejar las operaciones de la base de datos
El ejemplo anterior hizo llamadas a la API, pero los objetos de servicio también se pueden utilizar cuando todas las llamadas son a su base de datos en lugar de una API. Esto es especialmente útil si algunas acciones de negocio requieren múltiples actualizaciones de la base de datos envueltas en una transacción. Por ejemplo, este código de ejemplo utilizaría servicios para registrar un cambio de moneda que tiene lugar.
module MoneyManager # exchange currency from one amount to another class CurrencyExchanger < ApplicationService ... def call ActiveRecord::Base.transaction do # transfer the original currency to the exchange's account outgoing_tx = CurrencyTransferrer.call( from: the_user_account, to: the_exchange_account, amount: the_amount, currency: original_currency ) # get the exchange rate rate = ExchangeRateGetter.call( from: original_currency, to: new_currency ) # transfer the new currency back to the user's account incoming_tx = CurrencyTransferrer.call( from: the_exchange_account, to: the_user_account, amount: the_amount * rate, currency: new_currency ) # record the exchange happening ExchangeRecorder.call( outgoing_tx: outgoing_tx, incoming_tx: incoming_tx ) end end end # record the transfer of money from one account to another in money_accounts class CurrencyTransferrer < ApplicationService ... end # record an exchange event in the money_exchanges table class ExchangeRecorder < ApplicationService ... end # get the exchange rate from an API class ExchangeRateGetter < ApplicationService ... endend
¿Qué devuelvo de mi objeto de servicio?
Hemos discutido cómo call
nuestro objeto de servicio, pero ¿qué debería devolver el objeto? Hay tres maneras de abordar esto:
- Devuelve
true
ofalse
- Devuelve un valor
- Devuelve un Enum
Devuelve true
o false
Este es simple: Si una acción funciona como se pretende, devuelve true
; en caso contrario, devuelve false
:
def call ... return true if client.update(@message) false end
Devuelve un valor
Si tu objeto de servicio obtiene datos de algún lugar, probablemente quieras devolver ese valor:
def call ... return false unless exchange_rate exchange_rate end
Responde con un Enum
Si tu objeto de servicio es un poco más complejo, y quieres manejar diferentes escenarios, podrías simplemente añadir enums para controlar el flujo de tus servicios:
class ExchangeRecorder < ApplicationService RETURNS = def call foo = do_something return SUCCESS if foo.success? return FAILURE if foo.failure? PARTIAL_SUCCESS end private def do_something endend
Y luego en tu app, puedes usar:
case ExchangeRecorder.call when ExchangeRecorder::SUCCESS foo when ExchangeRecorder::FAILURE bar when ExchangeRecorder::PARTIAL_SUCCESS baz end
¿No debería poner los objetos de servicio en lib/services en lugar de app/services?
Esto es subjetivo. Las opiniones de la gente difieren sobre dónde poner sus objetos de servicio. Algunas personas los ponen en lib/services
, mientras que algunos crean app/services
. Yo estoy en este último bando. La Guía de Inicio de Rails describe la carpeta lib/
como el lugar donde poner “los módulos extendidos de tu aplicación”
En mi humilde opinión, “módulos extendidos” significa módulos que no encapsulan la lógica del dominio principal y que generalmente pueden ser utilizados en todos los proyectos. En las sabias palabras de una respuesta aleatoria de Stack Overflow, poner el código allí que “potencialmente puede convertirse en su propia joya.”
¿Son los objetos de servicio una buena idea?
Depende de su caso de uso. Mira-el hecho de que estés leyendo este artículo ahora mismo sugiere que estás tratando de escribir código que no pertenece exactamente a un modelo o controlador. Recientemente leí este artículo sobre cómo los objetos de servicio son un anti-patrón. El autor tiene sus opiniones, pero yo estoy respetuosamente en desacuerdo.
Sólo porque alguna otra persona haya usado en exceso los objetos de servicio no significa que sean inherentemente malos. En mi startup, Nazdeeq, usamos objetos de servicio así como modelos no-ActiveRecord. Pero la diferencia entre lo que va donde siempre ha sido evidente para mí: Mantengo todas las acciones de negocio en objetos de servicio, mientras que mantengo los recursos que realmente no necesitan persistencia en modelos no-ActiveRecord. Al final del día, eres tú quien debe decidir qué patrón es bueno para ti.
Sin embargo, ¿creo que los objetos de servicio en general son una buena idea? Absolutamente. Mantienen mi código ordenado, y lo que me hace confiar en mi uso de POROs es que Ruby ama los objetos. No, en serio, Ruby ama los objetos. Es una locura, una locura total, ¡pero me encanta! Un ejemplo:
> 5.is_a? Object # => true > 5.class # => Integer > class Integer?> def woot?> 'woot woot'?> end?> end # => :woot > 5.woot # => "woot woot"
¿Ves? 5
es literalmente un objeto.
En muchos lenguajes, los números y otros tipos primitivos no son objetos. Ruby sigue la influencia del lenguaje Smalltalk dando métodos y variables de instancia a todos sus tipos. Esto facilita el uso de Ruby, ya que las reglas que se aplican a los objetos se aplican a todo Ruby.Ruby-lang.org
¿Cuándo no debo usar un objeto de servicio?
Esta es fácil. Tengo estas reglas:
- ¿Tu código maneja enrutamiento, parámetros o hace otras cosas de controlador?
Si es así, no uses un objeto de servicio-tu código pertenece al controlador. - ¿Estás tratando de compartir tu código en diferentes controladores?
En este caso, no utilices un objeto de servicio-utiliza una preocupación. - ¿Es tu código como un modelo que no necesita persistencia?
Si es así, no utilices un objeto de servicio. Utilice un modelo no-ActiveRecord en su lugar. - ¿Es su código una acción de negocio específica? (por ejemplo, “Sacar la basura”, “Generar un PDF con este texto” o “Calcular los derechos de aduana utilizando estas complicadas reglas”)
En este caso, utilice un objeto de servicio. Ese código probablemente no encaja lógicamente ni en tu controlador ni en tu modelo.
Por supuesto, estas son mis reglas, así que eres bienvenido a adaptarlas a tus propios casos de uso. Estas han funcionado muy bien para mí, pero su kilometraje puede variar.
Reglas para escribir buenos objetos de servicio
Tengo cuatro reglas para crear objetos de servicio. Estas no están escritas en piedra, y si realmente quieres romperlas, puedes, pero probablemente te pediré que lo cambies en las revisiones de código a menos que tu razonamiento sea sólido.
Regla 1: Sólo un método público por objeto de servicio
Los objetos de servicio son acciones de negocio únicas. Puedes cambiar el nombre de tu método público si quieres. Yo prefiero usar call
, pero el código base de Gitlab CE lo llama execute
y otras personas pueden usar perform
. Utiliza lo que quieras-puedes llamarlo nermin
por lo que me importa. Simplemente no cree dos métodos públicos para un solo objeto de servicio. Divídelo en dos objetos si lo necesitas.
Regla 2: Nombra los objetos de servicio como los roles tontos de una empresa
Los objetos de servicio son acciones de negocio únicas. Imagina que contratas a una persona en la empresa para hacer ese único trabajo, ¿cómo la llamarías? Si su trabajo es crear tweets, llámelo TweetCreator
. Si su trabajo es leer tweets específicos, llámalos TweetReader
.
Regla 3: No crear objetos genéricos para realizar múltiples acciones
Los objetos de servicio son acciones de negocio únicas. Rompí la funcionalidad en dos piezas: TweetReader
, y ProfileFollower
. Lo que no hice es crear un solo objeto genérico llamado TwitterHandler
y volcar toda la funcionalidad de la API allí. Por favor, no hagas esto. Esto va en contra de la mentalidad de “acción empresarial” y hace que el objeto de servicio se parezca al Hada de Twitter. Si quieres compartir código entre los objetos de negocio, simplemente crea un objeto o módulo BaseTwitterManager
y mézclalo con tus objetos de servicio.
Regla 4: Maneja las excepciones dentro del objeto de servicio
Por enésima vez: Los objetos de servicio son acciones de negocio únicas. No puedo decir esto lo suficiente. Si tienes una persona que lee tweets, te dará el tweet, o dirá: “Este tweet no existe”. Del mismo modo, no dejes que tu objeto de servicio entre en pánico, salte a la mesa de tu controlador y le diga que detenga todo el trabajo porque “¡Error!”. Simplemente devuelve false
y deja que el controlador siga adelante a partir de ahí.
Este artículo no habría sido posible sin la increíble comunidad de desarrolladores de Ruby en Toptal. Si alguna vez me encuentro con un problema, la comunidad es el grupo más útil de ingenieros con talento que he conocido.
Si estás usando objetos de servicio, puedes encontrarte preguntando cómo forzar ciertas respuestas mientras haces pruebas. Te recomiendo que leas este artículo sobre cómo crear objetos de servicio falsos en Rspec que siempre devolverán el resultado que quieres, sin tener que golpear el objeto de servicio.
Si quieres aprender más sobre los trucos de Ruby, te recomiendo Creating a Ruby DSL: A Guide to Advanced Metaprogramming (Creando un DSL de Ruby: una guía para la metaprogramación avanzada) por el compañero de Toptaler Máté Solymosi. Desglosa cómo el archivo routes.rb
no se siente como Ruby y te ayuda a construir tu propio DSL.