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: