Responsibility Centric Design Using TDD

Camilo Reyes
Share

Responsibility

In this article, we’ll take a fresh look at tackling an external sort problem by applying a responsibility centric technique. Each responsibility in this particular problem gets encapsulated in a way that makes it easy to do test driven development and leaves us with code that is flexible and maintainable.

Hopefully, by the end of this article you will learn a radical approach at tackling exciting problems.

External Sort

External sort is a fascinating problem, as you are limited by the amount of memory available to do a sort on an input file.

This Wikipedia article states the crux of the problem:

External sorting is a term for a class of sorting algorithms that can handle massive amounts of data. External sorting is required when the data being sorted do not fit into the main memory of a computing device (usually RAM) and instead they must reside in the slower external memory (usually a hard drive).

I am limiting the problem to a randomly generated input file of size M and an array size of N in which N * N = M to keeps things simple. There is also a need to check our work, so we’ll write a check utility to make sure the sort was successful.

Responsibility Centric Design

The basic approach in this design pattern is to break the problem down into contracts which will then become our public APIs. Each contract will encapsulate a separate role in the solution. The key here is to focus on what each encapsulation does as opposed to how it does it.

This Practicing Ruby article details out the gist of this approach:

In a responsibility-centric design, systems are divided by the collection of behaviors that they must implement. The goal of this division is to formulate the description of the behavior of the system in terms of multiple interacting processes, with each process playing a separate role.

I break the problem down into a writer, reader, and queuer. The Writer and Reader will give me a general API to read or write into persistent storage. Since I have to break the large main input file into a smaller cache, Queuer will serve as the access point for each individual file for external sort. An interesting observation here is that roles tend to be named after verbs since they are designed to go out and do something. At this time, notice how each encapsulation is not concerned with how this is done.

So, let’s break it down:

module FIO
  class Writer
    def open(file)
    end
    def append(n)
    end
    def close
    end
  end

  class Reader
    def open(file)
    end
    def read
    end
    def close
    end
  end

  class Queuer
    def open(file)
    end
    def [](index)
    end
    def close
    end
  end
end

Test driven development

Now that we have the contracts completed, we can start thinking about writing tests. For test driven development, I would like to focus your attention on how we would conceptually solve this problem. The key here is to focus on each contract and what the proper behavior should be. Fortunately Ruby, being a dynamic language, makes this very easy so we don’t have to spend any time mocking and stubbing classes by writing up interface code.

So let’s talk about what we need.

To generate the main randomly generated input file, we need some kind of Export utility. For example:

class Export
  def initialize(writer)
    @writer = writer
  end
  def out(max, file_name)
  end
end

To do the external sort, we need a Sort utility:

class Sort
  def initialize(reader, writer, queuer)
    @reader = reader
    @writer = writer
    @queuer = queuer
  end
  def external(max, chache_size, file_in, file_out)
  end
end

Finally, to do the check at the end, we need a Check utility:

class Check
  def initialize(reader)
  end
  def is_sorted(max, file_out)
  end
end

That should do it.

The relationship between these utility objects and our contracts resemble that of a client / server model. Here the server gets injected as a dependency. The client code will then call the public API or contract to fulfill requests.

As an analogy, data that flows from our contracts is like the water service we get in our house. The utility objects are like the necessary plumbing in our home that fulfill the service. One thing to keep in mind is, in a responsibility centric approach, data flows through the system as opposed to being put in a single monolithic place.

Notice, at this point in the development process, we are not concerned with any implementation details. Our unit tests will be designed to guide the creative process.

Unit tests

For the purposes of this article, I am using mocha to help me write the tests.

In the tests, we’ll define M = 9 and N = 3,. Finally, a test filename like in for input and out for output should do.

Let’s conceptualize our export utility:

class ExportTest < Test::Unit::TestCase
  def test_export_out
    # Mock dependency and add expectations
    writer = mock()
    writer.expects(:open).with('in').at_least_once
    writer.expects(:append).with(anything).times(9)
    writer.expects(:close).at_least_once
    # Call utility
    Export.new(writer).out(9, 'in')
  end
end

To successfully pass this test we can do:

def out(max, file_name)
  @writer.open(file_name)
  max.times { @writer.append(Random.rand(10)) }
  @writer.close
end

The same pattern can be applied to the check utility:

class CheckTest < Test::Unit::TestCase
  def test_check_is_sorted
    reader = mock()
    reader.expects(:open).with('out').at_least_once
    reader.expects(:read).times(9).returns(0)
    reader.expects(:close).at_least_once
    target = Check.new(reader).is_sorted(9, 'out')
    assert target
  end

To successfully pass this test we can do:

def is_sorted(max, file_out)
  return true if max <= 1
  sorted = true
  @reader.open(file_out)
  a = @reader.read
  (max - 1).times do
    b = @reader.read
    sorted = false if a > b
    a = b
  end
  @reader.close
  sorted
end

For our final test, we need to implement external sort. The first thing is to break the input file into sorted chunks. That means we are reading from our input file 9 times. We’ll also be writing to separate chunk files a total of 9 times. Finally, we will write the final result into the output file a total of 9 times, which gives us a complete total of 18.

There are nuances with our Queuer that are accounted for in this test. Each index of the queuer object will give me access to an individual chunk. I am using the queuer to provide the data for sorting until it runs out. In the test case, that will be a total of 9 plus 3 since it needs to tell me when it no longer has anything.

So here is the test:

class SortTest < Test::Unit::TestCase
  def test_sort_external
    writer = mock()
    reader = mock()
    queuer = mock()
    reader.expects(:open).with('in').at_least_once
    reader.expects(:read).times(9)
    reader.expects(:close).at_least_once
    writer.expects(:open).with('out').at_least_once
    writer.expects(:open).with('in1').at_least_once
    writer.expects(:open).with('in2').at_least_once
    writer.expects(:open).with('in3').at_least_once
    writer.expects(:append).with(anything).times(18)
    writer.expects(:close).at_least_once
    queuer.expects(:open).with(3, 'in').at_least_once
    queuer.expects(:[]).with(anything).times(12).returns(0)
    queuer.expects(:close).at_least_once
    Sort.new(reader, writer, queuer).external(9, 3, 'in', 'out')
  end
end

To make the test pass, we can do:

def external(max, cache_size, file_in, file_out)
  capacity = Array.new(cache_size)
  # Break file_in into chunks
  @reader.open(file_in)
  max.times do |index|
    capacity[index % cache_size] = @reader.read
    if is_end_of_chunk(index, max, cache_size)
      capacity.sort!
      # Use naming convention, e.g., file_in1
      @writer.open(file_in + ((index + 1) / cache_size).to_s)
      cache_size.times { |i| @writer.append(capacity[i]) }
      @writer.close
    end
  end
  @reader.close
  # Merge chunks onto file_out
  @writer.open(file_out)
  @queuer.open(cache_size, file_in)
  cache_size.times { |i| capacity[i] = @queuer[i] }
  max.times do
    lowest = min_index(capacity)
    @writer.append(capacity[lowest])
    capacity[lowest] = @queuer[lowest]
  end
  @queuer.close
  @writer.close
end

private
  def is_end_of_chunk(index, max, size)
    (!index.zero? && ((index + 1) % size).zero?) || max == 1
  end
  def min_index(arr)
    index = 0
    (1..arr.length - 1).each do |i|
      next if arr[i].nil?
      index = i if arr[index].nil? || arr[index] > arr[i]
    end
    index
  end

When we run these tests, nothing is actually getting written into your file system. Again, the point here is to conceptually test our code to ensure proper behavior. Unit testing is the art of testing isolated code without crossing functional boundaries, like writing to a file or sending a network request.

Conclusion

Test driven development brings a lot of challenges in thinking about solutions from a very abstracted view. However, responsibility centric solutions benefit from already doing a lot of the heavy lifting for you by separating concerns and enforcing well designed layers of abstraction.

I omitted implementation details as an exercise for the reader. If you are interested, I have posted the rest of the code on GitHub.

Happy hacking!