Understanding Zeitwerk in Rails 6
Zeitwerk is the new code loader engine used in Rails 6. It’s meant to be the new default for all Rails 6+ projects replacing the old classic engine.
If we judge by its features, Zeitwerk mode seems to behave pretty much the same as the previous classic mode. The basics are very similar: models, controllers, helpers and so forth will continue to be autoloaded, reloaded and eager loaded as they have always been.
However, the main difference resides in the implementation. Zeitwerk uses a better strategy for loading things, which solves all of the known gotchas that classic mode carries.
Also, something exciting is that Zeitwerk comes as a gem, which means you can leverage it inside other non-rails projects.
Why is it better?
Just like classic, Zeitwerk provides the features of code autoloading, eager loading, and reloading.
However, while classic mode relies on the const_missing
callback for loading files, Zeitwerk uses Ruby’s native Module#autoload
method.
Let’s explore the differences to understand why the latter is better.
How classic mode autoloads
Classic mode algorithm mainly relies on two factors: autoload_paths
and the use of Ruby’s const_missing
callback.
How does it work?
During code execution, every time Ruby finds an unknown constant reference a const_missing
callback is fired. Rails overrides the default const_missing
callback from Ruby, which would normally just raise a NameError
. Instead, it performs an attempt to load the file associated with the constant being looked up.
This is when autoload_paths
comes into play. Rails walks through the autoload_paths
list looking for the “snake case” version of the referenced constant and, if it exists, loads/requires the file.
If everything works well, the file is required, the constant gets defined, and the code execution continues.
This works fine, but it has a few limitations.
For example, there’s a well-known issue that comes with classic mode known as When constants aren’t missed. Basically, it describes how you can easily introduce code that depends on the order in which files are autoloaded to work properly.
How so? Let’s see a quick example. Consider the following:
# app/models/user.rb
class User < ApplicationRecord
end# app/models/admin/user.rb
module Admin
class User < ApplicationRecord
end
end# app/models/admin/user_manager.rb
module Admin
class UserManager
def self.all
User.all # Want to load all admin users
end
end
end
In this case, if the constant Admin::User
was already loaded at the time Admin::UserManager.all
was called, then it would return Admin::User
objects.
However, if Admin::User
was not yet auto-loaded, but User
was, Admin::UserManager.all
would instead return User
objects!
This is obviously highly undesirable, and the source of the problem comes from design: classic relies on theconst_missing
callback and this callback is only fired as the last step of the lookup algorithm. So by construction, you can’t avoid having to deal with these issues at the application level.
So, how does Zeitwerk fix this? Continue reading…
Zeitwerk autoloading
Similarly to classic mode, Zeitwerk also relies on two factors. The first is the list of autoload_paths
(they are actually called root directories, but they are still referenced as autoload_paths
in the context of Rails).
The second factor and the main difference with classic is that Zeitwerk relies on Ruby’s native Module#autoload
method for triggering the autoloading of constants.
To understand it, let’s consider the following example. Let’s say you have a Rails 6 app with the following models:
app/
models/
comment.rb
post.rb
When the project boots, Rails will call Zeitwek#setup
. This method takes care of setting up the autoloaders for all of the known autoload_paths
(they won’t be loaded yet though).
In the example, app/models/
directory is included in the autoload_paths
, so Zeitwerk will set up a Module#autoload
for each one of the constants that are inferred by the name of the files inside that directory.
In this case, Zeitwerk will infer that comment.rb
and post.rb
should define the constants Comment
and Post
respectively.
So here’s the magic; Zeitwerk will execute the following code on your behalf:
autoload :Comment, Rails.root.join('app/models/comment.rb')
autoload :Post, Rails.root.join('app/models/post.rb')
It’s as simple as that! But, what does the above do? Let’s dig into how autoload
fits in this process.
Every time a Ruby program evaluates a constant there are certain steps that occur in order. For example, when Ruby evaluates the Post
constant:
- First, Ruby checks if there’s already a stored reference for
:Post
in the symbol table. If there’s one, it returns the pointer to the class definition. - If it doesn’t find one, then Ruby checks if there’s an
autoload
set up for:Post
- If there’s no autoload in place, then Ruby fires a
const_missing
callback. - However, if an
autoload
is indeed defined (like in our example), Rubyrequires
the path that was passed in as an argument to theautoload
call (Rails.root.join('app/models/post.rb')
) and then expects the required file to definePost
And just like that, the Post
constant is auto-loaded!
This works great and is a better approach than classic. Why? Because autoload
is a built-in feature in Ruby, so instead of listening on const_missing
and manually loading stuff (which can get hacky) we get to use the method that is served by the virtual machine to solve the very same purpose!
Note: Zeitwerk relies on the convention that each file will define the constant that is named after the name of the file (meaning that
/comment.rb
should define theComment
constant). Luckily, this is not surprising for a normal Rails app.
Zeitwerk and Rails 6
Zeitwerk comes enabled with Rails 6 by default.
If you are upgrading, load_defaults "6.0"
will set Zeitwerk as the default autoloader for your project and you’ll have it for free.
But, if for any reason you don’t want to use it, you can always opt-out by setting config.autoloader = :classic
in your application.rb
.
Gem usage
One of the greatest advantages of Zeitwerk is that it’s built as a separate gem from Rails. So, if you are writing or maintaining a gem, you can easily add Zeitwerk as a dependency and you’ll be able to forget about require
statements!
You can read the up-to-date doc here, but mainly you just need to do:
# lib/my_gem.rb (main file)
require "zeitwerk"
loader = Zeitwerk::Loader.for_gem
loader.setup # ready!
module MyGem
# ...
end
loader.eager_load # optionally
And that’s it!
Check this sample diff from the nanoc gem when they introduced Zeitwerk to their project: https://github.com/nanoc/nanoc/pull/1403/files and look at all the code they were able to delete.
To me, this approach is highly desirable because manually writing and maintaining requires
is error-prone and fragile if you don’t do it carefully. With this gem, you can just forget all about it!
Thanks to Xavier Noria and all the contributors that put the Zeitwerk project together!