Zeitwerk Autoloading and Modularizing Large Rails Apps
Objective
Zeitwerk is not an implementation detail you can ignore once the app boots — it
is a contract you write code against every day, and violating it is a runtime
crash, not a style nit. A file at app/services/api/user_sync.rb that defines
class UserSync instead of Api::UserSync doesn't fail at boot in development;
it fails the first time something references the constant, with a
Zeitwerk::NameError, potentially in production after a deploy that "worked"
because the code path wasn't eager-loaded until the first request hit it. As an
app grows past a few hundred models and services, that same contract becomes the
thing that either makes modularization free (namespaces map directly to
directories, no manual require) or the thing engineers fight when two teams'
directories collide on the same acronym. This concept is about knowing the
actual rules Zeitwerk enforces, the concrete ways they break at scale, and the
two real tools — Rails Engines and Packwerk — for drawing boundaries once
"everything is one big app/ namespace" stops working.
Use Cases
- Debugging a
Zeitwerk::NameErrorin CI or on deploy that never showed up in local development because the failing file was never autoloaded until eager loading ran. - Deciding what to do when a legitimate class name is an acronym (
API,VAT,HTML) and Zeitwerk's default camelization produces the wrong constant. - Running
bin/rails zeitwerk:checkas a CI gate before a deploy, instead of discovering a naming violation the first time production eager-loads the app. - Choosing between a full Rails Engine, a Packwerk package, or "just add another
directory under
app/" when a team wants to carve out a bounded module of an existing monolith. - Diagnosing why boot time keeps growing release over release, and whether the fix is a code-organization problem (too much to eager-load) or an architecture problem (too much coupled into one deploy unit).
- Reviewing a PR that adds a new top-level namespace and deciding whether it
belongs in
app/, in a new Packwerk package, or in an extracted engine.
Deep Dive
Zeitwerk's core contract
Zeitwerk replaced Rails' "classic" autoloader in Rails 6, and the rule it enforces is simple to state and easy to violate: file paths mirror constant paths. A directory is a namespace, a file is a constant, and nesting one inside the other means nesting the module inside the class:
plaintextapp/models/user.rb -> User app/models/billing/invoice.rb -> Billing::Invoice app/services/api/user_sync.rb -> Api::UserSync
There is no require, no require_dependency, no require_relative for your
own app code under an autoload path — Zeitwerk resolves the constant to a file
path on first reference and loads it. Any require_dependency call still in an
old codebase is a leftover from the classic autoloader; it's a no-op safety net
under Zeitwerk at best and a source of confusing double-loading at worst.
Rails registers two separate Zeitwerk loader instances, not one:
Rails.autoloaders.mainmanagesapp/*and any custom autoload paths — the code that gets unloaded and reloaded between requests in development (config.enable_reloading), and eager-loaded up front in production (config.eager_load = true, on by default inproduction, off by default indevelopment, and enabled intestwhenCIis set).Rails.autoloaders.oncemanages autoload-once paths — code that should load a single time and never be unloaded across reloads, because reloading it would leave stale references (framework decorations, some initializer-time extensions).libis a common example when it's added to autoload paths.
When the contract is violated — the file doesn't define the constant Zeitwerk
expected — the failure is a Zeitwerk::NameError, a subclass of NameError,
with a message in the shape:
plaintextZeitwerk::NameError: expected file /app/services/api/user_sync.rb to define constant Api::UserSync, but didn't
The critical operational detail: this only fires when the file is loaded.
In development with lazy autoloading, a broken file that nothing references
yet will sit there silently. The first time it bites is often in CI or
production, whichever eager-loads first — which is exactly why eager_load
in CI and the zeitwerk:check task (below) exist: to force the failure
early, on a machine that isn't serving real traffic.
Common Zeitwerk gotchas at scale
Acronym and inflection collisions. Zeitwerk's default inflector uses
String#camelize, so api_controller.rb becomes ApiController — probably
fine — but a bare api.rb becomes Api, not API. If the codebase's actual
convention is API, that mismatch is a Zeitwerk::NameError waiting for the
first reference. The fix is an explicit inflection override, conventionally
collected in one initializer:
plaintext# config/initializers/inflections.rb ActiveSupport::Inflector.inflections(:en) do |inflect| inflect.acronym "API" inflect.acronym "HTML" inflect.acronym "VAT" end
This changes ActiveSupport's camelize/underscore globally, which is
usually what you want since Zeitwerk's default inflector delegates to it. For
inflection rules scoped to a single loader instead of all of ActiveSupport,
override the loader directly:
plaintextRails.autoloaders.each do |autoloader| autoloader.inflector.inflect("html_parser" => "HTMLParser") end
The overrides are matched against exact file/directory basenames (without
extension), not applied as a general regex — api_client.rb needs its own
entry if api.rb doesn't cover it.
Naming collisions between STI subclasses and directories. A classic trap:
Vehicle uses single-table inheritance with Car and Truck as subclasses
living directly in app/models/. Someone later adds a Car namespace for
an unrelated concept — app/models/car/rental_agreement.rb defining
Car::RentalAgreement — and now Car is expected to be both a class (the STI
subclass) and a module (the namespace directory). Zeitwerk raises on this the
moment both are referenced, because a single constant can't be both. The real
fix is to rename one side (rare for a public STI class name) or move the STI
class into its own explicit-namespace file so the ambiguity is resolved
before Zeitwerk ever has to guess.
zeitwerk:check is the tool that catches these before deploy, not code
review. Run it locally or, better, wire it into CI:
plaintext$ bin/rails zeitwerk:check Hold on, I am eager loading the application. All is good!
On failure it aborts with the underlying Zeitwerk::NameError message. It can
also report directories that exist under an autoload path but won't be
eager-loaded unless added to config.eager_load_paths — worth reading, since
those directories are exactly the ones that can hide a broken file until
someone unlucky references it in production.
Rails Engines for hard module boundaries
An Engine is a Rails app nested inside a Rails app — the mechanism Rails
itself uses to be a Rails app (Rails::Application < Rails::Engine). Generate
one with rails plugin new blorgh --full for a basic engine, or
--mountable for a namespace-isolated one. The file that defines it:
plaintext# lib/blorgh/engine.rb module Blorgh class Engine < ::Rails::Engine isolate_namespace Blorgh end end
isolate_namespace is what makes a mountable engine a real boundary rather
than just "code that happens to live in a gem": it namespaces the engine's
controllers, models, table names (blorgh_articles), views, route helpers,
and params key, so the engine can't accidentally collide with the host app's
constants even though both are autoloaded by the same Zeitwerk process. A
non-mountable (--full) engine skips that isolation and shares the host
app's namespace directly — closer to a plain gem that hooks into Rails'
initialization than to a bounded module.
What a full engine extraction buys:
- Its own routes, drawn separately and mounted explicitly:plaintext
# config/routes.rb (host app) Rails.application.routes.draw do mount Blorgh::Engine, at: "/blog" end - Its own migrations, copied into the host app on demand rather than
auto-run:
bin/rails blorgh:install:migrations. - Its own asset pipeline, namespaced under
app/assets/.../blorgh/. - A namespace that can be gem-ified and versioned independently — pulled
out of the monolith's repo entirely, given its own CHANGELOG and semver, and
pinned by version in the host app's
Gemfile.
What it costs is real, not incidental: an engine is a second application with
its own test/dummy app to boot for its test suite, its own gemspec and
dependency graph, and its own lib/engine_name/engine.rb initialization
concerns to get right. Extracting an engine out of code that already lives in
app/ and freely references the host app's models is genuine surgery — every
cross-boundary reference has to become an explicit dependency, exactly the
kind of coupling that made the extraction worth doing in the first place, and
exactly the kind of work Packwerk (next) exists to do more cheaply.
Packwerk for lighter-weight boundaries
Packwerk, from Shopify, enforces module boundaries as a static lint,
without requiring engine-style extraction. Each package is a directory with a
package.yml:
plaintext# components/orders/package.yml enforce_dependencies: true enforce_privacy: true dependencies: - components/platform - components/shipping
enforce_dependencies: true means any reference to a constant defined in a
package not listed under dependencies is a dependency violation.
enforce_privacy: true means anything the package defines outside its
app/public/ directory (configurable via public_path) is treated as
private — a reference to it from another package is a privacy violation,
even if it's a declared dependency. Violations are found by static analysis
of constant references, checked with:
plaintext$ bin/packwerk check
CI runs this the same way it runs RuboCop. Existing violations at adoption
time — Packwerk is explicitly built to be dropped onto a monolith that
already breaks its own proposed rules — get recorded per package in
package_todo.yml rather than blocking the build immediately, so the
boundary can be introduced incrementally and violations paid down over time
instead of requiring a big-bang fix.
The trade-off against engines is the whole point of Packwerk: adopting it on
an existing monolith costs a package.yml file and a CI step, not a
directory restructure or a second test-suite boot. But the boundary it draws
is exactly as strong as CI enforcement and nothing more. A Packwerk package
is not a Zeitwerk namespace and not isolate_namespace — Ruby's own constant
lookup doesn't know packages exist, so nothing stops code at runtime from
calling Orders::LineItem from inside components/marketing the way an
Engine's namespacing would prevent. If bin/packwerk check isn't wired into
CI, or someone merges past a red check, the "boundary" is a comment, not a
constraint.
When to actually modularize
None of the above is worth doing preemptively. Concrete signals worth citing in an architecture discussion, rather than "the app feels big":
- Boot time growing roughly linearly with app size. Every class Zeitwerk eager-loads in production is a class instantiated into memory before the first request is served; an app that takes 45 seconds to boot because it eager-loads 6,000 classes is paying that cost on every deploy and every autoscaled instance start.
- Deploy risk from unrelated teams' changes. If a payments team's PR can
break the marketing team's code because both live in the same
app/modelsnamespace with no declared boundary, that's a Packwerk-shaped problem before it's an Engine-shaped one. - CI test-suite runtime becoming the bottleneck on every PR, because the whole suite loads the whole app regardless of which 200 lines changed — this is the strongest signal for an actual Engine extraction, since only a real boundary lets a subset of the test suite run in isolation.
Note the terminology collision: Zeitwerk's "eager loading" (load every class
at boot) and ActiveRecord's "eager loading" (includes/preload to avoid
N+1 queries) are unrelated mechanisms that happen to share a name — growing
boot time from too many autoloaded classes is not fixed by anything discussed
in query-loading strategy, and vice versa.
Trade-offs
- The Zeitwerk contract has no escape hatch for "just this one file."
Unlike the classic autoloader, which tolerated
require-based workarounds, Zeitwerk either resolves a constant to the exact expected path or raises. The one legitimate exception — a file that genuinely shouldn't be autoloaded — isRails.autoloaders.main.ignore(...), which is itself another thing to track and justify in review. zeitwerk:checkonly checks what gets eager-loaded. A directory that exists but isn't on an eager-load path can hide a broken file indefinitely; the task warns about this but doesn't fail on it by default. Treat that warning as a checklist, not noise.plaintextWARNING: The following directories will only be checked if you configure them to be eager loaded: app/uncommon_path- Inflection overrides are global and easy to under-scope. Adding
inflect.acronym "API"toActiveSupport::Inflector.inflectionschanges"api".camelizeeverywhere in the app, including string manipulation that has nothing to do with autoloading — a display label built with.camelizeupstream of Zeitwerk now readsAPItoo, which is sometimes desired and sometimes a silent behavior change nobody reviewed as such. - An Engine is real infrastructure, not a
mkdir. Its own dummy test app, its own boot sequence, its own gemspec — an engine that's extracted prematurely from a monolith with tangled cross-references costs weeks of untangling for a boundary that Packwerk could have enforced in an afternoon. Reach for an engine when you actually need process-independent versioning or genuine runtime isolation, not as the default modularization move. - Packwerk's boundary is advisory unless CI actually blocks on it. This is
the trade that makes Packwerk cheap: it's also the trade that makes it
fragile. A developer under deadline pressure who reaches across a
package.ymlboundary gets a red CI check, not aNameError— and a red check that gets merged past (or apackage_todo.ymlline quietly extended instead of fixed) is a boundary that exists on paper only.plaintext# inside components/marketing — enforce_privacy: true is set, # this constant lives in components/orders/app/models (not app/public) Orders::LineItem.where(status: "pending") # violation, but boots and runs fine - Neither tool tells you what the boundary should be. Packwerk and Engines both enforce a boundary once you've drawn it; deciding where the seam actually belongs — which models, services, and jobs form a cohesive package — is a design judgment neither tool makes for you, and drawing it in the wrong place just moves the coupling problem to the packages themselves.