The traditional autoloader in Rails was quite helpful, but it had a few flaws that occasionally made autoloading challenging. To overcome the challenges, Xavier Noria introduced zeitwerk mode in Rails 6 with this PR and kept it configurable. Rails 7 onwards, the zeitwerk completely replaces the classic autoloader.
In this article, we'll look at some gotchas in classic autoloading and how Zeitwerk mode solves them.
(You may read this article to learn and understand how Rails autoloading works).
How classic autoloading works?
From the start, Rails used an autoloader implemented in Active Support called Classic Autoloading which is available till Rails 6.
Classic Autoloading depends on the Ruby constant lookup. To resolve a constant, it is first searched in the lexical scope of the defined class and then in its ancestors. If the constant is not found, the const_missing
method is called by Ruby. Rails overrides Ruby's const_missing
method and uses the autoload_paths
to resolve the constant as per the convention.
How zeitwerk autoloading works?
The newly introduced Zeitwerk Mode does not depend on Ruby's constant lookup.
Instead, it makes use of Ruby's Module#autoload
method to instruct Ruby ahead of time which file will define a specific constant without incurring the cost of loading that file right away.
Common Problems resolved by zeitwerk mode
Various issues were present with the classic mode and have been resolved by the zeitwerk mode. Out of them, we'll look at three different gotchas, each with an example.
-
When Constants aren't Missed
Let's consider we have the following model structure.# course.rb class Course def initialize puts "From Course" end end # mit_university/course.rb module MitUniversity class Course def initialize puts "From MitUniversity::Course" end end end # mit_university/engineering.rb module MitUniversity class Engineering def initialize @course = Course.new end end end
With Classic Mode
Loading development environment (Rails 5.2.7.1) 2.7.5 :001 > Course.new From Course => #<Course:0x0000563bfa029810> 2.7.5 :002 > MitUniversity::Engineering.new From Course => #<MitUniversity::Engineering:0x0000563bf9e7ab40 @course=#<Course:0x0000563bf9e7aaf0>>
Here, as we have called
Course
before callingMitUniversity::Course
, Ruby's constant lookup autoloadedCourse
in memory.
So if we try to create an object forMitUniversity::Engineering
, it refers to theCourse
which was already autoloaded in memory instead of searching forMitUniversity::Course
.
This makes autoloading dependent on the order in which constants are called.With Zeitwerk Mode
Loading development environment (Rails 7.0.3) 2.7.5 :001 > Course.new From Course => #<Course:0x00005615a9707fa0> 2.7.5 :002 > MitUniversity::Engineering.new From MitUniversity::Course => #<MitUniversity::Engineering:0x00005615ae41d290 @course=#<MitUniversity::Course:0x00005615ae40b270>> 2.7.5 :003 >
As zeitwerk mode defines
autoload_path
for all the constants, it already knows where to look for which constant. So despite initializingCourse
first, it still loadsMitUniversity::Course
as expected when called fromMitUniversity::Engineering
class. -
Autoloading within Singleton Classes
A similar issue has been resolved for the Singleton class method using zeitwerk mode. An example is shown below:# mit_university/course.rb module MitUniversity class Course def initialize puts "From MitUniversity::Course" end end end # mit_university/engineering.rb module MitUniversity class Engineering class << self def details Course.new end end end end
With classic mode
If we call
MitUniversity::Engineering.details
before callingMitUniversity::Course
it will throw an erroruninitialized constant Course
. This is because, when autoloading is triggered, Rails only checks the top-level namespace as the singleton class is anonymous and does not know about the nesting ofMitUniversity
.Loading development environment (Rails 5.2.7.1) 2.7.5 :001 > MitUniversity::Engineering.details Traceback (most recent call last): 2: from (irb):3 1: from app/models/mit_university/engineering.rb:5:in `details' NameError (uninitialized constant Course) 2.7.5 :002 > MitUniversity::Course => MitUniversity::Course 2.7.5 :003 > MitUniversity::Engineering.details From MitUniversity::Course => #<MitUniversity::Course:0x0000560cabb27c18>
With zeitwerk mode
Zeitwerk does not throw any error and loads the constant even if it was not autoloaded before.
Loading development environment (Rails 7.0.3) 2.7.5 :001 > MitUniversity::Engineering.details From MitUniversity::Course => #<MitUniversity::Course:0x0000559ebba13dd0>
-
Autoloading and Single-table Inheritance (STI)
Let us say, we have the following Single-table Inheritance (STI) models defined.class Polygon < ApplicationRecord end class Triangle < Polygon end class Rectangle < Polygon end class Square < Rectangle end
Square
inherits fromRectangle
, so when we callRectangle.all
the result must includePolygon
of typeSquare
along withRectangle
.With classic mode
However, when we call
Rectangle.all
we do not seeSquare
records in the result. We can see the generated SQL query does not includeSquare
.Loading development environment (Rails 5.2.7.1) 2.7.5 :001 > Rectangle.all Rectangle Load (0.4ms) SELECT `polygons`.* FROM `polygons` WHERE `polygons`.`type` = 'Rectangle' /* loading for inspect */ LIMIT 11 => #<ActiveRecord::Relation [#<Rectangle id: 1, area: 100.0, type: "Rectangle", type_id: nil, created_at: "2022-08-28 14:00:50.705523000 +0000", updated_at: "2022-08-28 14:00:50.705523000 +0000">, #<Rectangle id: 2, area: 200.0, type: "Rectangle", type_id: nil, created_at: "2022-08-28 14:01:09.543825000 +0000", updated_at: "2022-08-28 14:01:09.543825000 +0000">]> 2.7.5 :002 > Square => Square(id: integer, area: float, type: string, type_id: integer, created_at: datetime, updated_at: datetime) 2.7.5 :003 > Rectangle.all Rectangle Load (0.9ms) SELECT `polygons`.* FROM `polygons` WHERE `polygons`.`type` IN ('Rectangle', 'Square') /* loading for inspect */ LIMIT 11 => #<ActiveRecord::Relation [#<Rectangle id: 1, area: 100.0, type: "Rectangle", type_id: nil, created_at: "2022-08-28 14:00:50.705523000 +0000", updated_at: "2022-08-28 14:00:50.705523000 +0000">, #<Rectangle id: 2, area: 200.0, type: "Rectangle", type_id: nil, created_at: "2022-08-28 14:01:09.543825000 +0000", updated_at: "2022-08-28 14:01:09.543825000 +0000">, #<Square id: 5, area: 250.0, type: "Square", type_id: nil, created_at: "2022-08-28 14:01:52.165141000 +0000", updated_at: "2022-08-28 14:01:52.165141000 +0000">]>
To resolve this, we have to add
require_dependency 'square'
at the bottom of therectangle.rb
file.# app/models/rectangle.rb class Rectangle < Polygon end require_dependency 'square'
With zeitwerk mode
Since in zeitwerk mode
Square
gets autoloaded we don't need to addrequire_dependency 'square'
.Loading development environment (Rails 7.0.3) 2.7.5 :001 > Rectangle.all Rectangle Load (0.9ms) SELECT `polygons`.* FROM `polygons` WHERE `polygons`.`type` IN ('Rectangle', 'Square') /* loading for inspect */ LIMIT 11 => #<ActiveRecord::Relation [#<Rectangle id: 1, area: 100.0, type: "Rectangle", type_id: nil, created_at: "2022-08-28 14:00:50.705523000 +0000", updated_at: "2022-08-28 14:00:50.705523000 +0000">, #<Rectangle id: 2, area: 200.0, type: "Rectangle", type_id: nil, created_at: "2022-08-28 14:01:09.543825000 +0000", updated_at: "2022-08-28 14:01:09.543825000 +0000">, #<Square id: 5, area: 250.0, type: "Square", type_id: nil, created_at: "2022-08-28 14:01:52.165141000 +0000", updated_at: "2022-08-28 14:01:52.165141000 +0000">]>
Conclusion
With Rails 7, Zeitwerk has become the default mode and classic mode is no longer available. This is an impactful change that has improved the way constants are autoloaded in Rails and has resolved many more issues with classic autoloading apart from the ones we have covered above.