Rails Service Objects: A Comprehensive Guide

Ruby on Rails envia com tudo o que você precisa para prototipar sua aplicação rapidamente, mas quando sua base de código começa a crescer, você vai se deparar com cenários onde o Modelo Gordo convencional, Skinny Controller mantra quebra. Quando a sua lógica de negócio não consegue encaixar nem num modelo nem num controlador, é quando os objectos de serviço entram e nos deixam separar cada acção de negócio no seu próprio objecto Ruby.

Um exemplo de ciclo de requisição com objetos de serviço Rails

Neste artigo, explicarei quando um objeto de serviço é necessário; como escrever objetos de serviço limpos e agrupá-los para contribuir com a sanidade; as regras rígidas que imponho aos meus objetos de serviço para ligá-los diretamente à minha lógica de negócios; e como não transformar seus objetos de serviço em um lixão 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 sua aplicação precisa tweetar o texto de params?

Se você tem usado vanilla Rails até agora, então você provavelmente fez algo assim:

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

O problema aqui é que você adicionou pelo menos dez linhas ao seu controller, mas elas realmente não pertencem lá. Além disso, e se você quisesse usar a mesma funcionalidade em outro controlador? Você passa isso para uma preocupação? Espere, mas este código não pertence de todo 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, eu senti que tinha feito algo sujo. Meus, anteriormente, controladores de Rails lindamente magros tinham começado a engordar e eu não sabia o que fazer. Eventualmente, eu corrigi meu controller com um objeto de serviço.

Antes de você começar a ler este artigo, vamos fingir:

  • Esta aplicação 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 fingir que sou realmente um.

O que são objetos de serviço?

Objetos de serviço são objetos Ruby antigos (PORO) que são projetados para executar uma única ação na lógica do seu domínio e fazê-lo bem. Considere o exemplo acima: Nosso método já tem a lógica para fazer uma única coisa, e que é criar um tweet. E se esta lógica foi encapsulada dentro de uma única classe Ruby que podemos instanciar e chamar de método? Algo 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)

É isso mesmo; o nosso objecto de serviço TweetCreator, uma vez criado, pode ser chamado de qualquer lado, e faria isso muito bem.

Criando um objeto de serviço

Primeiro vamos criar um novo TweetCreator numa 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.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

Então você pode chamar TweetCreator.new(params).send_tweet de qualquer lugar na sua aplicação, e funcionará. Rails irá carregar este objeto magicamente porque ele carrega tudo automaticamente sob app/. Verifique isso executando:

$ 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

Deseja saber mais sobre como autoload funciona? Leia o Guia de Constantes de Autocarregamento e Recarregamento.

Adicionar açúcar sintáctico para fazer objetos de serviço Rails chuparem menos

Veja, isto é ótimo em teoria, mas TweetCreator.new(params).send_tweet é apenas uma boca cheia. É muito verboso com palavras redundantes… muito parecido com HTML (ba-dum tiss!). Mas, com toda seriedade, por que as pessoas usam HTML quando o HAML está por perto? Ou mesmo o Slim. Acho que isso é outro artigo para outra altura. Voltando à tarefa em mãos:

TweetCreator é um nome de classe bem curto, mas o cruft extra ao redor instanciar o objeto e chamar o método é muito longo! Se ao menos houvesse precedência em Ruby para chamar algo e tê-lo executar-se imediatamente com os parâmetros dados… oh espere, há! É Proc#call.

Proccall invoca o bloco, definindo os parâmetros do bloco para os valores nos params usando algo próximo ao método chamando semântica. 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) #=> aproc #=> aproc.(9, 1, 2, 3) #=> aproc.yield(9, 1, 2, 3) #=> 

Documentação

Se isto o confunde, deixe-me explicar. A proc pode ser call-ed para se executar com os parâmetros dados. 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).call, o que parece bastante similar ao nosso velho e pesado TweetCreator.new(params).send_tweet.

Então vamos fazer nosso objeto de serviço comportar-se 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.rbclass ApplicationService def self.call(*args, &block) new(*args, &block).call endend

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 nós queríamos! A última coisa a fazer é renomear o método da nossa TweetCreator classe para call, e fazer a classe herdar 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

E finalmente, vamos terminar chamando nosso objeto de serviço no controller:

class TweetController < ApplicationController def create TweetCreator.call(params) endend

Grupos de 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 comerciais relacionadas, por exemplo, ter um serviço Follower que seguisse outra conta no 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: namespacing.

Vamos fingir que fomos incumbidos 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 no mínimo, um papel em uma organização. Alguém que cria Tweets. Eu gosto de nomear meus objetos de serviço como se eles fossem apenas isso: papéis em uma organização. Depois desta convenção, vou chamar o meu novo objeto: ProfileFollower.

Agora, como sou o soberano supremo desta aplicação, 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 este novo cargo de gerente TwitterManager.

Desde que este gerente não faça 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 será parecida com:

services├── application_service.rb└── twitter_manager ├── profile_follower.rb └── tweet_creator.rb

E nossos objetos de serviço:

# services/twitter_manager/tweet_creator.rbmodule TwitterManager class TweetCreator < ApplicationService ... endend
# services/twitter_manager/profile_follower.rbmodule TwitterManager class ProfileFollower < ApplicationService ... endend

E nossas chamadas agora serã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 API, mas objetos de serviço também podem ser usados quando todas as chamadas são para seu banco de dados ao invés de uma API. Isto é especialmente útil se algumas ações de negócios requerem múltiplas atualizações de banco de dados envolvidas em uma transação. Por exemplo, este exemplo de código 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 ... endend

O que devo retornar 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 isto:

  • Retornar true ou false
  • Retornar um valor
  • Retornar um Enum

Retornar true ou false

Esta é simples: Se uma acção funcionar como pretendido, devolva true; caso contrário, devolva false:

 def call ... return true if client.update(@message) false end

Retornar um Valor

Se o seu objecto de serviço buscar dados de algum lugar, provavelmente quer devolver esse valor:

 def call ... return false unless exchange_rate exchange_rate end

Resposta com um Enum

Se o seu objeto de serviço é um pouco mais complexo, e você quer lidar com diferentes cenários, você poderia apenas adicionar enumeros para controlar o fluxo dos seus serviços:

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

E então no seu aplicativo, você pode usar:

 case ExchangeRecorder.call when ExchangeRecorder::SUCCESS foo when ExchangeRecorder::FAILURE bar when ExchangeRecorder::PARTIAL_SUCCESS baz end

Não deveria colocar objetos de serviço em lib/services ao invés 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 algumas criam app/services. Eu caio no último acampamento. O Guia de Introdução ao Rails descreve a pasta lib/ como o lugar 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 do domínio central e geralmente podem ser usados em projetos. Nas palavras sábias de uma resposta aleatória de Stack Overflow, ponha lá código que “pode potencialmente tornar-se a sua própria gema”

Are Service Objects a Good Idea?

Depende do seu caso de uso. Veja o fato de que você está lendo este artigo agora mesmo sugere que você está tentando escrever 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 anti-padrão. O autor tem suas opiniões, mas eu respeitosamente discordo.

Apenas porque alguma outra pessoa usou demais os objetos de serviço não significa que eles sejam intrinsecamente ruins. Na minha inicialização, Nazdeeq, nós usamos objetos de serviço, bem como modelos não-ActiveRecord. Mas a diferença entre o que vai onde sempre foi aparente para mim: Mantenho todas as acções de negócio em objectos de serviço enquanto guardo recursos que não precisam de persistência em modelos não-ActiveRecord. No final das contas, cabe a você decidir qual padrão é bom para você.

No entanto, eu acho que os objetos de serviço em geral são uma boa idéia? Absolutamente! Eles mantêm o meu código bem organizado, e o que me faz confiar no meu uso de POROs é que Ruby adora objetos. Não, a sério, o Ruby adora objectos. É uma loucura, totalmente louco, 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 objecto.

Em muitas linguagens, números e outros tipos primitivos não são objectos. O Ruby segue a influência da linguagem Smalltalk dando métodos e variáveis de instância a todos os seus tipos. Isto facilita o uso do Ruby, uma vez que as regras aplicáveis aos objectos aplicam-se a todo o Ruby.Ruby-lang.org

When Should I Not Use a Service Object?

Esta é fácil. Eu tenho estas regras:

  1. O seu código lida com roteamento, params ou faz outras coisas com o controller?
    Se sim, não use um objeto de serviço – o seu código pertence ao controller.
  2. Você está tentando compartilhar seu código em diferentes controladores?
    Neste caso, não use um objeto de serviço – use uma preocupação.
  3. Seu código é como um modelo que não precisa de persistência?
    Se for o caso, não use um objeto de serviço. Use um modelo não-ActiveRecord em vez disso.
  4. O seu código é uma acção de negócio específica? (por exemplo, “Tirar o lixo”, “Gerar um PDF usando este texto” ou “Calcular a taxa alfandegária usando estas regras complicadas”)
    Neste caso, use um objeto de serviço. Esse código provavelmente não cabe logicamente nem no seu controlador nem no seu modelo.

Obviamente, estas são as minhas regras, por isso é bem-vindo a adaptá-las aos seus próprios casos de uso. Estes funcionaram muito bem para mim, mas a sua quilometragem pode variar.

Regras para escrever bons objectos de serviço

Eu tenho quatro regras para criar objectos de serviço. Estes não são escritos em pedra, e se você realmente quer quebrá-los, você pode, mas provavelmente vou pedir-lhe para alterá-lo em revisões de código, a menos que o seu raciocínio seja sólido.

Regra 1: Apenas um método público por objeto de serviço

Objetos de serviço são ações comerciais únicas. Você pode mudar o nome do seu método público, se quiser. 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 você quiser – você pode chamá-lo de nermin por tudo que eu quiser. Apenas não crie dois métodos públicos para um único objeto de serviço. Quebre-o em dois objetos se você precisar.

Regra 2: Name Service Objects Like Dumb Roles at a Company

Os objetos de serviço são ações comerciais individuais. Imagine se você contratasse uma pessoa na empresa para fazer esse único trabalho, como você os chamaria? Se o trabalho deles for criar tweets, chame-os de TweetCreator. Se o trabalho deles for ler tweets específicos, chame-os de TweetReader.

Regra 3: Não criar objetos genéricos para executar múltiplas ações

Os objetos de serviço são ações de negócios individuais. 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á dentro. Por favor, não faça isso. Isso vai contra a mentalidade da “ação de negócios” e faz o objeto de serviço parecer a Fada do Twitter. Se você quiser compartilhar código entre os objetos de negócio, basta criar um objeto ou módulo BaseTwitterManager e misturá-lo em seus objetos de serviço.

Regra 4: Abrir exceções dentro do objeto de serviço

Pela enésima vez: Service objects are single business actions. Eu não posso dizer isto o suficiente. Se você tem uma pessoa que lê tweets, ela lhe dará o tweet, ou dirá: “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 parar todo o trabalho porque “Erro!”. Apenas retorne false e deixe o controller continuar a partir daí.

Este artigo não teria sido possível sem a incrível comunidade de desenvolvedores Ruby no Toptal. Se alguma vez me deparar com um problema, a comunidade é o grupo mais útil de engenheiros talentosos que já conheci.

Se estiver a usar objectos de serviço, pode encontrar-se a pensar em como forçar certas respostas enquanto testa. Eu recomendo a leitura deste artigo sobre como criar objectos de serviço simulados em Rspec que irão sempre retornar o resultado desejado, sem realmente atingir o objecto de serviço!

Se quiser aprender mais sobre truques Ruby, eu recomendo Criar uma DSL Ruby: Um Guia para Metaprogramação Avançada pelo colega Toptaler Máté Solymosi. Ele explica como o ficheiro routes.rb não parece ser Ruby e ajuda-o a construir a sua própria DSL.

Deixe uma resposta

O seu endereço de email não será publicado.