Designing Classes with a Single Responsibility

Chapter 2 Notes from Practical Object-Oriented Design in Ruby by Sandi Metz
ch2-oo-design

These notes discuss the second chapter of Sandi Metz's book with the code snippets in Ruby. If you're new to the Ruby language, you may want to check out this simple tutorial.

If you missed chapter 1, here it is.

I've included quotes from the book and important notes. All Ruby codes here are taken from the book or slightly changed.

Let's start chapter 2 now...

Your goal is to model your application, using classes, such that it does what it is supposed to do right now and it is also easy to change later.

Sometimes you know how to write code but not where to put it. Once you know where to put that code, you can decide what belongs in a class.

Organizing Code to Allow for Easy Changes

The idea of easy is too broad; you need concrete definitions of easiness and specific criteria by which to judge code. If you define easy to change as:

  • Changes have no unexpected side effects
  • Small changes in requirements require correspondingly small changes in code
  • Existing code is easy to reuse
  • The easiest way to make a change is to add code that in itself is easy to change

Then the code you write should have the following qualities. Code should be:

  • Transparent The consequences of change should be obvious in the code that is changing and in distant code that relies upon it
  • Reasonable The cost of any change should be proportional to the benefits the change achieves
  • Usable Existing code should be usable in new and unexpected contexts
  • Exemplary The code itself should encourage those who change it to perpetuate these qualities

Code that is Transparent, Reasonable, Usable, and Exemplary (TRUE) not only meets today's needs but can also be changed to meet the needs of the future. The first step in creating code that is TRUE is to ensure that each class has a single, well-defined responsibility.

Creating Classes That Have a Single Responsibility

When you create a class, put this in your mind: Class always does the smallest possible useful thing. It shouldn't do two things. That is to say, it should have a single responsibility.

An Example Application: Bicycles and Gears

Let's design a class and try to apply single responsibility by solving a problem. Say, you want to buy a bike and you'd like to make sure what bike would be a better choice. That choice can depend on how long the bike will move once you push the pedal. Bicyclists compare that by comparing the teeth of two gears; the one at the front (chainring) and the rear one (cog).

The gear combining a 52-teeth chainring and an 11-teeth cog has a ratio of ~ 4.73 which means the rotational motion is converted to translational 5 times that round your feet do when they push the pedal.

Here is the simple class design quoted from the book:

class Gear
    attr_reader , 
    def initialize chainring, cog
        @chainring = chainring
        @cog       = cog
    end

    def ratio
        chainring / cog.to_f
    end
end

puts Gear.new(52, 11).ratio # 4.7272727272727275
puts Gear.new(30, 27).ratio # 1.1111111111111112

Since the Gear class is a subclass of Object it inherits all its methods like new so we pass the arguments of chainring and cog teeth to it. calling the ratio method calculates the ratio of both teeth dividing chainring by the cog.

Let's iterate on this class if you want to consider the wheel size.

Cyclists (at least in the US) use something called gear inches to compare bicycles that differ in both gearing and wheel size. The formula follows: gear inches = wheel diameter * gear ratio

where wheel diameter = rim diameter + twice tire diameter

Let's add this to our Gear class:

class Gear
    attr_reader , , , 
    def initialize chainring, cog, rim, tire
        @chainring = chainring
        @cog       = cog
        @rim       = rim
        @tire      = tire
    end

    def ratio
        chainring / cog.to_f
    end

    def gear_inches
        ratio * (rim + (2 * tire))
    end
end

puts Gear.new(52, 11, 26, 1.5).gear_inches # 137.0909090909091

puts Gear.new(52, 11, 24, 1.25).gear_inches # 125.27272727272728

There is a problem with this design because ratio does not work anymore:

puts Gear.new(52, 11).ratio 
# gear_wheel_sz.rb:3:in `initialize': wrong number of arguments (given 2, expected 4) (ArgumentError)
#   from gear_wheel_sz.rb:23:in `new'
#   from gear_wheel_sz.rb:23:in `<main>'

Let's ignore this bug for now and see if this class has a single responsibility or not.

Determining if a Class has a Single Responsibility

One way to determine if a class has a single responsibility or not is to ask the methods questions. For example, a question like Please Mr. Gear, what is your ratio? makes perfect sense, while Please Mr. Gear, what is your gear_inches? is on shaky ground, and Please Mr. Gear, what is your tire (size)? seems ridiculous.

Another way is to be able to describe the class in one sentence. When you use the word 'and' or 'or', the class likely has more than one responsibility and you need to avoid that.

Determining When to Make Design Decisions

Ask yourself, is this class really a Gear? How it has rims and tires?! Perhaps Gear should be Bicycle? or maybe there is a Wheel in here?

This "improve it now" versus "improve it later" tension always exists. Applications are never perfectly designed. Every choice has a price. A good designer understands this tension and minimizes costs by making informed tradeoffs between the needs of the present and the possibilities of the future.

Writing Code That Embraces Change

You want to create a code that depends on behavior, not data. A behavior is captured by methods. The "Don't Repeat Yourself" (DRY) phrase is a shortcut to writing a class that has a single responsibility.

One way to make your code changeable is to make hide instance variables just like we did with cog for example. We used attr_reader which causes Ruby to create a wrapper method for the variables. It virtually created this method for cog:

def cog
    @cog
end

so that you don't have to call the instance variable @cog each time you need it.

Also, to make your code changeable you should hide data structure as in the following example:

class ObscuringReferences
    attr_reader 
    def initialize data
        @data = data
    end

    def diameters
        # 0 is rim, 1 is tire
        data.collect {|diameter|
            diameter[0] + (diameter[1] * 2)}
    end
end

puts ObscuringReferences.new([[662, 20], [662, 23]]).diameters

Remember when we had the equation inside the gear_inches which has rim + (2 * tire). The above example shows that we can do that by defining a data structure of 2d array data which explicitly shows that if you iterate over data, you'll find rims are at the index 0 and tires are at the index 1.

This simple example is bad enough; imagine the consequences if data returned an array of hashes that were referenced in many places. A change to its structure would cascade through your code; each change represents an opportunity to create a bug so stealthy that your attempts to find it will make you cry.

We can solve this issue by applying the following:

class RevealingReferences
    attr_reader wheels
    def initialize data
        @wheels = wheelify data
    end

    def diameters
        wheels.collect {|wheel|
            wheel.rim + (2 * wheel.tire)}
    end

    Wheel = Struct.new(, )
    def wheelify data
        data.collect {|diameter|
            Wheel.new(diameter[0], diameter[1])}
    end
end

The wheelify method contains the only bit of code that understands the structure of the incoming array and the diameters method only knows that the message wheels returns an enumerable and that each enumerated thing responds to rim and tire.

Enforce Single Responsibility Everywhere

Single responsibility is not only applied to classes but also methods.

Looking at the diameters method, we can see it has two responsibilities: it iterates over the wheel and calculates the diameters:

def diameters
    wheels.collect {|wheel|
        wheel.rim + (2 * wheel.tire)}
end

What we need to do is to separate the two responsibilities into two different methods:

# first method - iterates over the array
def diameters
    wheels.collect {|wheel| diameter(wheel)}
end
# second method - calculates the diameter of one wheel
def diameter wheel
    wheel.rim + (wheel.tire * 2)
end

This way, you can use the diameter method in other places inside your code. It's not overdesigned, it's just simplifying things for the future.

Remember the gear_inches method:

def gear_inches
    ratio * (rim + (2 * tire))
end

this method has two responsibilities that enforce us to isolate them:

def gear_inches
    ratio * diameter
end

def diameter
    rim + (2 * tire)
end

here we calculated the wheel diameter in a separate method and then the output of it is used to determine gear_inches.

This simple refactoring makes the problem obvious. Gear is responsible for calculating gear_inches but Gear should not be calculating wheel diameter.

Extending the Gear class code further by adding diameter method:

class Gear
    attr_reader , , 
    def initialize chainring, cog, rim, tire
        @chainring = chainring
        @cog       = cog
        @wheel     = Wheel.new(rim, tire)
    end

    def ratio
        chainring / cog.to_f
    end

    def gear_inches
        ratio * wheel.diameter
    end

    Wheel = Struct.new(, ) do
        def diameter
            rim + (tire * 2)
        end
    end
end

Finally, the Real Wheel

There is a feature request coming your way to calculate the circumference of the wheel. It's simple, right? It is, but the real change here is that little change forces us to make a new design decision which is to make a separate class for Wheel independent of Gear.

Adding a new class in our case is simple because what you just need to do is to convert the structure data type that you made previously and add a circumference method to it.

class Gear
    attr_reader , , 
    def initialize chainring, cog, wheel=nil
        @chainring = chainring
        @cog       = cog
        @wheel     = wheel
    end

    def ratio
chainring / cog.to_f end def gear_inches ratio * wheel.diameter end end class Wheel attr_reader , def initialize rim, tire @rim = rim @tire = tire end def diameter rim + (tire * 2) end def circumference diameter * Math::PI end end CHAINRING = 26 COG = 1.5 RIM = 52 TIRE = 11 @wheel = Wheel.new(CHAINRING, COG) puts @wheel.diameter # 29.0 puts @wheel.circumference # 91.106186954104 puts Gear.new(RIM, TIRE, @wheel).gear_inches # 137.0909090909091 puts Gear.new(RIM, TIRE).ratio # 4.7272727272727275

Each class now has a single responsibility. The code is not perfect though, but it is good enough.

Final Thoughts

In this chapter, we've been introduced to the single responsibility and how changeable and maintainable object-oriented software is applied through classes that do one thing. Each class should be isolated from the rest of your application to make your code easy to change.

Note: Please note that these notes are not sufficient to get all the info from the book, these are my notes on the book. Sometimes I skip stuff and sometimes I rephrase statements based on my understandings and other times I write my own thoughts. If you want to get the complete knowledge behind the book, you've got to get the book and read it :)

See you in chapter 3 notes!

Get Practical Object-Oriented Design in Ruby now! (if you haven't already)

or this second edition (both links are affiliates)

Practical Object-Oriented Design in Ruby by Sandi Metz
By the author

Get my web scraping ebook

Published on medium

Join the conversation

Get FREE coupons and discounts
on my upcoming courses
when you subscribe!

Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.