Rails Assumptions and Metaprogramming
Ruby is a beautiful, intuitive and expressive language. It has a rich set of libraries, excellent support for packages and namespaces, and permits you to write clear, explicit code. These features make it suitable for large, complex applications.
Unfortunately, Ruby on Rails employs a number of practices that complicate the process of developing and maintaining large-scale applications. Rails’ worst practices include a heavy reliance on assumptions and metaprogramming.
In virtually any application, the code is the authoritative documentation. Statically-typed languages, like Java and C#, require everything to be specified before compile time. This forces developers be explicit, and results in unambiguous code in which every class, property and method can be instantly discovered within a good IDE.
Some dynamically-typed languages, like Python, encourage explicitness and clarity, and have communities that practice these values.
Rails, however, relies on assumptions and dynamically-generated meta-methods that are by definition not in the code. Developers are left to learn about the assumptions through outside reading, and to discover the meta-methods by experimenting with the running application. This has a cost, over time, as developers must maintain in their minds vast amounts of information that, in other languages, can be left up to the IDE to maintain.
In Rails, you can’t just look at the code to answer a question like “What is the update action doing to my UserProfile model?”
You have to generate all of the contextual information of the running application and unwind all of the Rails assumptions to find your answer. This is especially difficult on large Rails applications that you did not write yourself.
Let’s assume we’ve inherited a Rails application and we’re having a problem saving a UserProfile model to the database. First, let’s have a look at the UserProfile model. Here’s the code for that model:
class UserProfile < ActiveRecord::Base
end
Very enlightening. It’s an empty class. Let’s do some introspection on this model in the Rails console.
> UserProfile.methods.count
=> 814
> UserProfile.first.methods.count
=> 729
OK, so our UserProfile model picked up 800+ class methods and 700 or so instance methods from ActiveRecord::Base. The rest of the instance methods came from columns in the users table of the database. ActiveRecord inspects the users table and uses Ruby’s meta-programming facilities to dynamically generate getter and setter methods for each column. Of course, there’s nothing in the code to tell you this. It just happens. It’s magic!
Now let’s have a look at the controller.
class UserProfilesController < ApplicationController
end
Hmmm. Nothing there. But we know that we can call each of the seven REST actions on our UserProfile model. So where are these actions defined? Let’s look in the ApplicationController.
class ApplicationController < ActionController::Base
inherit_resources
end
OK, we’re using InheritedResources. That automatically defines all of the RESTful actions for all of our controllers. But how can it define methods to work with ANY models?
Like ActiveRecord, it uses introspection to examine the state and form of objects and classes in the runtime context, and then uses Ruby’s metaprogramming capabilities to add methods to our controllers. Among other things, these methods load model instances, bind request parameters, validate and save models.
Rails is littered with meta-methods. ActiveRecord allows us to call a method like find_by_first_name_and_last_name on our UserProfile object, even though we never defined that method. ActiveRecord uses Ruby’s built-in method_missing to catch the call to the undefined method. Then it picks apart the name of the method to figure out what the developer probably wants to do– find a user by first name and last name– and it generates a method at runtime to do just that. The result is that you can call an undefined method, and it will do what you want. Again, magic!
That’s very convenient. So what’s wrong with it?
As a developer, you will typically spend much more time maintaining an application than you spend writing it. Maintenance includes fixing bugs, refactoring, adding features, and improving performance. All maintenance work requires an understanding of what the application is doing and how it works.
To understand what an application is doing and how it works, you read the code. This is especially true when you are new to a project, or when you’ve inherited an existing code base.
But you can’t read the code if it’s not there. In Rails projects, you will find methods everywhere that aren’t defined anywhere. Try debugging that!
Let’s go back to the hypothetical issue we wanted to look into: we’re having some trouble saving an instance of the UserProfile model. Looking at the model code doesn’t help, because none of the model attributes exist in the code. They magically spring into being at runtime.
Looking at the controller code doesn’t help, because none of the controller actions are defined. They too magically spring into being at runtime.
Even if we’re using an IDE with a visual debugger, we have nowhere to set a breakpoint.
The process of Rails development ultimately is not one of stepping through an explicit set of instructions that your program is executing. It is instead a process of unwinding the vast set of assumptions implicit in Rails and all its gems, and then evaluating what sort of code these assumptions will generate within some hypothetical context of a running application, and then evaluating that hypothetically generated code. This is not a productive use of mental energy.
Brian Kernighan said:
Everyone knows that debugging is twice as hard as writing a program in the first place. So if you’re as clever as you can be when you write it, how will you ever debug it?
The model and controller issues outlined above are simple examples of how magic hides complexity that you will need to dig into many times over the course of an application’s lifetime. These particular examples of magic are so common and fundamental to Rails that they would not trip up a beginner or intermediate-level Rails programmer.
But a real-world Rails application may include dozens or even hundreds of gems, many of which bring their own assumptions into your application, and generate their own meta-methods at runtime.
This is where complexity and the lack of explicitness start to kill you.