Delegating Dependencies Among Ruby Objects

Recently I picked up Sandi Metz’s Practical Objected Oriented Design in Ruby once more. It was great reading it again because as you become more experienced in programming you understand the why of many design techniques she elaborates on her book.

In this blog post, I would like to jot down my notes from chapter 3: Managing Dependencies. Once I got in to the habit of creating singly responsible classes and methods in Ruby, the consequence was that my objects became more interdependent (a natural consequence). Overdependency is perilous because as the application grows and accommodates changes it is prone to breaking. As Metz puts it, “If not managed carefully, these dependencies will strangle your application.”

Here are three techniques to reduce the impact of needy objects in your code.

Isolate Dependencies

Think of this as quarantining the dependency. You want to isolate the creation of a parasite object to a single place in a class. Check the code below.


class Book
 attr_reader :cover_type, :binding_type
 def initialize(cover_type, binding_type)
  @cover_type = cover_type
  @binding_type = binding_type
 end

 def page_numbers(number, height, length)
  number * Page.new(height, length)
 end

 def page_area(height, length)
  Page.new(height, length).area
 end

end

class Page 
 attr_reader :height, :length
 def initialize(height, length)
  @height = height
  @length = length
 end

 def area
  height * length
 end
end


See how the Book class is calling Page.new twice? It is clunky and if Book changed to receive RecycledPaper instead you would have to change the code in two locations. Let us isolate this Paper object dependency.


class Book
 attr_reader :page, :cover_type, :binding_type
 def initialize(cover_type, binding_type, page_height, page_length)
  @cover_type = cover_type
  @binding_type = binding_type
  @page = Page.new(page_height, page_length)
 end

 def page_numbers(number)
  number * page
 end

 def page_area
  page.area
 end

end

Here Page.new is confined inside the initialize method. This cleans up page_number and page_area.

An improvement from before for sure but not yet ideal. Notice the awkward page_height and page_length arguments in initialize. Still, if Book changes later to receive Recycled paper or a paper with different height or lengths, this class will have management issues. This is a good cue to introduce…

Dependency Injection

This technique takes out the need to construct the Paper object inside the Book class. Instead you pass Paper to Book as an argument when you initialize it.

This is how it would look like:


class Book
 attr_reader :page, :cover_type, :binding_type
 def initialize(page, cover_type, binding_type)
  @cover_type = cover_type
  @binding_type = binding_type
  @page = page
 end

 def page_numbers(number)
  number * page
 end

 def page_area
  page.area
 end
end

class Page 
 attr_reader :height, :length
 def initialize(height, length)
  @height = height
  @length = length
 end

 def area
  height * length
 end
end

#Book expects an argument that knows about page
Book.new(Page.new(8.5, 11), "hardcovers", "loop stitched")

As you see, Page isn’t attached to Book anymore. Book expects to be initialized with an object that can respond to page.area.

Remove Argument-Order Dependencies

Book now depends on its arguments to function properly. But if by any chance the order of the arguments is messed up during Book creation, all goes awry.

For example,

 
Book.new(Page.new(8.5, 11), "hardcovers", "loop stitched")

#this is cool
#<Book:0x00000102893160 @cover_type="hardcovers", @binding_type="loop stitched", @page=#>

Book.new("hardcovers", "loop stitched", Page.new(8.5, 11))

# What? @binding_type possesses Page object? Not cool.
#<Book:0x00000101ab1dd0 @cover_type="loop stitched", @binding_type=#, @page="hardcovers">

In order to remove this argument-order dependency, use hashes as initialize arguments. The next example shows that initialize now takes one argument, which is a hash that contains all the inputs.


class Book
 attr_reader :page, :cover_type, :binding_type
 def initialize(arguments)
  @cover_type = arguments[:cover_type]
  @binding_type = arguments[:binding_type]
  @page = arguments[:page]
 end

 def page_numbers(number)
  number * page
 end

 def page_area
  page.area
 end
end

class Page 
 attr_reader :height, :length
 def initialize(height, length)
  @height = height
  @length = length
 end

 def area
  height * length
 end
end

Book.new(
	cover_type: "hardcover",
	 binding_type: "loop stitched",
	 page: Page.new(8.5, 11) )

Every dependency on argument order is removed with the code above. Book is freer at last. This technique also allows to add defaults more easily.


class Book
 attr_reader :page, :cover_type, :binding_type
 def initialize(arguments)
  @cover_type = arguments[:cover_type] || "hardcover",
  @binding_type = arguments[:binding_type] || "loop stitched"
  @page = arguments[:page] 
 end

 def page_numbers(number)
  number * page
 end

 def page_area
  page.area
 end
end

Or Sandi Metz’s preferred way to implement defaults:


class Book
 attr_reader :page, :cover_type, :binding_type
 def initialize(arguments)
  arguments = defaults.merge(arguments)
  @cover_type = arguments[:cover_type],
  @binding_type = arguments[:binding_type]
  @page = arguments[:page] 
 end

 def defaults
 {covertype: "hardcover", biding_type: "loop stitched"}
 end

 def page_numbers(number)
  number * page
 end

 def page_area
  page.area
 end
end

#merge in the initialize method above (shockingly!) merges only if the keys are not in the hash.

And these are my notes. I hope you learned how to manage dependencies or if you knew this already, I hope this blog post served as a good review.

Now go on and start managing dependencies!

One thought on “Delegating Dependencies Among Ruby Objects

  1. Instead of using arguments[:cover_type] || “hardcover”, you could just use arguments.fetch(:cover_type, ‘hardcover’)

Leave a reply to Vitor Oliveira Cancel reply