Skip to content

Zeitwerk

Most Rails apps and many plain Ruby apps autoload their code with Zeitwerk, so moving that code into Cartons means running Zeitwerk inside them. Done carelessly, a Carton’s files can load in the app’s Box instead of the Carton’s, often without an error, and the boundary is gone. A Carton has Zeitwerk’s gem layout, so its Entrypoint can run a loader of its own. Two things then need care:

  • The app’s loader must not load a Carton’s files into the Main Box.
  • Reloading a Carton replaces its classes, but Importers keep the old ones.

Lazy autoloading needs no care: a constant a Carton leaves to autoload loads in the Carton Box even when the app references it first, including a namespace defined in a Zeitwerk nsfile. This example shows each case, with no Rails involved: main.rb is a short demo, and test/zeitwerk_test.rb checks every behavior below. Source on GitHub.

Gemfile # carton, zeitwerk 2.8.3 for the app
main.rb # the app's loader for lib/, then the imports
test/zeitwerk_test.rb # checks each case below
lib/
order_summary.rb # the app's own code, autoloaded in the Main Box
shipping.rb # shipping Carton's Entrypoint: reloadable loader with nsfiles
shipping/rate.rb # inside the shipping Carton
shipping/carriers/ns.rb # nsfile: defines Shipping::Carriers, left lazy
shipping/carriers/post.rb # Shipping::Carriers::Post
cartons/billing/
Gemfile # zeitwerk 2.7.5 for this Carton only
lib/billing.rb # billing Carton's Entrypoint: Zeitwerk::Loader.for_gem
lib/billing/invoice.rb # eager loaded inside the Carton
lib/billing/archive.rb # left lazy; still loads in the Carton Box

The app’s loader manages lib/, ignoring the shipping Carton there; each import then runs a Carton’s own loader in its Box:

main.rb
require 'carton'
# Bundle boundary: the app's Gemfile gives the Main Box zeitwerk 2.8.3.
Carton.setup_bundle
require 'zeitwerk'
# The app's own Zeitwerk loader, in the Main Box, for the app's code in lib/.
# The shipping Carton lives in lib/ too, so ignore it: otherwise this loader
# autoloads lib/shipping.rb in the Main Box, outside any Carton.
lib = File.join(__dir__, 'lib')
app_loader = Zeitwerk::Loader.new
app_loader.push_dir(lib)
app_loader.ignore(File.join(lib, 'shipping.rb'), File.join(lib, 'shipping'))
app_loader.setup
# Carton boundary: each import runs a Carton's Entrypoint in its own Box,
# where it sets up its own Zeitwerk loader.
Billing = import_relative 'cartons/billing'
Shipping = import_relative 'lib/shipping'
puts "Main Box zeitwerk: #{Zeitwerk::VERSION}"
puts "billing zeitwerk: #{Billing.zeitwerk_version}"
puts "shipping zeitwerk has nsfiles: #{Shipping.zeitwerk_nsfiles?}"
# OrderSummary is the app's own code, autoloaded in the Main Box. It calls
# both Cartons through their Exports.
puts OrderSummary.line(4200, 3)
Main Box zeitwerk: 2.8.3
billing zeitwerk: 2.7.5
shipping zeitwerk has nsfiles: true
invoice $42.00 + shipping $11.00

cartons/billing/lib/billing.rb calls Zeitwerk::Loader.for_gem, sets it up, eager loads, and ends with export Billing. Nothing else is needed: lib/billing.rb plus lib/billing/ is both a Carton and a Zeitwerk gem. Keep one Carton per lib/, since for_gem manages the whole directory.

cartons/billing/lib/billing.rb
# The billing Carton's Entrypoint. Everything in this file and lib/billing/
# runs in the billing Carton's own Box.
# Bundle boundary: cartons/billing/Gemfile locks zeitwerk 2.7.5, while
# the app locks 2.8.3. Each Box requires its own copy of Zeitwerk.
Carton.setup_bundle
require 'zeitwerk'
# A Carton already has Zeitwerk's gem layout (lib/billing.rb plus
# lib/billing/), so for_gem needs no configuration.
loader = Zeitwerk::Loader.for_gem
# Archive stays lazy to show that an autoload triggered from another Box still
# runs here; see this example's guide on the website.
loader.do_not_eager_load(File.join(__dir__, 'billing', 'archive.rb'))
loader.setup
module Billing
def self.invoice(cents) = Invoice.new(cents).to_s
# This Box's Zeitwerk, not the app's.
def self.zeitwerk_version = Zeitwerk::VERSION
def self.zeitwerk_dirs = Zeitwerk::Loader.all_dirs
def self.zeitwerk_nsfiles? = Zeitwerk::Loader.method_defined?(:nsfile=)
end
# Load the rest of the Carton now, as an app does in production, so a broken
# file fails at boot. Lazy autoloads would also run in this Box.
loader.eager_load
# Export: only Billing crosses the Carton boundary.
export Billing

Every Box requires Zeitwerk separately and gets its own Zeitwerk module, loader registry, and Kernel#require patch. The billing Carton runs zeitwerk 2.7.5 from its own Gemfile while the app runs 2.8.3. The shipping Carton sets up the app’s Gemfile in its own Box and gets its own copy of 2.8.3. Zeitwerk::Loader.all_dirs in each Box lists only that Box’s loaders:

Box Zeitwerk::VERSION Zeitwerk::Loader.all_dirs nsfiles
Main 2.8.3 lib yes
billing 2.7.5 cartons/billing/lib no
shipping 2.8.3 lib/shipping yes

So a Carton can use a Zeitwerk feature, such as nsfiles, that the app’s or another Carton’s version lacks.

The app’s loader and a Carton in its directory

Section titled “The app’s loader and a Carton in its directory”

The app’s loader manages lib/, and the shipping Carton lives in lib/ too, on purpose, to show this case; the billing Carton lives under cartons/, as other examples’ Cartons do. Left alone, the app’s loader defines an autoload for Shipping pointing at lib/shipping.rb. Anything in the app that touches Shipping before the import, including app_loader.eager_load, then runs the Entrypoint in the Main Box, outside any Carton. In this example that fails loudly: the Entrypoint’s own push_dir lands inside the app loader’s root, and Zeitwerk raises Zeitwerk::Error (“wants to manage directory …/lib/shipping, which is already managed by” the app’s loader). A Carton that used require_relative instead would load a Second Copy without any error.

main.rb happens to import before anything references Shipping, and the assignment Shipping = import_relative 'lib/shipping' replaces the pending autoload, so the example would run without a fix. That depends on load order. The fix is to tell the app’s loader the Carton is not its code:

app_loader.push_dir(lib)
app_loader.ignore(File.join(lib, 'shipping.rb'), File.join(lib, 'shipping'))

Ignore both the Entrypoint and its directory. The test checks that the Main Box has no autoload for Shipping and, after the app’s eager load, that no Carton file is in the Main Box’s $LOADED_FEATURES. A Carton outside the app’s loader roots, like cartons/billing, needs nothing.

An autoload a Carton defines loads in the Carton Box, whichever Box references the constant first. Billing::Archive is left lazy to show it: the test references it first from the Main Box, and archive.rb still runs in the billing Carton’s Box, through that Box’s own Zeitwerk, so on_load callbacks and reloading see it.

cartons/billing/lib/billing.rb still calls loader.eager_load, as an app does in production: a broken file then fails at boot, not on first use. Eager loading runs in the Carton Box too: no Carton file appears in the Main Box’s $LOADED_FEATURES, and Shipping::Rate records Ruby::Box.current when its file runs, which is not the Main Box.

Zeitwerk 2.8 added nsfiles: with loader.nsfile = 'ns.rb', a directory’s ns.rb defines the directory’s namespace, so a self-contained unit needs no file beside its directory. The shipping Carton’s loader sets one, and lib/shipping/carriers/ns.rb defines Shipping::Carriers, not Shipping::Carriers::Ns.

Shipping::Carriers is left lazy, so its autoload points at ns.rb until the test references it from the Main Box. ns.rb still runs in the shipping Carton’s Box, and Zeitwerk then defines Post’s autoload there, so Shipping::Carriers::Post loads in the Carton Box too. Reloading reloads the nsfile like any other file.

nsfiles organize a Carton’s Interior, not Carton boundaries. A Carton still needs its Entrypoint beside its directory, lib/shipping.rb beside lib/shipping/. A directory whose namespace is defined by its own ns.rb, with no file beside it, cannot be imported by its directory as a Carton.

Shipping.reload! calls loader.reload and loader.eager_load. The block was defined in the shipping Carton’s Box, so it runs there even though the test calls it from the Main Box. After a reload:

  • Shipping is the same module. Its Entrypoint defines it and the loader manages only lib/shipping/ inside it, via push_dir(..., namespace: Shipping).
  • Shipping::Rate is a new class, loaded again in the Carton Box, and so is Shipping::Carriers, from its nsfile.
  • A reference the Importer kept, such as held_rate_class = Shipping::Rate, is the old class and still runs the old code. Instances made before the reload are instances of the old class, so is_a?(Shipping::Rate) is false for them.

So reloading refreshes code in a Carton’s Interior for code that looks constants up through the Carton’s root module each time. It does not update constants, instance variables, or objects an Importer already holds. A Shared Import after a reload returns the same Export module, not a new one.

One setup does not reload:

  • A for_gem loader manages the Entrypoint too. Reloading removes the top-level Billing from the Carton Box, and the Billing module Importers hold loses its autoloaded constants, so Billing::Invoice raises NameError for them. Loading Billing again re-runs the Entrypoint, which fails either way. Eager loading it calls enable_reloading while the loader is busy and raises ThreadError (“deadlock; recursive locking”); Zeitwerk does the same without Boxes, since a loader cannot reload the file that configures it. Autoloading it lazily reaches export a second time, which raises Carton::ExportError. Use push_dir(..., namespace:) for a reloadable Carton.
lib/shipping.rb
# The shipping Carton's Entrypoint. It sits in the app's lib/, which the app's
# own Zeitwerk loader manages in the Main Box, so main.rb tells that loader to
# ignore this file and lib/shipping/. Everything here runs in the shipping
# Carton's own Box.
# Bundle boundary: the app's Gemfile, found by searching upward, set up again
# in this Box. This
# Box gets its own copy of zeitwerk 2.8.3, separate from the app's.
Carton.setup_bundle
require 'zeitwerk'
# The Entrypoint defines the root namespace, and the loader manages only
# lib/shipping/ inside it. Reloading then replaces Shipping::Rate but never
# Shipping itself, the module importers hold.
module Shipping
# This Box's Zeitwerk, not the app's.
def self.zeitwerk_dirs = Zeitwerk::Loader.all_dirs
def self.zeitwerk_nsfiles? = Zeitwerk::Loader.method_defined?(:nsfile=)
end
loader = Zeitwerk::Loader.new
# Zeitwerk 2.8+: a directory's ns.rb defines its namespace, so
# shipping/carriers/ns.rb defines Shipping::Carriers.
loader.nsfile = 'ns.rb'
loader.push_dir(File.join(__dir__, 'shipping'), namespace: Shipping)
# Carriers stays lazy, so its autoload, which points at its ns.rb, is
# triggered from the Main Box; see this example's guide on the website.
loader.do_not_eager_load(File.join(__dir__, 'shipping', 'carriers'))
loader.enable_reloading
loader.setup
loader.eager_load
# Reloads in this Box: the block was defined here, so it runs here even when
# the app calls it.
Shipping.define_singleton_method(:reload!) do
loader.reload
loader.eager_load
end
# Export: only Shipping crosses the Carton boundary.
export Shipping

From the repository root:

bundle exec rake example:zeitwerk

After the task installs the app and billing bundles:

RUBY_BOX=1 ruby examples/zeitwerk/main.rb
RUBY_BOX=1 ruby examples/zeitwerk/test/zeitwerk_test.rb