11 June 2021
  
    
      Photo by Brett Jordan on Unsplash
    
  
I often come across service object classes like CreatePurchase or PostCustomerReceipt which create a record on the database and/or perform an HTTP query to a third party. Often that class has a #call method that does two things:
Disclaimer: I usually explain my points with working code or something close to reality; however, today I’ll use imaginary ruby code.
Services I described are used in these situations:
def query
  QueryCustomer.call(customer: customer)
end
While the service is about acting, it is likely that another class already has this responsibility. What is important is how customer gets serialized. Those services put the focus on an unimportant part of the code… the action. The parameters used to act is probably what requires more attention.
Sometimes these types of service objects have the same implementation of #call method, only the serialization of parameters differs.
Those classes often look something like this:
class QueryCustomer < QueryCustomer
  def self.call(**args)
    new(**args).call
  end
  def initialize(customer:)
    @customer = customer
  end
  def call
    return false unless valid?
    if Client.query(url, params: params) # or Customer.create(params)
      # something to return
    else
      # store errors with custom error handling
    end
  end
  private
  def valid?
    # rolls custom validations
  end
  def params
    result = {
      details1: details1,
      details2: details2,
      # ...
      detailsn: detailsn,
    }
    result.merge(more_details) if @customer.more_bill_details?
    result
  end
  def details1
    # a lot of stuff 
    #...
  end
  
  def details2
    # a lot of stuff 
    #...
  end
  
  # More details definition ...  
  def detailsn
    # a lot of stuff 
    #...
  end
  def more_details
    # conditional stuff
  end
end
The class handles errors, validations, query and generates the payload. Most of the methods are private used to serialize customer. It looks noisy and hard to understand.
Because it is advised to only test public interfaces, the #call method is the only one getting tested. The tests often stub clients and put expectation on the parameters passed. Sometimes private methods end up getting tested too… 🙈
Something like this:
require 'rails_helper'
RSpec.describe QueryCustomer do
  let(:customer) { create(:customer, :important_trait) }
  let(:service) { described_class.new(customer:customer) }
  describe '#call' do
    subject { service.call }
    it 'calls the client with the correct payload' do
      payload = service.send(:payload) # or hardcoded
      
      expect(Client).to receive(:query)
        .with('/url', payload)
        .and_return({hello: 'world'})
      
      subject
    end
  end
  describe '#payload' do
    subject { service.send(:payload) } # 🙈
    # ...
  end
end
There are few code smells here. We generate the expected payload calling a private method and then stub the client call used in the service with the payload. We’re not testing much here…
If you have made it this far, you can start to see where I’m going. Our service class does too much and we’ll probably win by extracting the serialization logic into a class. Who knows we might even get rid of the service entirely (hooray!).
Let’s consider a class whose responsibility is to provide the correct ruby hash to a service object, an active record or an HTTP client like so:
# with a service object
def query
  QueryCustomer.call payload: CustomerPayload.to_h(customer)
end
# without the service object
def query
  Client.query url, params: CustomerPayload.to_h(customer)
end
# another form using ActiveRecord methods instead of a service
def query
  Customer.create CustomerPayload.to_h(customer)
end
Sometimes QueryCustomer can be ditched completely because it becomes is an unecessary wrapper around a client or an active record model. Sometimes, you still need a wrapper and that is totally fine. Testing the class is now easier and the developer attention can be on validations or error handling instead of the serializing mess across the whole service object class.
Payload.to_h method can be tested separately which is easier to understand. The tests will document how each hash is supposed to look based on contexts.
require 'rails_helper'
RSpec.describe CustomerPayload do
  describe '.to_h' do
    let(:customer) { create(:customer) }
    subject { described_class.to_h(customer: customer) }
    it { is_expected.to eql({ ... }) # hardcoded hash }
    context 'when bills are important' do
      let(:bills) { [build(:bill, :important)] }
      it { is_expected.to eql({ ... }) # another hardcoded hash }
    end
  end
end
require 'rails_helper'
RSpec.describe QueryCustomer do
  describe '#call' do
    subject { described_class.call(payload: { hello: 'world' }) }
    it 'calls the client with the correct payload' do
      expect(Client).to receive(:query) 
                    .with('/url', hello: 'world')
                    .and_return(true)
      
      subject
    end
  end
end
We have control over what we’re testing, this test is simple, yet effective. In this case, the service object could be reused with any payload and not just be a specific implementation for a specific query on a specific customer.
This is a fictive example but if you use service objects you’ve likely encountered some similar use cases. Service objects are overused and this type of refactoring can potentially remove the need for these types entirely. Future devs will thank you for it.
Other steps to improve the code would be to:
Payload classes with something closer to the domain you are coding for.ActiveModel::Serialization to generate the hash elegantly in CustomerPayload.ActiveModel::Validations for a validation framework.ActiveModel::Model for maximum brownie points.I’ve written another article about service objects if you’re interested: