11 November 2022
On writing better service objects
Seven service objects tips from someone who has had enough.
Here are the tests for a small exercise found on CodeWars to identify whether an array is odd heavy or not. Let's solve it the same way we would approach a Rails problem. We'll see how we can find our way into writing useful service objects through refactoring.
require "minitest/autorun"
class OddHeavyTests < MiniTest::Test
def test_is_odd_heavy
assert_equal false, is_odd_heavy([])
assert_equal true, is_odd_heavy([-1])
assert_equal false, is_odd_heavy([2])
assert_equal true, is_odd_heavy([11,4,9,2,8])
assert_equal false, is_odd_heavy([11,4,9,2,3,10])
end
end
TL;DR
Click the
previous slide or
next slide
links to skim through the journey. Each step is more detailed below.
0: Writing Ruby code like Rails apps
def is_odd_heavy(arr)
odds, evens = OddPartitionArray.new(arr).call
HasAnyArray.new(odds).call && (IsEmptyArray.new(evens).call || MinArray.new(odds).call > MaxArray.new(evens).call)
end
1: On improving testability
def is_odd_heavy(arr)
odds, evens = OddPartitionArray.call(arr)
HasAnyArray.call(odds) && (IsEmptyArray.call(evens) || MinArray.call(odds) > MaxArray.call(evens))
end
2: On improving discoverability
def is_odd_heavy(arr)
odds, evens = ArrayOddPartition.call(arr)
ArrayHasAny.call(odds) && (ArrayIsEmpty.call(evens) || ArrayMin.call(odds) > ArrayMax.call(evens))
end
3: On improving code organisation
def is_odd_heavy(arr)
odds, evens = MyArray::ArrayOddPartition.call(arr)
MyArray::ArrayHasAny.call(odds) && (MyArray::ArrayIsEmpty.call(evens) || MyArray::ArrayMin.call(odds) > MyArray::ArrayMax.call(evens))
end
4: On improving readability
def is_odd_heavy(arr)
odds, evens = MyArray::OddPartition.call(arr)
MyArray::HasAny.call(odds) && (MyArray::IsEmpty.call(evens) || MyArray::Min.call(odds) > MyArray::Max.call(evens))
end
5: On improving cohesion
def is_odd_heavy(arr)
odds, evens = MyArray.odd_partition(arr)
MyArray.any?(odds) && (MyArray.empty?(evens) || MyArray.min(odds) > MyArray.max(evens))
end
6: On improving maintainability
def is_odd_heavy(arr)
odds, evens = MyArray.odd_partition(arr)
MyArray.any?(odds) && (MyArray.empty?(evens) || MyArray.min(odds) > MyArray.max(evens))
end
7: On improving standards
def is_odd_heavy(arr)
odds, evens = arr.partition(&:odd?)
odds.any? && (evens.empty? || odds.min > evens.max)
end
Summary
0. Writing Ruby code like Rails apps
def is_odd_heavy(arr)
odds, evens = OddPartitionArray.new(arr).call
HasAnyArray.new(odds).call && (IsEmptyArray.new(evens).call || MinArray.new(odds).call > MaxArray.new(evens).call)
end
Naturally, as a Rails developer, my Pavlovian response is to extract every single piece of logic into a service object.
No one would ever want this Ruby syntax, but we purposely seek it in Rails. Why?
The Array
class isn't that different than application models. Let's find out why and how we can improve our code.
Show/Hide new solution
def is_odd_heavy(arr)
odds, evens = OddPartitionArray.new(arr).call
HasAnyArray.new(odds).call && (IsEmptyArray.new(evens).call || MinArray.new(odds).call > MaxArray.new(evens).call)
end
class OddPartitionArray
def initialize(array)
@array = array
end
def call
@array.partition(&:odd?)
end
end
class HasAnyArray
def initialize(array)
@array = array
end
def call
@array.any?
end
end
class IsEmptyArray
def initialize(array)
@array = array
end
def call
@array.empty?
end
end
class MinArray
def initialize(array)
@array = array
end
def call
@array.min
end
end
class MaxArray
def initialize(array)
@array = array
end
def call
@array.max
end
end
1. Improving testability
def is_odd_heavy(arr)
odds, evens = OddPartitionArray.call(arr)
HasAnyArray.call(odds) && (IsEmptyArray.call(evens) || MinArray.call(odds) > MaxArray.call(evens))
end
Like most of the Ruby shops, we're using RSpec. A common practice is to avoid running any code with the use of mocks and stubs because Ruby is too slow. Stubs in RSpec are lengthy and the team decides that Service.call
is a better convention than Service.new.call
.
We're now able to write this:
describe "OddHeavy" do
describe 'when partition returns no odd numbers' do
before do
allow(OddPartitionArray).to receive(:call).and_return([[],[]])
end
# specs
end
end
instead of this
describe "OddHeavy" do
describe 'when partition returns no odd numbers' do
before do
service = double('OddPartitionArray')
allow(service).to receive(:call).and_return([[],[]])
allow(OddPartitionArray).to receive(:new).and_return(service)
end
# specs
end
end
Show/Hide new solution
def is_odd_heavy(arr)
odds, evens = OddPartitionArray.call(arr)
HasAnyArray.call(odds) && (IsEmptyArray.call(evens) || MinArray.call(odds) > MaxArray.call(evens))
end
class OddPartitionArray
def self.call(array); new(array).call ; end
def initialize(array); @array = array ; end
def call
@array.partition(&:odd?)
end
end
class HasAnyArray
def self.call(array); new(array).call ; end
def initialize(array); @array = array ; end
def call
@array.any?
end
end
class IsEmptyArray
def self.call(array); new(array).call ; end
def initialize(array); @array = array ; end
def call
@array.empty?
end
end
class MinArray
def self.call(array); new(array).call ; end
def initialize(array); @array = array ; end
def call
@array.min
end
end
class MaxArray
def self.call(array); new(array).call ; end
def initialize(array); @array = array ; end
def call
@array.max
end
end
2. Improving discoverability
def is_odd_heavy(arr)
odds, evens = ArrayOddPartition.call(arr)
ArrayHasAny.call(odds) && (ArrayIsEmpty.call(evens) || ArrayMin.call(odds) > ArrayMax.call(evens))
end
We realise that the tree structure of our services becomes challenging to explore. We decide to update our naming pattern from MaxArray
to ArrayMax
. This refactoring allows us to group Array services in a similar location in the services
folder. We now have something like this:
// From this
services
├── CharsString
├── ChunkEnumerable
├── EncodeString
├── FirstEnumerable
├── HasAnyArray
├── IsEmptyArray
├── MaxArray
├── MinArray
├── OddPartitionArray
├── ReduceEnumerable
├── ReverseString
├── StripString
├── ...
// To this
services
├── ArrayHasAny
├── ArrayIsEmpty
├── ArrayMax
├── ArrayMin
├── ArrayOddPartition
├── EnumerableChunk
├── EnumerableFirst
├── EnumerableReduce
├── StringChars
├── StringEncode
├── StringReverse
├── StringStrip
├── ...
Show/Hide new solution
def is_odd_heavy(arr)
odds, evens = ArrayOddPartition.call(arr)
ArrayHasAny.call(odds) && (ArrayIsEmpty.call(evens) || ArrayMin.call(odds) > ArrayMax.call(evens))
end
class ArrayOddPartition
def self.call(array); new(array).call ; end
def initialize(array); @array = array ; end
def call
@array.partition(&:odd?)
end
end
class ArrayHasAny
def self.call(array); new(array).call ; end
def initialize(array); @array = array ; end
def call
@array.any?
end
end
class ArrayIsEmpty
def self.call(array); new(array).call ; end
def initialize(array); @array = array ; end
def call
@array.empty?
end
end
class ArrayMin
def self.call(array); new(array).call ; end
def initialize(array); @array = array ; end
def call
@array.min
end
end
class ArrayMax
def self.call(array); new(array).call ; end
def initialize(array); @array = array ; end
def call
@array.max
end
end
3. Improving code organisation
def is_odd_heavy(arr)
odds, evens = MyArray::ArrayOddPartition.call(arr)
MyArray::ArrayHasAny.call(odds) && (MyArray::ArrayIsEmpty.call(evens) || MyArray::ArrayMin.call(odds) > MyArray::ArrayMax.call(evens))
end
With this new folder structure, it becomes evident that an Array module can hold every array services. Discovering our application's behaviour is now easier.
services
├── MyArray
│ ├── ArrayHasAny
│ ├── ArrayIsEmpty
│ ├── ArrayMax
│ ├── ArrayMin
│ ├── ArrayOddPartition
├── MyEnumerable
│ ├── EnumerableChunk
│ ├── EnumerableFirst
│ ├── EnumerableReduce
├── MyString
│ ├── StringChars
│ ├── StringEncode
│ ├── StringReverse
│ ├── StringStrip
Show/Hide new solution
def is_odd_heavy(arr)
odds, evens = MyArray::ArrayOddPartition.call(arr)
MyArray::ArrayHasAny.call(odds) && (MyArray::ArrayIsEmpty.call(evens) || MyArray::ArrayMin.call(odds) > MyArray::ArrayMax.call(evens))
end
module MyArray
class ArrayOddPartition
def self.call(array); new(array).call ; end
def initialize(array); @array = array ; end
def call
@array.partition(&:odd?)
end
end
class ArrayHasAny
def self.call(array); new(array).call ; end
def initialize(array); @array = array ; end
def call
@array.any?
end
end
class ArrayIsEmpty
def self.call(array); new(array).call ; end
def initialize(array); @array = array ; end
def call
@array.empty?
end
end
class ArrayMin
def self.call(array); new(array).call ; end
def initialize(array); @array = array ; end
def call
@array.min
end
end
class ArrayMax
def self.call(array); new(array).call ; end
def initialize(array); @array = array ; end
def call
@array.max
end
end
end
4. Improving readability
def is_odd_heavy(arr)
odds, evens = MyArray::OddPartition.call(arr)
MyArray::HasAny.call(odds) && (MyArray::IsEmpty.call(evens) || MyArray::Min.call(odds) > MyArray::Max.call(evens))
end
Having MyArray::ArrayMin
is a bit redundant and we can remove the Array references in our service classes. Like so:
services
├── MyArray
│ ├── HasAny
│ ├── IsEmpty
│ ├── Max
│ ├── Min
│ ├── OddPartition
├── MyEnumerable
│ ├── Chunk
│ ├── First
│ ├── Reduce
├── MyString
│ ├── Chars
│ ├── Encode
│ ├── Reverse
│ ├── Strip
Show/Hide new solution
def is_odd_heavy(arr)
odds, evens = MyArray::OddPartition.call(arr)
MyArray::HasAny.call(odds) && (MyArray::IsEmpty.call(evens) || MyArray::Min.call(odds) > MyArray::Max.call(evens))
end
module MyArray
class OddPartition
def self.call(array); new(array).call ; end
def initialize(array); @array = array ; end
def call
@array.partition(&:odd?)
end
end
class HasAny
def self.call(array); new(array).call ; end
def initialize(array); @array = array ; end
def call
@array.any?
end
end
class IsEmpty
def self.call(array); new(array).call ; end
def initialize(array); @array = array ; end
def call
@array.empty?
end
end
class Min
def self.call(array); new(array).call ; end
def initialize(array); @array = array ; end
def call
@array.min
end
end
class Max
def self.call(array); new(array).call ; end
def initialize(array); @array = array ; end
def call
@array.max
end
end
end
5. Improving cohesion
def is_odd_heavy(arr)
odds, evens = MyArray.odd_partition(arr)
MyArray.any?(odds) && (MyArray.empty?(evens) || MyArray.min(odds) > MyArray.max(evens))
end
MyArray::Max.call(array)
and MyArray.max(array)
are equivalent, but the latter is easier to read and understand. Putting all Array methods in MyArray
module seems to make perfect sense. Therefore, we create modules with multiple methods to increase code cohesion. We decide to only test MyArray
public methods. This meaning flagging our service objects as "private" and not test them.
Show/Hide new solution
def is_odd_heavy(arr)
odds, evens = MyArray.odd_partition(arr)
MyArray.any?(odds) && (MyArray.empty?(evens) || MyArray.min(odds) > MyArray.max(evens))
end
module MyArray
module_function
def odd_partition(arr)
OddPartition.call(arr)
end
def any?(arr)
HasAny.call(arr)
end
def empty?(arr)
IsEmpty.call(arr)
end
def min(arr)
Min.call(arr)
end
def max(arr)
Max.call(arr)
end
# Untested private classes enforced with packwerk
class OddPartition
def self.call(array); new(array).call ; end
def initialize(array); @array = array ; end
def call; @array.partition(&:odd?) ; end
end
class HasAny
def self.call(array); new(array).call ; end
def initialize(array); @array = array ; end
def call; @array.any? ; end
end
class IsEmpty
def self.call(array); new(array).call ; end
def initialize(array); @array = array ; end
def call; @array.empty? ; end
end
class Min
def self.call(array); new(array).call ; end
def initialize(array); @array = array ; end
def call; @array.min ; end
end
class Max
def self.call(array); new(array).call ; end
def initialize(array); @array = array ; end
def call; @array.max ; end
end
end
6. Improving maintainability
def is_odd_heavy(arr)
odds, evens = MyArray.odd_partition(arr)
MyArray.any?(odds) && (MyArray.empty?(evens) || MyArray.min(odds) > MyArray.max(evens))
end
Our tests don't reference our service objects anymore because they are private global functions and we can safely delete/rename/update them as we like.
Someone brilliant finds out that moving the logic of our services into our module methods doesn't make the code more complex but, in fact, easier to understand. We can safely remove unused services.
This Array Module is terrific. It encapsulates all the behaviour of arrays in one place. It's highly cohesive, makes sense, and is easy to discover, understand and change.
It's pointed out that our module looks like how other programming languages expose their packages. We came full circle.
Show/Hide new solution
def is_odd_heavy(arr)
odds, evens = MyArray.odd_partition(arr)
MyArray.any?(odds) && (MyArray.empty?(evens) || MyArray.min(odds) > MyArray.max(evens))
end
module MyArray
module_function
def odd_partition(arr)
arr.partition(&:odd?)
end
def any?(arr)
arr.any?
end
def empty?(arr)
arr.empty?
end
def min(arr)
arr.min
end
def max(arr)
arr.max
end
end
7. Improving standards
def is_odd_heavy(arr)
odds, evens = arr.partition(&:odd?)
odds.any? && (evens.empty? || odds.min > evens.max)
end
Our team wants to write more Object-Oriented code and removes the Array module to use Ruby's standard libraries directly.
That simple change was easy to perform incrementally without breaking our specs. We can safely fall back to using MyArray
module if necessary.
Our journey taught us how to create rich libraries that are easier to explore, understand, read and test. We should apply these lessons to more complex business logic and leave to Ruby what it does best.
That's why we rewrote #is_odd_heavy
as:
class OddHeavyArrayCheck
def call(arr)
odds, evens = arr.partition(&:odd?)
odds.any? && (evens.empty? || odds.min > evens.max)
end
end
OddHeavyArrayCheck.new.call([11,4,9,2,3,10])
# => false
;)
Read more about service objects
I've written another article about service objects if you're interested: