I recently had the good fortune of attending Practical Object-Oriented Design with Sandi Metz and Katrina Owen. It was fantastic. Three days with them did more to improve my object-oriented thinking than months of reading and experimentation. I can't recommended the course highly enough.

Sandi and Katrina provided an elegant refactoring guideline on the first day:

Stay one undo away from green.

It was a key take-away and it's as simple as it sounds. Make one small change at a time during refactoring. If the tests go red, hit undo and immediately get back to green. If you're like me, and easily venture a dozen or more changes away from a passing test suite, this approach is transformative.

To illustrate, they walked the class through their refined, systematic process for extracting a conditional into a method. Two examples of their approach are below. The first lays out the steps used when starting with an 'else' branch. The second starts with an explicit case. The number of steps may appear tedious but each change is tiny. The entire process goes very quickly. As we discovered in class, picking a good name for the method takes longer than typing the code.


Extracting a conditional starting with the 'else' branch

Step 0 - Initial Code

class Bottles def verse_for(number) if number == 1 "#{number} bottle of beer on the wall..." else "#{number} bottles of beer on the wall..." end end end

Step 1 - Identify the things that are most alike.

class Bottles def verse_for(number) if number == 1 "#{number} bottle of beer on the wall..." else "#{number} bottles of beer on the wall..." end end end

Step 2 - Identify the smallest difference in the things that are most alike.

class Bottles def verse_for(number) if number == 1 "#{number} bottle of beer on the wall..." else "#{number} bottles of beer on the wall..."</span> end end end

Step 3 - Make a method to return the value needed by the 'else' case but don't actually call it. Just make sure it parses.

class Bottles def verse_for(number) if number == 1 "#{number} bottle of beer on the wall..." else "#{number} bottles of beer on the wall..." end end def container "bottles" end end

Step 4 - Execute the method without actually using the value.

class Bottles def verse_for(number) container if number == 1 "#{number} bottle of beer on the wall..." else "#{number} bottles of beer on the wall..." end end def container "bottles" end end

Step 5 - Used the new method in the 'else' case.

class Bottles def verse_for(number) # container if number == 1 "#{number} bottle of beer on the wall..." else "#{number} #{container} of beer on the wall..." # Replaced 'bottles' end end def container "bottles" end end

Step 6 - Shim on an argument and run the tests to make sure it still passes.

class Bottles def verse_for(number) if number == 1 "#{number} bottle of beer on the wall..." else "#{number} #{container} of beer on the wall..." end end def container(bottle_count=:FIXME) "bottles" end end

Step 7 - Send an argument to the method even though it's not used.

class Bottles def verse_for(number) if number == 1 "#{number} bottle of beer on the wall..." else "#{number} #{container(number)} of beer on the wall..." end end def container(bottle_count=:FIXME) "bottles" end end

Step 8 - Remove the default shim for the argument.

class Bottles def verse_for(number) if number == 1 "#{number} bottle of beer on the wall..." else "#{number} #{container(number)} of beer on the wall..." end end def container(bottle_count) # Removed =:FIXME "bottles" end end

Step 9 - Update the method to have the new functionality without actually calling it in the new location.

class Bottles def verse_for(number) if number == 1 "#{number} bottle of beer on the wall..." else "#{number} #{container(number)} of beer on the wall..." end end def container(bottle_count) if bottle_count == 1 "bottle" else "bottles" end end end

Step 10 - Call the method in the new location.

class Bottles def verse_for(number) if number == 1 "#{number} #{container(number)} of beer on the wall..." # Replaced 'bottle' else "#{number} #{container(number)} of beer on the wall..." end end def container(bottle_count) if bottle_count == 1 "bottle" else "bottles" end end end

Step 11 - Comment out unused cases.

class Bottles def verse_for(number) # if number == 1 # "#{number} #{container(number)} of beer on the wall..." # else "#{number} #{container(number)} of beer on the wall..." # end end def container(bottle_count) if bottle_count == 1 "bottle" else "bottles" end end end

Step 12 - Remove comments and you're done.

class Bottles def verse_for(number) "#{number} #{container(number)} of beer on the wall..." end def container(bottle_count) if bottle_count == 1 "bottle" else "bottles" end end end


Extracting a conditional starting with a specific case

Step 0 - Initial Code

class Bottles def verse_for(number) if number == 1 "#{number} bottle of beer on the wall..." else "#{number} bottles of beer on the wall..." end end end

Step 1 - Identify the things that are most alike.

class Bottles def verse_for(number) if number == 1 "#{number} bottle of beer on the wall..." else "#{number} bottles of beer on the wall..." end end end

Step 2 - Identify the smallest difference in the things that are most alike.

class Bottles def verse_for(number) if number == 1 "#{number} bottle of beer on the wall..." else "#{number} bottles of beer on the wall..."</span> end end end

Step 3 - Make a method that takes an unused argument and returns the value needed but don't actually call it. Just make sure it parses.

class Bottles def verse_for(number) if number == 1 "#{number} bottle of beer on the wall..." else "#{number} bottles of beer on the wall..." end end def container(bottle_count) "bottle" end end

Step 4 - Execute the method without actually using the value.

class Bottles def verse_for(number) container(number) if number == 1 "#{number} bottle of beer on the wall..." else "#{number} bottles of beer on the wall..." end end def container(bottle_count) "bottle" end end

Step 5 - Use the new method

class Bottles def verse_for(number) # container(number) if number == 1 "#{number} #{container(number)} of beer on the wall..." # Replaced 'bottle' else "#{number} bottles of beer on the wall..." end end def container(bottle_count) "bottle" end end

Step 6 - Update the method to have the new functionality without actually calling it in the new location.

class Bottles def verse_for(number) if number == 1 "#{number} #{container(number)} of beer on the wall..." else "#{number} bottles of beer on the wall..." end end def container(bottle_count) if bottle_count == 1 "bottle" else "bottles" end end end

Step 7 - Call the method in the 'else' case.

class Bottles def verse_for(number) if number == 1 "#{number} #{container(number)} of beer on the wall..." else "#{number} #{container(number)} of beer on the wall..." # Replaced 'bottles' end end def container(bottle_count) if bottle_count == 1 "bottle" else "bottles" end end end

Step 8 - Comment out unused case.

class Bottles def verse_for(number) # if number == 1 "#{number} #{container(number)} of beer on the wall..." # else # "#{number} #{container(number)} of beer on the wall..." # end end def container(bottle_count) if bottle_count == 1 "bottle" else "bottles" end end end

Step 9 - Remove comments and you're done.

class Bottles def verse_for(number) "#{number} #{container(number)} of beer on the wall..." end def container(bottle_count) if bottle_count == 1 "bottle" else "bottles" end end end

Don't let the terse nature of the examples fool you. They are simplified versions of a 99 Bottles exercise used throughout the class. The full problem has a lot more going on and the actual extraction provides benefits that aren't evident in this demonstration. If you want to learn more, definitely attend the class. Sandi and Katrina also have a '99 Bottles Of OOP' book coming out soon. Based on the class, I expect it'll be great. Sign up for info about it if you're interested.