Zeitwerk-based autoload and workarounds for single-file-many-classes problem
Zeitwerk-based autoload and workarounds for single-file-many-classes problem
Rails has deprecated its classic autoloader by the release of version 6.1. From now on it by default uses zeitwerk gem as a basis for new autoloading. The previous autoloader is going to be dropped in future releases. That's good news — the classic autoloader had several, well-documented, but nevertheless tricky gotchas. This welcomed change brings back some sanity.
Unfortunately the initial scope of zeitwerk features did not include one, that I'd welcome the most — an ability to host several classes in a single file.
The Problem
Several years ago I've described a pattern that we've been using for "components" (or rather a way to express bounded contexts) in Rails apps.
Here's an example of one of such components — the Scanner context. It's a top-level, autoloaded directory in a Rails app.
scanner/
├── lib
│ ├── scanner
│ │ ├── event.rb
│ │ ├── event_db.rb
│ │ ├── domain_events.rb
│ │ ├── scan_tickets_command.rb
│ │ ├── scanner_service.rb
│ │ ├── ticket.rb
│ │ ├── ticket_db.rb
│ │ └── version.rb
│ └── scanner.rb
└── spec
├── scan_tickets_command_spec.rb
├── scan_tickets_flow_spec.rb
└── spec_helper.rb
# config/application.rb
config.paths.add 'scanner/lib', eager_load: true
Let's focus on scanner/lib/scanner/domain_events.rb
. This file hosts several, rather small classes that describe domain events in the Scanner subdomain:
# scanner/lib/scanner/domain_events.rb
module Scanner
class TicketScanned < Fact
SCHEMA = {
vendor: String,
event_id: String,
barcode: String,
ticket_type: String,
scanned_at: Time,
terminal_name: String
}
end
class TicketAlreadyScanned < Fact
SCHEMA = {
vendor: String,
event_id: String,
barcode: String,
ticket_type: String,
scanned_at: Time,
terminal_name: String
}
end
# ...and many more, skipped for brevity
end
This worked with classic autoloader mostly due to require_dependency
placed in the bottom of Scanner
module file:
# scanner/lib/scanner.rb
module Scanner
end
require_dependency 'scanner/version'
require_dependency 'scanner/domain_events'
require_dependency 'scanner/scanner_service'
require_dependency 'scanner/scan_tickets_command'
require_dependency 'scanner/ticket'
require_dependency 'scanner/ticket_db'
require_dependency 'scanner/event'
require_dependency 'scanner/event_db'
In zeitwerk-based autoloader there is no place for require_dependency
anymore.
The Workarounds
The limitation is known and described in the migration guide.
What are your options when you have similar code structure and intend to migrate beyond Rails 6.1?
Multiple classes in a single file sharing a common namespace
It is totally fine for zeitwerk if multiple classes in a single file are nested under a common module, which maps to a file name:
# scanner/lib/scanner/domain_events.rb
module Scanner
module DomainEvents # matches file name
TicketScanned = Class.new(Fact)
TicketAlreadyScanned = Class.new(Fact)
# ...
end
end
I'm not a fan of excessive nesting and would like to keep namespaces as flat as possible. With Scanner::DomainEvents::TicketScanned
it is already 3rd level and quite verbose.
What doesn't suit me however may be totally fine for you.
Single class per file in a collapsed directory
Another option is to bow to single-file-per-class philosophy. Yet to keep things organized we can group those related classes within a directory. And make sure this directory does not imply unnecessary namespace with collapsing,
scanner/
├── lib
│ ├── scanner
│ │ └── domain_events
│ │ ├── ticket_scanned.rb
│ │ └── ticker_already_scanned.rb
│ │ ├── event.rb
│ │ ├── event_db.rb
│ │ ├── scan_tickets_command.rb
│ │ ├── scanner_service.rb
│ │ ├── ticket.rb
│ │ ├── ticket_db.rb
│ │ └── version.rb
│ └── scanner.rb
└── spec
├── scan_tickets_command_spec.rb
├── scan_tickets_flow_spec.rb
└── spec_helper.rb
We need to tell autoloader to keep scanner/lib/scanner/domain/events
collapsed.
# config/initializers/zeitwerk.rb
SUBDOMAINS = %w(
scanner
# ...
)
Rails.autoloaders.each do |autoloader|
SUBDOMAINS.each do |sub|
domain_events_dir =
Rails.root.join("#{sub}/lib/#{sub}/domain_events")
autoloader.collapse(domain_events_dir)
end
end
The pro is that namespace is kept intact as in Scanner::TicketScanned
. The classes are also grouped, although not in a single file.
Then con is obviously a class-per-file religion. Which may be totally fine for some and there's nothing wrong with that.
Opt out of autoloading
Remove zeitwerk, explicit require list. ;)
— Markus Schirp (@m_b_j) March 18, 2021
Perhaps now opting out of autoloading in Rails is easier than ever. With zeitwerk you can tell the autoloader to ignore particular directories.
# config/initializers/zeitwerk.rb
SUBDOMAINS = %w(
scanner
# ...
)
Rails.autoloaders.each do |autoloader|
SUBDOMAINS.each do |sub|
autoloader.ignore(Rails.root.join(sub))
end
end
Feel free to expand this article or ping me on twitter with comments.