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
country
field
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 tocountry_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.