Applying Object Oriented Design Principles Case Study

Problem Statement

Let's write some code to track driving history for people. The code will process an input file. You can either choose to accept the input via stdin (e.g. if you're using Ruby cat input.txt | ruby yourcode.rb), or as a file name given on the command line (e.g. ruby yourcode.rb input.txt).

Each line in the input file will start with a command. There are two possible commands.The first command is Driver, which will register a new Driver in the app. Example:

Driver Dan

The second command is Trip, which will record a trip attributed to a driver. The line will be space delimited with the following fields: the command (Trip), driver name, start time, stop time, miles driven. Times will be given in the format of hours:minutes. We'll use a 24-hour clock and will assume that drivers never drive past midnight (the start time will always be before the end time). Example:

Trip Dan 07:15 07:45 17.3

Discard any trips that average a speed of less than 5 mph or greater than 100 mph. Generate a report containing each driver with total miles driven and average speed. Sort the output by most miles driven to least. Round miles and miles per hour to the nearest integer.

Example input:

Driver Dan
Driver Alex
Driver Bob
Trip Dan 07:15 07:45 17.3
Trip Dan 06:12 06:32 21.8
Trip Alex 12:01 13:16 42.0

Expected output:

Alex: 42 miles @ 34 mph
Dan: 39 miles @ 47 mph
Bob: 0 miles

Solution

Domain Objects

Driver and Trip capture only one abstraction in the domain. Driver is an Entity Object : It has Identity (assuming name is unique in the system). Trip is a Domain Object. It is associated to a specific driver. It has behavior. The Driver class currently has no behavior. This class was needed to capture the relationship between Driver and Trip. This association is useful during report generation and makes the rest of the solution simpler.

class Driver
  attr_reader :name
  attr_accessor :trips

  def initialize(name:)
    @name = name
    @trips = []
  end  
end
class Trip
  ONE_HOUR_IN_SECONDS = 3600
  LOWER_LIMIT = 5
  UPPER_LIMIT = 100

  attr_reader :driver, :start_time, :stop_time, :miles_driven

  def initialize(driver:, start_time:, stop_time:, miles_driven:)
    @driver = driver
    @start_time = start_time
    @stop_time = stop_time
    @miles_driven = miles_driven
  end  

  def speed
    (miles_driven/hours).round
  end

  def within_range?
    (LOWER_LIMIT..UPPER_LIMIT).cover?(speed)
  end

  private

  def hours
    (stop_time - start_time)/ONE_HOUR_IN_SECONDS
  end
end

Factory

Factory class creates instances of Driver and Trip. It has one purpose: to create domain objects.

require 'time'

class Factory
  def self.create_driver(input)
    fields = input.split(' ')   
    Driver.new(name: fields[1])  
  end

  def self.create_trip(input)
    fields = input.split(' ')    
    driver = fields[1]  
    start_time = Time.parse(fields[2])
    stop_time = Time.parse(fields[3])
    miles_driven = fields[4].to_f

    Trip.new(driver: driver,
             start_time: start_time,
             stop_time: stop_time,
             miles_driven: miles_driven)
  end
end

CommandProcessor

CommandProcessor is responsible for parsing the input, process commands and delegating the driver registration and recording of trip to DrivingHistoryTracker.

# frozen_string_literal: true
class CommandProcessor
  DRIVER_COMMAND = 'Driver'
  TRIP_COMMAND = 'Trip'

  attr_reader :tracker

  def initialize(file)
    @file = file
    @tracker = DrivingHistoryTracker.new
  end

  def process
    @file.readlines.each do |line|
      if line.include?(DRIVER_COMMAND)     
        driver = create_driver(line)
        tracker.register(driver)
      end
      if line.include?(TRIP_COMMAND)        
        trip = create_trip(line)
        tracker.record(trip)
      end
    end
  end

  private

  def create_driver(line)
    Factory.create_driver(line)
  end

  def create_trip(line)
    Factory.create_trip(line)
  end  
end

DrivingHistoryTracker

The responsibilities of DrivingHistoryTracker are:

  • Register a driver
  • Record a trip
  • Delegate report generation to DrivingHistory

The name of this class is derived from the problem statement: Let's write some code to track driving history for people. The purpose of this class is to track driving history of people.

class DrivingHistoryTracker  
  attr_reader :drivers

  def initialize
    @drivers = []
    @trips = []
  end

  def register(driver)
    @drivers << driver
  end

  def record(trip)   
    return unless trip.within_range?

    driver = find_by(name: trip.driver)
    driver.trips << trip
  end

  def registered?(driver)
    object = find(driver)
    object.instance_of? Driver
  end

  def driving_history_report
    driving_history = DrivingHistory.new(self)
    driving_history.report    
  end

  def find_trips(driver)
    driver = find(driver)
    driver.trips
  end

  def find_trips_for_driver_by(name:)
    driver = Driver.new(name: name)
    find_trips(driver)
  end

  private

  def find_by(name:)
    driver = Driver.new(name: name)
    find(driver)
  end 

  def find(driver)
    @drivers.select{|d| d.name == driver.name}.first
  end  
end

DrivingHistory

DrivingHistory is responsible for generating the report for all drivers. The classes are designed in such a way that they only have one reason for change. DrivingHistoryTracker and DrivingHistory collaborate very closely. DrivingHistoryTracker registers itself with DrivingHistory. DrivingHistory calls back DrivingHistoryTracker to delegate functionality. This design choice localized changes to these classes and eliminated duplication across them.

# frozen_string_literal: true
class DrivingHistory
  NEW_LINE = "\n"

  def initialize(tracker)
    @tracker = tracker
    @drivers = tracker.drivers
  end

  def report
    line_items = generate_line_items_for_all_drivers
    sort_by_most_miles_driven_to_least(line_items)

    generate_output(line_items)    
  end

  private

  def generate_line_items_for_all_drivers
    line_items = []

    @drivers.each do |driver|
      line_items << generate_report_for(driver.name)
    end
    line_items
  end

  def generate_report_for(driver)
    trips = find_trips_for(driver)
    total_distance = total_miles_driven_for(trips)
    average_speed = average_speed_for(trips)

    DrivingLineItem.new(driver: driver, 
                        distance: total_distance, 
                        speed: average_speed, 
                        trips: trips)
  end

  def find_trips_for(name)
    @tracker.find_trips_for_driver_by(name: name)    
  end

  def total_miles_driven_for(trips)
    total_miles_driven = trips.inject(0) {|sum, trip| sum += trip.miles_driven }
    total_miles_driven.round
  end

  def average_speed_for(trips)
    return 0 if trips.empty?

    total_speed = trips.inject(0) {|sum, trip| sum += trip.speed  }
    total_speed/trips.size
  end

  def sort_by_most_miles_driven_to_least(line_items)
    line_items.sort! { |a, b|  b.distance <=> a.distance }
  end

  def generate_output(line_items)
    output = ''
    line_items.each do |line_item|
      output << line_item.to_s + NEW_LINE
    end
    output
  end
end

DrivingLineItem

DrivingLineItem is an Immutable Value Object. It represents each line in the driving report.

class DrivingLineItem
  attr_reader :distance

  def initialize(driver:, distance:, speed:, trips:)
    @driver = driver
    @distance = distance
    @speed = speed
    @trips = trips
  end  

  def to_s
    out = "#{@driver}: #{@distance} miles"
    out += " @ #{@speed} mph" unless @trips.empty?
    out
  end
end

Design Intent

Minimize dependencies and reduce bi-directionality

This makes the solution simple and easy to maintain.

Write unit tests for every class

The reason for writing unit tests for every class is when someone makes modification to any of the classes to implement their feature, it will not break the features we have implemented.

require 'minitest/assertions'

module Minitest::Assertions
  #
  #  Fails unless +expected and +actual have the same trip details.
  #
  def assert_trip(expected, actual)
    assert same_trip(expected, actual),
      "Expected #{ expected.inspect } and #{ actual.inspect } to be the same trip"
  end

  def assert_association(driver, trip)
    assert association(driver, trip),
      "Expected #{ driver } and #{ trip } to be associated"    
  end

  def assert_registration(tracker, driver)
    assert registraion(tracker, driver),
      "Expected #{ driver } to be registered"    
  end

  private

  def same_trip(expected, actual)
    assert_equal expected.driver, actual.driver
    assert_equal expected.start_time, actual.start_time
    assert_equal expected.stop_time, actual.stop_time
    assert_equal expected.miles_driven, actual.miles_driven
  end

  def association(driver, trip)
    assert_equal driver.name, trip.driver  
  end

  def registraion(tracker, driver)
    assert tracker.registered?(driver)
  end
end
require "minitest/autorun"
require_relative 'custom_assertions'

class TestReport < Minitest::Test
  def context(input)
    @driver = Driver.new(name: 'Dan')

    fake_file = StringIO.new(input)
    command_processor = CommandProcessor.new(fake_file)
    command_processor.process
    @tracker = command_processor.tracker    
  end

  # The first command is Driver, which will register a new Driver in the app
  def test_register_new_driver_in_the_app
    input = %{Driver Dan
Trip Dan 07:15 07:45 17.3
}
    context(input)

    assert_registration @tracker, @driver
  end

  # Trip command will record a trip attributed to a driver
  def test_record_trip_for_a_driver
    input = %{Driver Dan
Trip Dan 07:15 07:45 17.3
}
    context(input)

    trip = @tracker.find_trips(@driver).first

    assert_association @driver, trip
  end

   # Trip consists of driver name, start time, stop time, miles driven
  def test_trip_consists_of_driver_name_start_time_stop_time_miles_driven
    input = %{Driver Dan
Trip Dan 07:15 07:45 17.3
}
    context(input)

    actual = @tracker.find_trips(@driver).first
    expected = Factory.create_trip('Trip Dan 07:15 07:45 17.3')

    assert_trip expected, actual
  end

  def test_driving_report
    input = %{Driver Dan
Driver Alex
Driver Bob
Trip Dan 07:15 07:45 17.3
Trip Dan 06:12 06:32 21.8
Trip Alex 12:01 13:16 42.0
} 
    context(input)

    actual_report = @tracker.driving_history_report

    expected_output = %{Alex: 42 miles @ 34 mph
Dan: 39 miles @ 50 mph
Bob: 0 miles
}

    assert_equal expected_output, actual_report
  end

  # Discard any trips that average a speed of less than 5 mph or greater than 100 mph.
  def test_driving_report_excludes_out_of_range_speed
    input = %{Driver Dan
Driver Alex
Driver Bob
Trip Dan 07:15 07:45 17.3
Trip Dan 07:15 07:45 51
Trip Dan 07:15 07:45 2
Trip Dan 06:12 06:32 21.8
Trip Alex 12:01 13:16 42.0
} 
    context(input)

    actual_report = @tracker.driving_history_report

    expected_output = %{Alex: 42 miles @ 34 mph
Dan: 39 miles @ 50 mph
Bob: 0 miles
}

    assert_equal expected_output, actual_report
  end
end
require "minitest/autorun"

class TestReport < Minitest::Test
  def test_trip_within_range_for_lower_bound
    trip = Factory.create_trip('Trip Dan 07:00 08:00 5')

    assert trip.within_range?
  end

  def test_trip_out_of_range_lower_bound
    trip = Factory.create_trip('Trip Dan 07:00 08:00 4')

    refute trip.within_range?    
  end

  def test_trip_within_range_for_upper_bound
    trip = Factory.create_trip('Trip Dan 07:00 08:00 100')

    assert trip.within_range?    
  end

  def test_trip_out_of_range_upper_bound
    trip = Factory.create_trip('Trip Dan 07:00 08:00 101')

    refute trip.within_range?    
  end

  def test_trip_within_range
    trip = Factory.create_trip('Trip Dan 07:00 08:00 90')

    assert trip.within_range?    
  end

  def test_round_miles_driven_per_hour_to_the_nearest_integer
    trip = Factory.create_trip('Trip Dan 07:00 08:00 100.4')

    assert_equal 100, trip.speed
  end
end

We can run the solution:

ruby example.rb input.txt

The example.rb:

if ARGV.empty?
  puts "Input file missing. Example: ruby example.rb input.txt"
else  
  file = File.open(ARGV[0])

  command_processor = CommandProcessor.new(file)
  command_processor.process
  tracker = command_processor.tracker    

  actual_report = tracker.driving_history_report

  puts actual_report
end

The input.txt:

Driver Dan
Driver Alex
Driver Bob
Trip Dan 07:15 07:45 17.3
Trip Dan 07:15 07:45 100
Trip Dan 07:15 07:45 2
Trip Dan 06:12 06:32 21.8
Trip Alex 12:01 13:16 42.0

Assumption

Requirements Document Sample Output:

Dan: 39 miles @ 47 mph 

is wrong.


Related Articles


Ace the Technical Interview

  • Easily find the gaps in your knowledge
  • Get customized lessons based on where you are
  • Take consistent action everyday
  • Builtin accountability to keep you on track
  • You will solve bigger problems over time
  • Get the job of your dreams

Take the 30 Day Coding Skills Challenge

Gain confidence to attend the interview

No spam ever. Unsubscribe anytime.