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.

Rails-autoload-how-it-works

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.

  1. 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 calling MitUniversity::Course, Ruby's constant lookup autoloaded Course in memory.
    So if we try to create an object for MitUniversity::Engineering, it refers to the Course which was already autoloaded in memory instead of searching for MitUniversity::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 initializing Course first, it still loads MitUniversity::Course as expected when called from MitUniversity::Engineering class.

  2. 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 calling MitUniversity::Course it will throw an error uninitialized 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 of MitUniversity.

    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>
    
  3. 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 from Rectangle, so when we call Rectangle.all the result must include Polygon of type Square along with Rectangle.

    With classic mode

    However, when we call Rectangle.all we do not see Square records in the result. We can see the generated SQL query does not include Square.

     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 the rectangle.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 add require_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.

References

  1. Common gotchas
  2. Rails autoloading — now it works, and how!
  3. Understanding Zeitwerk in Rails 6
  4. Classic to Zeitwerk HOWTO
  5. RailsConf 2022 - Opening Keynote: The Journey to Zeitwerk by Xavier Noria