11 June 2021

Service object alternatives: Serializers

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

Why do I try to avoid this?

Focus: the attention is on the wrong part of the code

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.

Testing

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…

Why extract the logic out of the service?

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

Focus: attention is now on performing an action with correct parameters

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.

Testing

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.

Conclusion

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.

Pushing it further

Other steps to improve the code would be to:

Read more about service objects

I've written another article about service objects if you're interested: