Cognito Broadcast

How Ruby Hides Complexity

Ruby makes it easy to write concise code. This is a benefit of the language and the ecosystem. Matz focuses on “making programs succinct” and Rails boasts that it lets you build “in a matter of days” what used to take months.

Concise code can have a dark side. Convenient interfaces can tuck away complexity and side effects that might surprise you later. Brevity in software comes at the cost of diligence both from developers and reviewers. It is especially important to understand how your abstractions work and the business rules they implicitly handle.

Moving Fast

Imagine you are adding a new feature to your Ruby on Rails web application. This feature breaks down into three small tasks:

  • Integrate with an internal API which provides information about the current user
  • Use information about the current user in order add a welcome message to the header of each page
  • Display a flag alongside the message corresponding to the user’s countryfield

The current user JSON looks like this

{
  "status": "success",
  "data": {
    "name": {
      "first": "Edmond",
      "last": "O'Connell"
    },
    "address": {
      "street1": "53236 Camilla Light",
      "street2": null,
      "city": "Pierceville",
      "state": "NJ",
      "country": "United States"
    }
  }
}

To integrate with the API you create three simple classes with ActiveModel::Model:

class User
  include ActiveModel::Model

  attr_accessor :address, :name
end

class Name
  include ActiveModel::Model

  attr_accessor :first, :last
end

class Address
  include ActiveModel::Model

  attr_accessor :street1, :street2, :city, :state, :country
end

To extract the user data you use the new #dig method introduced in Ruby 2.3:

User.new(
  name:    Name.new(response.dig('data', 'name')),
  address: Address.new(response.dig('data', 'address')))
)

Finally, you add a current_country view helper method and create a new view partial:

module UserHelper
  def current_country
    return 'Unknown' unless current_user

    current_user.address.country
  end
end
<div id="user-welcome">
  <% if current_user %>
    <span>Welcome back <%= current_user.name.first %>!</span>
  <% end %>

  <div id="user-welcome-flag">
    <%= image_tag("/imgs/flags/#{current_country}.png") %>
  </div>
</div>

Breaking Things

A few weeks pass and you find out that some pages rendered the message “Welcome back !” and a broken image in place of the flag. The internal API encountered its own error and returned

{
  "status": "error",
  "message": "Internal server error"
}

Oddly enough this did not break your code:

response = { 'status' => 'error', 'message' => 'Internal server error' }

name    = response.dig('data', 'name')    # => nil
address = response.dig('data', 'address') # => nil

user = User.new(name: Name.new(name), address: Address.new(address))

user.name            # => #<Name:0x0011910412163>
user.address         # => #<Address:0x0011910412163>
user.name.first      # => nil
user.address.country # => nil

Feeling a bit embarrassed by the bug you reflect on how you could prevent similar issues in the future:

What if the internal API renames the country field to country_code? That would also silently break the view. Can I only avoid these cryptic bugs by being vigilant about every external dependency?

Reflection

The features in Ruby and Rails which let you write concise code can also let you cut corners. Consider our Name class and how the corresponding response data was originally extracted:

class Name
  include ActiveModel::Model

  attr_accessor :first, :last
end

module ResponseHandler
  def self.extract_name(response)
    Name.new(response.dig('data', 'name'))
  end
end

Let’s rewrite Name without ActiveModel or attr_accessor:

class Name
  # Inlined from Active Model source http://git.io/vuECr
  def initialize(params={})
    params.each do |attr, value|
      self.public_send("#{attr}=", value)
    end if params

    super()
  end

  def first
    @first
  end

  def first=(first)
    @first = first
  end

  def last
    @last
  end

  def last=(last)
    @last = last
  end
end

Imagining our code like this is instructive. It seems like three questions are now immediately obvious

  • Should the initializer invoke setter methods for any key passed to the initializer?
  • Will Name ever be invoked without arguments?
  • Are these public setter methods necessary or is Name a value object?

Let’s throw out #dig and instead handle each edge case manually.

module ResponseHandler
  def self.extract_name(response)
    return Name.new(nil) unless response.key?('data')
    return Name.new(nil) if     response['data'].empty?

    Name.new(response['data']['name'])
  end
end

Expanding this method highlights three distinct outcomes which are each important to consider. The original code properly handled a valid user object but overlooked two important edge cases:

1. API error handling when response['data'] is nil

return Name.new(nil) unless response.key?('data')

This happened when the internal API encountered an error. This condition should instead result in our application notifying the end user of an error.

2. Alternate behavior when a user is not returned

return Name.new(nil) if response['data'].empty?

This corresponds to the following JSON

{
  "status": "success",
  "data": {}
}

This might mean that the current user has not yet logged in. It could also be a buggy response.

Depending on how robust you expect the internal API to be you might want to handle this case independently as well. If this is invalid state then the response handler should raise an error. If it is valid state and you want to handle cases where the user is not logged in then there should be a separate Guest class independent of the User class.

Both of these options are better than implicitly assuming this condition never happens. Once the code embedding your assumption is deployed it is too easy to forget and unknowingly introduce a silent regression in the future.

Conclusions

Ruby certainly makes it easy to write concise code. The question then is how do you reap these benefits without cutting corners accidentally? At BlockScore we have a few practices which help us write better Ruby.

1. Strict and simple dependencies

Active Model’s initializer is permissive and this led to surprising behavior. Consider the benefit of a strict alternative like anima:

# Test cases
valid_arguments  = { first: 'John', last: 'Doe'                  }
missing_argument = { first: 'John'                               }
extra_argument   = { first: 'John', last: 'Doe', nickname: 'Jim' }

# With Active Model
class Name
  include ActiveModel::Model

  attr_accessor :first, :last
end

Name.new(valid_arguments)  # => #<Name:0x0011910412163 @first="John", @last="Doe">
Name.new(missing_argument) # => #<Name:0x0011910412163 @first="John">
Name.new(extra_argument)   # => NoMethodError: undefined method `nickname=`
Name.new(nil)              # => #<Name:0x0011910412163>
Name.new                   # => #<Name:0x0011910412163>

# With Anima
class Name
  include Anima.new(:first, :last)
end

Name.new(valid_arguments)  # => #<Name first="John" last="Doe">
Name.new(missing_argument) # => Anima::Error: Name attributes missing: [:last]
Name.new(extra_argument)   # => Anima::Error: Name attributes missing: [], unknown: [:nickname]
Name.new(nil)              # => NoMethodError: undefined method `keys'
Name.new                   # => ArgumentError: wrong number of arguments (given 0, expected 1)

2. Meticulous code review

An inconspicuous line of code like

Name.new(response.dig('data', 'name'))

can encode multiple important code paths. With Ruby it is especially important to visualize the equivalent “expanded” code.

3. Static analysis

Tools like reek and rubocop are great for learning how to write better code. Reek might point out a design issue before you notice it. Rubocop now goes way beyond style: the next release will include eight new cops for helping you catch bad performing code.

4. Mutation testing

Mutation testing helps me write better Ruby. It sniffs out dead code, helps me find missing tests, and generally helps me think about the assumptions I’ve made.

Ready to get started?

More Stories