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 appmain.rb # the app's loader for lib/, then the importstest/zeitwerk_test.rb # checks each case belowlib/ 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::Postcartons/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 BoxThe app
Section titled “The app”The app’s loader manages lib/, ignoring the shipping Carton there; each import then runs a Carton’s own loader in its Box:
require 'carton'
# Bundle boundary: the app's Gemfile gives the Main Box zeitwerk 2.8.3.Carton.setup_bundlerequire '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.newapp_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.3billing zeitwerk: 2.7.5shipping zeitwerk has nsfiles: trueinvoice $42.00 + shipping $11.00A Carton with Zeitwerk::Loader.for_gem
Section titled “A Carton with Zeitwerk::Loader.for_gem”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.
# 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_bundlerequire '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 BillingEach Box has its own Zeitwerk
Section titled “Each Box has its own Zeitwerk”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.
Lazy autoloads stay in the Carton
Section titled “Lazy autoloads stay in the Carton”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.
Namespaces in nsfiles
Section titled “Namespaces in nsfiles”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.
Reloading
Section titled “Reloading”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:
Shippingis the same module. Its Entrypoint defines it and the loader manages onlylib/shipping/inside it, viapush_dir(..., namespace: Shipping).Shipping::Rateis a new class, loaded again in the Carton Box, and so isShipping::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, sois_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_gemloader manages the Entrypoint too. Reloading removes the top-levelBillingfrom the Carton Box, and theBillingmodule Importers hold loses its autoloaded constants, soBilling::InvoiceraisesNameErrorfor them. LoadingBillingagain re-runs the Entrypoint, which fails either way. Eager loading it callsenable_reloadingwhile the loader is busy and raisesThreadError(“deadlock; recursive locking”); Zeitwerk does the same without Boxes, since a loader cannot reload the file that configures it. Autoloading it lazily reachesexporta second time, which raisesCarton::ExportError. Usepush_dir(..., namespace:)for a reloadable Carton.
# 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_bundlerequire '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_reloadingloader.setuploader.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_loadend
# Export: only Shipping crosses the Carton boundary.export ShippingFrom the repository root:
bundle exec rake example:zeitwerkAfter the task installs the app and billing bundles:
RUBY_BOX=1 ruby examples/zeitwerk/main.rbRUBY_BOX=1 ruby examples/zeitwerk/test/zeitwerk_test.rb