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 servicesfolder. 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

;)