Objetos de serviço Rails: um guia completo
Publicados: 2022-03-11Ruby on Rails vem com tudo que você precisa para prototipar seu aplicativo rapidamente, mas quando sua base de código começar a crescer, você se deparará com cenários onde o mantra convencional Fat Model, Skinny Controller quebra. Quando sua lógica de negócios não pode caber em um modelo ou controlador, é quando os objetos de serviço entram e nos permitem separar cada ação de negócios em seu próprio objeto Ruby.
Neste artigo, explicarei quando um objeto de serviço é necessário; como escrever objetos de serviço limpos e agrupá-los para manter a sanidade do contribuidor; as regras estritas que imponho aos meus objetos de serviço para vinculá-los diretamente à minha lógica de negócios; e como não transformar seus objetos de serviço em uma lixeira para todo o código com o qual você não sabe o que fazer.
Por que preciso de objetos de serviço?
Tente isto: O que você faz quando seu aplicativo precisa twittar o texto de params[:message]
?
Se você está usando Rails vanilla até agora, provavelmente já fez algo assim:
class TweetController < ApplicationController def create send_tweet(params[:message]) end private def send_tweet(tweet) client = Twitter::REST::Client.new do |config| config.consumer_key = ENV['TWITTER_CONSUMER_KEY'] config.consumer_secret = ENV['TWITTER_CONSUMER_SECRET'] config.access_token = ENV['TWITTER_ACCESS_TOKEN'] config.access_token_secret = ENV['TWITTER_ACCESS_SECRET'] end client.update(tweet) end end
O problema aqui é que você adicionou pelo menos dez linhas ao seu controlador, mas elas realmente não pertencem a ele. Além disso, e se você quisesse usar a mesma funcionalidade em outro controlador? Você move isso para uma preocupação? Espere, mas esse código realmente não pertence aos controladores. Por que a API do Twitter não pode vir com um único objeto preparado para eu chamar?
A primeira vez que fiz isso, senti como se tivesse feito algo sujo. Meus controladores Rails, antes lindamente enxutos, começaram a engordar e eu não sabia o que fazer. Eventualmente, consertei meu controlador com um objeto de serviço.
Antes de começar a ler este artigo, vamos fingir:
- Este aplicativo lida com uma conta do Twitter.
- The Rails Way significa “a maneira convencional do Ruby on Rails de fazer as coisas” e o livro não existe.
- Eu sou um especialista em Rails… o que me dizem todos os dias que sou, mas tenho dificuldade em acreditar, então vamos apenas fingir que realmente sou um.
O que são objetos de serviço?
Objetos de serviço são Plain Old Ruby Objects (PORO) que são projetados para executar uma única ação em sua lógica de domínio e fazê-lo bem. Considere o exemplo acima: Nosso método já tem a lógica de fazer uma única coisa, que é criar um tweet. E se essa lógica fosse encapsulada em uma única classe Ruby que podemos instanciar e chamar um método? Algo como:
tweet_creator = TweetCreator.new(params[:message]) tweet_creator.send_tweet # Later on in the article, we'll add syntactic sugar and shorten the above to: TweetCreator.call(params[:message])
Isso é muito bonito; nosso objeto de serviço TweetCreator
, uma vez criado, pode ser chamado de qualquer lugar, e isso funcionaria muito bem.
Criando um objeto de serviço
Primeiro vamos criar um novo TweetCreator
em uma nova pasta chamada app/services
:
$ mkdir app/services && touch app/services/tweet_creator.rb
E vamos despejar toda a nossa lógica dentro de uma nova classe Ruby:
# app/services/tweet_creator.rb class TweetCreator def initialize(message) @message = message end def send_tweet client = Twitter::REST::Client.new do |config| config.consumer_key = ENV['TWITTER_CONSUMER_KEY'] config.consumer_secret = ENV['TWITTER_CONSUMER_SECRET'] config.access_token = ENV['TWITTER_ACCESS_TOKEN'] config.access_token_secret = ENV['TWITTER_ACCESS_SECRET'] end client.update(@message) end end
Então você pode chamar TweetCreator.new(params[:message]).send_tweet
em qualquer lugar do seu aplicativo, e ele funcionará. Rails irá carregar este objeto magicamente porque ele carrega automaticamente tudo em app/
. Verifique isso executando:
$ rails c Running via Spring preloader in process 12417 Loading development environment (Rails 5.1.5) > puts ActiveSupport::Dependencies.autoload_paths ... /Users/gilani/Sandbox/nazdeeq/app/services
Quer saber mais sobre como funciona o autoload
? Leia o Guia de constantes de carregamento automático e recarregamento.
Adicionando açúcar sintático para tornar os objetos de serviço do Rails menos suculentos
Olha, isso parece ótimo em teoria, mas TweetCreator.new(params[:message]).send_tweet
é apenas um bocado. É muito verboso com palavras redundantes… muito parecido com HTML (ba-dum tiss! ). Com toda a seriedade, porém, por que as pessoas usam HTML quando o HAML está por perto? Ou mesmo Slim. Acho que isso é outro artigo para outro momento. De volta à tarefa em mãos:
TweetCreator
é um bom nome de classe curto, mas o trabalho extra em instanciar o objeto e chamar o método é muito longo! Se ao menos houvesse precedência em Ruby para chamar algo e executá-lo imediatamente com os parâmetros fornecidos… oh espere, existe! É Proc#call
.
Proccall
invoca o bloco, definindo os parâmetros do bloco para os valores em params usando algo próximo à semântica de chamada de método. Retorna o valor da última expressão avaliada no bloco.aproc = Proc.new {|scalar, values| values.map {|value| valuescalar } } aproc.call(9, 1, 2, 3) #=> [9, 18, 27] aproc[9, 1, 2, 3] #=> [9, 18, 27] aproc.(9, 1, 2, 3) #=> [9, 18, 27] aproc.yield(9, 1, 2, 3) #=> [9, 18, 27]
Documentação
Se isso te confunde, deixe-me explicar. Um proc
pode ser call
-ed para se executar com os parâmetros fornecidos. O que significa que, se TweetCreator
fosse um proc
, poderíamos chamá-lo com TweetCreator.call(message)
e o resultado seria equivalente a TweetCreator.new(params[:message]).call
, que é bastante semelhante ao nosso antigo e desajeitado TweetCreator.new(params[:message]).send_tweet
.
Então vamos fazer nosso objeto de serviço se comportar mais como um proc
!
Primeiro, porque provavelmente queremos reutilizar esse comportamento em todos os nossos objetos de serviço, vamos pegar emprestado do Rails Way e criar uma classe chamada ApplicationService
:
# app/services/application_service.rb class ApplicationService def self.call(*args, &block) new(*args, &block).call end end
Você viu o que eu fiz lá? Eu adicionei um método de classe chamado call
que cria uma nova instância da classe com os argumentos ou bloco que você passa para ela e chama call
na instância. Exatamente o que queríamos! A última coisa a fazer é renomear o método da nossa classe TweetCreator
para call
e fazer com que a classe seja herdada de ApplicationService
:
# app/services/tweet_creator.rb class TweetCreator < ApplicationService attr_reader :message def initialize(message) @message = message end def call client = Twitter::REST::Client.new do |config| config.consumer_key = ENV['TWITTER_CONSUMER_KEY'] config.consumer_secret = ENV['TWITTER_CONSUMER_SECRET'] config.access_token = ENV['TWITTER_ACCESS_TOKEN'] config.access_token_secret = ENV['TWITTER_ACCESS_SECRET'] end client.update(@message) end end
E, finalmente, vamos encerrar isso chamando nosso objeto de serviço no controlador:
class TweetController < ApplicationController def create TweetCreator.call(params[:message]) end end
Agrupando objetos de serviço semelhantes para sanidade
O exemplo acima tem apenas um objeto de serviço, mas no mundo real as coisas podem ficar mais complicadas. Por exemplo, e se você tivesse centenas de serviços e metade deles fossem ações de negócios relacionadas, por exemplo, ter um serviço Follower
que seguisse outra conta do Twitter? Honestamente, eu ficaria louco se uma pasta contivesse 200 arquivos com aparência única, então ainda bem que há outro padrão do Rails Way que podemos copiar - quero dizer, usar como inspiração: namespaces.
Vamos fingir que fomos encarregados de criar um objeto de serviço que segue outros perfis do Twitter.
Vejamos o nome do nosso objeto de serviço anterior: TweetCreator
. Parece uma pessoa, ou pelo menos, um papel em uma organização. Alguém que cria Tweets. Gosto de nomear meus objetos de serviço como se fossem apenas isso: funções em uma organização. Seguindo essa convenção, chamarei meu novo objeto: ProfileFollower
.
Agora, como sou o suserano supremo deste aplicativo, vou criar uma posição gerencial na minha hierarquia de serviços e delegar a responsabilidade por ambos os serviços a essa posição. Vou chamar essa nova posição gerencial TwitterManager
.
Como esse gerenciador não faz nada além de gerenciar, vamos torná-lo um módulo e aninhar nossos objetos de serviço sob este módulo. Nossa estrutura de pastas agora ficará assim:
services ├── application_service.rb └── twitter_manager ├── profile_follower.rb └── tweet_creator.rb
E nossos objetos de serviço:
# services/twitter_manager/tweet_creator.rb module TwitterManager class TweetCreator < ApplicationService ... end end
# services/twitter_manager/profile_follower.rb module TwitterManager class ProfileFollower < ApplicationService ... end end
E nossas chamadas agora se tornarão TwitterManager::TweetCreator.call(arg)
e TwitterManager::ProfileManager.call(arg)
.

Objetos de serviço para lidar com operações de banco de dados
O exemplo acima fez chamadas de API, mas objetos de serviço também podem ser usados quando todas as chamadas são para seu banco de dados em vez de uma API. Isso é especialmente útil se algumas ações de negócios exigirem várias atualizações de banco de dados agrupadas em uma transação. Por exemplo, esse código de amostra usaria serviços para registrar uma troca de moeda ocorrendo.
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 ... end end
O que eu retorno do meu objeto de serviço?
Discutimos como call
nosso objeto de serviço, mas o que o objeto deve retornar? Existem três maneiras de abordar isso:
- Retorna
true
oufalse
- Retornar um valor
- Retornar um enum
Retorna true
ou false
Esta é simples: se uma ação funcionar como pretendido, retorne true
; caso contrário, retorne false
:
def call ... return true if client.update(@message) false end
Retornar um valor
Se o seu objeto de serviço buscar dados de algum lugar, você provavelmente deseja retornar esse valor:
def call ... return false unless exchange_rate exchange_rate end
Responda com um Enum
Se o seu objeto de serviço for um pouco mais complexo e você quiser lidar com diferentes cenários, basta adicionar enums para controlar o fluxo de seus serviços:
class ExchangeRecorder < ApplicationService RETURNS = [ SUCCESS = :success, FAILURE = :failure, PARTIAL_SUCCESS = :partial_success ] def call foo = do_something return SUCCESS if foo.success? return FAILURE if foo.failure? PARTIAL_SUCCESS end private def do_something end end
E então em seu aplicativo, você pode usar:
case ExchangeRecorder.call when ExchangeRecorder::SUCCESS foo when ExchangeRecorder::FAILURE bar when ExchangeRecorder::PARTIAL_SUCCESS baz end
Não devo colocar objetos de serviço em lib/services
em vez de app/services
?
Isso é subjetivo. As opiniões das pessoas diferem sobre onde colocar seus objetos de serviço. Algumas pessoas os colocam em lib/services
, enquanto alguns criam app/services
. Eu caio no último campo. O Guia de Introdução do Rails descreve a pasta lib/
como o local para colocar “módulos estendidos para sua aplicação”.
Na minha humilde opinião, “módulos estendidos” significa módulos que não encapsulam a lógica de domínio principal e geralmente podem ser usados em projetos. Nas palavras sábias de uma resposta aleatória do Stack Overflow, coloque um código lá que “pode potencialmente se tornar sua própria joia”.
Os objetos de serviço são uma boa ideia?
Depende do seu caso de uso. Veja, o fato de você estar lendo este artigo agora sugere que você está tentando escrever um código que não pertence exatamente a um modelo ou controlador. Recentemente, li este artigo sobre como os objetos de serviço são um antipadrão. O autor tem suas opiniões, mas eu respeitosamente discordo.
Só porque alguma outra pessoa usou objetos de serviço em excesso não significa que eles sejam inerentemente ruins. Na minha startup, Nazdeeq, usamos objetos de serviço, bem como modelos não ActiveRecord. Mas a diferença entre o que vai para onde sempre foi aparente para mim: eu mantenho todas as ações de negócios em objetos de serviço enquanto mantenho recursos que realmente não precisam de persistência em modelos não ActiveRecord. No final do dia, cabe a você decidir qual padrão é bom para você.
No entanto, acho que os objetos de serviço em geral são uma boa ideia? Absolutamente! Eles mantêm meu código bem organizado, e o que me deixa confiante no uso de POROs é que Ruby adora objetos. Não, sério, Ruby adora objetos. É insano, totalmente maluco, mas eu adoro! Caso em questão:
> 5.is_a? Object # => true > 5.class # => Integer > class Integer ?> def woot ?> 'woot woot' ?> end ?> end # => :woot > 5.woot # => "woot woot"
Ver? 5
é literalmente um objeto.
Em muitas linguagens, números e outros tipos primitivos não são objetos. Ruby segue a influência da linguagem Smalltalk fornecendo métodos e variáveis de instância para todos os seus tipos. Isso facilita o uso do Ruby, já que as regras que se aplicam a objetos se aplicam a todo o Ruby. Ruby-lang.org
Quando não devo usar um objeto de serviço?
Este é fácil. Eu tenho essas regras:
- Seu código lida com roteamento, parâmetros ou faz outras coisas do controlador?
Nesse caso, não use um objeto de serviço — seu código pertence ao controlador. - Você está tentando compartilhar seu código em diferentes controladores?
Nesse caso, não use um objeto de serviço — use uma preocupação. - Seu código é como um modelo que não precisa de persistência?
Nesse caso, não use um objeto de serviço. Em vez disso, use um modelo não ActiveRecord. - Seu código é uma ação de negócios específica? (por exemplo, “Retire o lixo”, “Gere um PDF usando este texto” ou “Calcule a taxa alfandegária usando essas regras complicadas”)
Nesse caso, use um objeto de serviço. Esse código provavelmente não se encaixa logicamente em seu controlador ou em seu modelo.
Claro, essas são minhas regras, então você pode adaptá-las aos seus próprios casos de uso. Estes funcionaram muito bem para mim, mas sua milhagem pode variar.
Regras para escrever bons objetos de serviço
Eu tenho quatro regras para criar objetos de serviço. Eles não estão escritos em pedra, e se você realmente quiser quebrá-los, você pode, mas provavelmente vou pedir que você altere nas revisões de código, a menos que seu raciocínio seja correto.
Regra 1: apenas um método público por objeto de serviço
Objetos de serviço são ações de negócios únicas . Você pode alterar o nome do seu método público, se desejar. Eu prefiro usar call
, mas a base de código do Gitlab CE o chama de execute
e outras pessoas podem usar perform
. Use o que quiser - você poderia chamá-lo de nermin
para tudo que me importa. Apenas não crie dois métodos públicos para um único objeto de serviço. Divida-o em dois objetos se precisar.
Regra 2: Nomeie objetos de serviço como funções idiotas em uma empresa
Objetos de serviço são ações de negócios únicas. Imagine se você contratasse uma pessoa na empresa para fazer aquele trabalho, como você a chamaria? Se o trabalho deles é criar tweets, chame-os TweetCreator
. Se o trabalho deles é ler tweets específicos, chame-os TweetReader
.
Regra 3: não crie objetos genéricos para executar várias ações
Objetos de serviço são ações de negócios únicas. Eu quebrei a funcionalidade em duas partes: TweetReader
e ProfileFollower
. O que eu não fiz foi criar um único objeto genérico chamado TwitterHandler
e despejar toda a funcionalidade da API lá. Por favor, não faça isso. Isso vai contra a mentalidade de “ação comercial” e faz com que o objeto de serviço pareça a Fada do Twitter. Se você deseja compartilhar código entre os objetos de negócios, basta criar um objeto ou módulo BaseTwitterManager
e misturá-lo em seus objetos de serviço.
Regra 4: lidar com exceções dentro do objeto de serviço
Pela enésima vez: Objetos de serviço são ações de negócios únicas. Eu não posso dizer isso o suficiente. Se você tem uma pessoa que lê tweets, ela vai te dar o tweet ou dizer: “Este tweet não existe”. Da mesma forma, não deixe seu objeto de serviço entrar em pânico, pule na mesa do seu controlador e diga a ele para interromper todo o trabalho porque "Erro!" Apenas retorne false
e deixe o controlador seguir em frente a partir daí.
Créditos e próximos passos
Este artigo não teria sido possível sem a incrível comunidade de desenvolvedores Ruby da Toptal. Se eu tiver algum problema, a comunidade é o grupo mais útil de engenheiros talentosos que já conheci.
Se você estiver usando objetos de serviço, poderá se perguntar como forçar determinadas respostas durante o teste. Eu recomendo ler este artigo sobre como criar objetos de serviço simulados no Rspec que sempre retornarão o resultado desejado, sem realmente atingir o objeto de serviço!
Se você quiser aprender mais sobre os truques do Ruby, recomendo Criar um Ruby DSL: Um Guia para Metaprogramação Avançada do colega Toptaler Mate Solymosi. Ele explica como o arquivo routes.rb
não se parece com Ruby e ajuda você a construir sua própria DSL.