Confident Ruby: A Review

Ryan Foster, Former Developer

Article Category: #Code

Posted on

Over the past six weeks, the development team here at Viget has been working through Confident Ruby, by Avdi Grimm. Confident Ruby makes the case that writing code is like telling a story, and reading code littered with error handling, edge cases, and nil checks is like listening to a bad story. The book presents techniques and patterns to write more expressive code with less noise—to tell a better story. Here are some of our favorites.

Using Hash#fetch for Flexible Argument Confidence

Lawson: Using option hashes as method arguments is great; it reduces argument order dependencies and increases flexibility. But with extra flexibility comes extra danger if you’re not careful.

 def do_a_thing(options)
 @important_thing = options[:important_thing]
end

# Inevitably
do_a_thing(important_thin: ImportantThing.new)

Typo-ing a key in the hash provided to method has started causing errors everywhere @important_thing is used, and it’s not immediately apparent where the bug truly lies. This is most definitely not confident code.

You can increase the confidence of the method like so: @important_thing = options[:important_thing] || raise WheresTheThingError, but this solution falls on its face when you require the ability to use nil or false as values.

Avdi suggests the liberal use of Hash#fetch to increase your code’s confidence without such value inflexibility.

 def do_a_thing(options)
 @important_thing = options.fetch(:important_thing)
end

Using #fetch establishes an assertion that the desired key exists. If it doesn’t, it raises a KeyError right at the source of the bug, making your bug scrub painless.

And there’s another benefit too; you can #fetch to set default values, while maintaining the flexibility to explicity use false or nil.

 def do_a_thing(options = {})
 @important_thing = options.fetch(:important_thing) { ImportantThing.new }
end

Document Assumptions with Assertions

Chris: It’s happened to every developer: you need to load data from an external system, but the documentation is incomplete, unhelpful, or nonexistent. You have sample data, but does it cover all possible inputs? Can you trust it?

Avdi suggests, “document every assumption about the format of the incoming data with an assertion at the point where the assumed feature is first used.” I like those words. Let’s see how it works in practice.

Suppose we’re writing an app to load sales reports from an external service and save them in your database for later querying. (Perhaps this app will show fancy charts and pizza graphs for sales of sportsball jerseys.) You might start with this code:

def load_sales_reports(date)
 reports = api.read_reports(date)

 reports.each do |report|
 create_sales_report(
 report['sku'],
 report['quantity'],
 report['amount']
 )
 end
end

Simple, clean, readable. And full of assumptions. We assume:

  • #read_reports always returns an array
  • report is a hash and contains values for sku, quantity, and amount
  • the values in report are valid inputs for our application

Let’s make some changes. (Assume we’ve determined that amount is a decimal string in the data and we will store it as an integer in our app.)

def load_sales_reports(date)
 reports = api.read_reports(date)

 unless reports.is_a?(Array)
 raise TypeError, "expected reports to be an array, got #{reports.class}"
 end

 reports.each do |report|
 sku = report.fetch('sku')
 quantity = report.fetch('quantity')
 amount = report.fetch('amount')

 cents = (Float(amount) * 100).to_i

 create_sales_report(sku, quantity, cents)
 end
end

Here’s how we’ve documented those assumptions

  • Raise an exception if reports is not an array
  • Retrieve the values using Hash#fetch
  • Convert amount to a float using Kernel#float, which, unlike String#to_f, will raise an exception if the input is not a valid float

Benefits to this approach include:

  • Initial development is easier because each point of failure is explicitly tested
  • Data is validated before it enters our database and app, reducing unexpected bugs down the road
  • No silent failures—if the data format ever changes in the future, we’ll know

High five.

Bonus Tip: Transactions are your friend

All this input validation is great, but if you aren’t careful a validation failure can easily put your database in an inconsistent state. The solution is easy: wrap that thing in a transaction.

SalesReport.transaction do
 sales_importer.load_sales_reports(date)
end

Any exception will rollback the transaction, leaving your data as it was before the import. Sweet.

You shall not pass!

you shall not pass

Ryan S.: I came across a section that talked about a really helpful pattern referred to as bouncer methods. These are methods that serve as a kind of post-processing state-check that either:

  1. Raise errors based on result state; or
  2. Allow the application to continue if the result state is acceptable

If you’re in a situation where you have a method that performs some kind of action regardless of input and then has to make sure that the resulting output is valid or else throw an error, look no further!

def do_a_thing_with(stuff)
 check_for_valid_output do
 process(stuff)
 end
end

def check_for_valid_output
 output = yield
 raise CustomError, 'Bad output!' unless valid_output?(output)
 output
end

def valid_output?(output)
 # do your validations
end

Well-written methods usually have a narrative to them. Bouncer methods are a great way to signal that the result of some nested action is going to be checked before allowing the application to continue on. They can help you maintain a narrative without cluttering up your method with explicit validations and/or error handling.

BRE GTFO LOL

David: Avdi opens his chapter on “Handling Failure” with a topic near to my heart: the awfulness of the begin/rescue/end (“BRE”) block. My favorite quote:

BRE is the pink lawn flamingo of Ruby code: it is a distraction and an eyesore wherever it turns up.

A top-level rescue is always preferable to a BRE block. If you think you need to rescue an exception from a specific part of your method using a BRE, consider instead creating a new method that uses a top-level rescue. Before:

 def foo
 do_work

 begin
 do_more_work
 rescue
 handle_error
 end
end

After:

 def foo
 do_work
 safe_do_more_work
end

def safe_do_more_work
 do_more_work
rescue
 handle_error
end

Much cleaner (terrible method names aside).

throw/catch for great justice

Ryan: Just the words “throw” and “catch” scare me. They remind me of dark, sad Java programming days. I’ve always been vaguely aware that Ruby, like Java, had throw and catch, but I’ve never used them. In Ruby land, our exception handling is raise and rescue. So what are throw and catch for?

Avdi shows an example in the book where a long-running task has the option to terminate early by raising a custom DoneException. Exceptions used like this can be handy to break out of logical loops. But is the act of “finishing” really exceptional? Not really.

Exceptions should be reserved for truly unusual and unexpected events. The code’s author was only hijacking DoneException because it could punch out of the current stack to finish executing.

Enter throw/catch. They give you the same power of execution flow without raising an exception:

 def consume_work
 #do things

 throw :finished if exit_early?
end

#elsewhere

def loopy_work
 while read_line
 consume_work
 end
catch :finished
 #clean up
end

throw/catch allow the code to be intentional. The execution is clearer to the reader and doesn’t raise unnecessary exception-esque attention.

Conclusion

Confident Ruby is an excellent book and we've already applied many of its lessons in our projects. It's packed with useful techniques and patterns—most of them are practical and immediately applicable, and all of them will help you write code that tells a better story.

Check it out and let us know in the comments what your favorite parts were. If you want to join us for the next round of book club, why not apply to work with us?

Related Articles