Service Objects no Rails em 2026: Design Patterns, PORO e Perguntas de Entrevista Técnica

Os service objects do Rails extraem a lógica de negócio de controllers e models para classes Ruby simples. Este guia cobre padrões de projeto, implementações PORO e as perguntas de entrevista técnica mais frequentes.

Service Objects do Rails e Padrões de Projeto PORO

Os service objects do Rails extraem a lógica de negócio de controllers e models para Plain Old Ruby Objects (POROs). Esse padrão mantém as aplicações Rails manuteníveis conforme crescem, e os recrutadores o avaliam frequentemente para medir a compreensão de arquitetura limpa dos candidatos.

O que os recrutadores esperam

Um service object encapsula uma única operação de negócio. A classe tem um único método público (tipicamente call), aceita dependências através do seu construtor, e retorna um resultado previsível. Candidatos que conseguem articular por que isso importa, não apenas como implementar, se destacam.

Por Que Service Objects Resolvem os Problemas de Fat Model e Fat Controller

O Rails incentiva colocar a lógica em algum lugar. Sem orientação clara, as equipes a empurram para os models ("fat models, skinny controllers") até que as classes ActiveRecord atinjam milhares de linhas. Outros a deixam nos controllers, tornando as actions impossíveis de testar isoladamente.

Os service objects oferecem um terceiro caminho: a lógica de domínio reside em classes dedicadas que não dependem de nada do Rails exceto o que explicitamente requerem. Esse desacoplamento torna o código testável sem carregar o framework completo, portável através de diferentes pontos de entrada (controllers, jobs em background, console), e legível porque cada classe faz uma única coisa.

As Rails Guides não prescrevem service objects, mas a comunidade se padronizou em torno do padrão. A ênfase do Rails 8 na simplicidade, com Solid Queue e Solid Cache substituindo dependências externas, torna os POROs ainda mais atraentes: menos gems, mais Ruby puro.

Anatomia de um Service Object Pronto para Produção

Um service object precisa de três coisas: um construtor que recebe dependências, um único método público call, e um tipo de retorno que sinalize sucesso ou falha.

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

O construtor aceita user_repo e mailer com valores padrão. O código de produção usa os defaults; os testes injetam mocks. O método call valida, persiste, envia um email, e encapsula cada resultado em um objeto Result.

Construindo uma Classe Result Mínima Sem Gems

Algumas equipes adotam dry-monads imediatamente. A gem é sólida, mas adiciona uma curva de aprendizado. Uma classe Result de 30 linhas cobre a maioria das necessidades:

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

Esse Result suporta encadeamento com on_success e on_failure, expõe códigos de erro estruturados, e requer zero dependências. Os controllers o consomem de forma limpa:

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
Quando usar dry-monads

Equipes que já usam o ecossistema dry-rb, ou aquelas que precisam da notação Do para encadear múltiplas operações, se beneficiam do dry-monads. A gem fornece tipos Success, Failure e Maybe com suporte a pattern matching no Ruby 3.x.

Convenções de Nomenclatura que Sinalizam a Intenção

Duas convenções dominam:

EstiloExemploQuando usar
Frase verbalCreateAccount, SendInvoice, RefundPaymentComandos que alteram estado
Substantivo com -orAccountCreator, InvoiceSender, PaymentRefunderMenos comum, algumas equipes preferem

O estilo de frase verbal se lê naturalmente: Users::CreateAccount.new.call(params). O sufixo -or funciona mas adiciona sílabas sem clareza.

A estrutura de diretórios também importa. Agrupar por domínio mantém os serviços 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

Essa organização permite encontrar instantaneamente a lógica relacionada a um domínio. A alternativa, um diretório plano com prefixos (user_create_account.rb), se torna ingerenciável além de 20 arquivos.

Injeção de Dependências para Testes Isolados

Os construtores de service objects aceitam colaboradores com valores padrão. Essa técnica permite aos testes substituir dependências lentas ou com efeitos colaterais:

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

Esses testes executam em milissegundos porque nunca instanciam models ActiveRecord reais nem enviam emails. O código de produção usa os defaults, então nenhuma configuração é necessária fora dos testes.

Compondo Múltiplos Serviços para Operações Complexas

Operações complexas encadeiam múltiplos serviços. Uma transação de e-commerce pode envolver validar inventário, processar pagamento e notificar envio. Compor esses serviços mantém cada peça testável:

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 serviço interno gerencia sua própria lógica e retorna um Result. O serviço pai retorna antecipadamente se um passo falhar, evitando callbacks aninhados ou tratamento complexo de exceções.

Perguntas Comuns de Entrevista Técnica

Os recrutadores exploram a compreensão de service objects através de perguntas situacionais. Estes são os temas mais frequentes com respostas sólidas:

Pergunta: Quando usar um service object em vez de um método de model?

Usar um service object quando a operação envolve múltiplos models, tem efeitos colaterais (emails, APIs de terceiros), ou contém lógica de negócio que não pertence ao ciclo de vida de um único registro. Métodos de model são apropriados para queries simples ou validações específicas daquele registro.

Pergunta: Como testar service objects que interagem com APIs externas?

Injetar o cliente HTTP ou wrapper de API através do construtor. Nos testes, fornecer um double que retorna respostas predefinidas. Esse design evita chamadas de rede e permite testar caminhos de erro sem provocar falhas reais.

Pergunta: O que um service object deve retornar?

Um objeto tipado que representa sucesso ou falha. As abordagens comuns incluem uma classe Result personalizada, dry-monads, ou mesmo um struct simples com um flag booleano e um payload. A pior opção é retornar valores mistos (às vezes um model, às vezes nil, às vezes uma exceção), porque os chamadores não conseguem tratar os resultados de forma consistente.

Pergunta: Como service objects tratam transações de banco de dados?

Encapsular o bloco ActiveRecord::Base.transaction dentro do método call. Se múltiplos serviços precisam compartilhar uma transação, passar o bloco de transação como argumento ou usar um serviço orquestrador que gerencia os limites de transação:

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 Erros Comuns

Equipes novas em service objects frequentemente cometem esses erros:

Criar service objects para tudo. Nem toda peça de lógica precisa de sua própria classe. Um callback de model simples ou um scope é às vezes a resposta correta. A sobre-engenharia fragmenta a lógica tanto que ninguém consegue seguir o fluxo.

Expor múltiplos métodos públicos. Um service object deve ter um único método público, call. Múltiplos métodos sugerem que a classe gerencia múltiplas responsabilidades e deveria ser dividida.

Ignorar o tratamento de erros. Retornar nil ou lançar exceções brutas força os chamadores a adivinhar o que deu errado. O padrão Result torna os caminhos de falha explícitos e o código chamador mais limpo.

Hardcodear dependências. Chamar User.find ou UserMailer.welcome_email diretamente dentro dos métodos previne a substituição em testes e acopla o serviço a classes concretas.

Pronto para mandar bem nas entrevistas de Ruby on Rails?

Pratique com nossos simuladores interativos, flashcards e testes tecnicos.

Organizando Service Objects em Aplicações Maiores

Conforme as aplicações crescem, as equipes precisam de convenções adicionais:

Serviços com namespace por domínio. Users::CreateAccount e Billing::CreateInvoice mantêm os contextos separados. Equipes construindo arquiteturas modulares (como Packwerk) mapeiam naturalmente os namespaces para packages.

Classes base compartilhadas com cautela. Um ApplicationService que fornece helpers de logging ou atalhos de transação pode ajudar, mas a herança se torna um fardo se a classe base crescer demais. Preferir módulos ou composição.

Consistência do padrão Result. Escolher um estilo de Result e usá-lo em todos os lugares. Misturar dry-monads, classes Result personalizadas e retornos booleanos força os chamadores a verificar múltiplos formatos.

Performance e Escalabilidade

Os service objects não adicionam overhead significativo. As chamadas de métodos Ruby são rápidas, e as alocações de objetos para classes pequenas são triviais. Os verdadeiros custos de performance vêm do que os serviços fazem, não do padrão em si.

Para operações de alta frequência, considerar:

Reutilização de instâncias de serviço. Em vez de Users::CreateAccount.new.call(params) em um loop apertado, instanciar uma vez e chamar múltiplas vezes se o serviço for stateless.

Cache de dependências. Se um serviço cria conexões custosas (pools de banco de dados, clientes HTTP), passá-las do código chamador em vez de criá-las novas em cada invocação.

Background jobs para efeitos colaterais. Mover emails, webhooks e outras operações lentas para jobs em background. O serviço se torna responsável por enfileirar o job, não por executar o efeito colateral diretamente.

Integração com Background Jobs do Rails 8

O Solid Queue do Rails 8 funciona perfeitamente com service objects. O job encapsula a chamada do serviço, mantendo a lógica de negócio separada da mecânica de enfileiramento:

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

A lógica de retry do job trata falhas transitórias. O service object permanece focado em processar o pedido, não em gerenciar a fila.

Conclusão

Os service objects trazem estrutura e testabilidade para aplicações Rails sem introduzir complexidade desnecessária. O padrão encapsula lógica de negócio em POROs que aceitam dependências, retornam resultados estruturados, e fazem uma única coisa bem.

Candidatos em entrevistas técnicas que conseguem explicar não apenas como implementar service objects, mas também por que o padrão melhora a manutenibilidade, demonstram a maturidade arquitetural que as equipes buscam. Os exemplos de código apresentados aqui fornecem uma base para construir e discutir service objects em produção.

A chave é a consistência: escolher convenções de nomenclatura, adotar um padrão Result, injetar dependências, e aplicar essas escolhas através da codebase. As aplicações Rails mais manuteníveis não são aquelas com as abstrações mais inteligentes, mas aquelas onde cada service object segue as mesmas regras previsíveis.

Desafio do dia

Você saberia encontrar o bug em Ruby on Rails?

Um trecho real, um bug escondido, uma tentativa por dia. Sem conta para testar.

Anthony Fillion-Maillet

Escrito por

Anthony Fillion-Maillet

Fundador da SharpSkill

Desenvolvedor fullstack há mais de 10 anos. Dirige a SharpSkill e responde por tudo o que é publicado aqui.

Atualizado em 20 de agosto de 2026

Tags

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

Compartilhar

Artigos relacionados