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

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.

Now, a plug πŸ”Œ. Join ARKADEMY.DEV and get access to our best courses: Rails Architect Masterclass, Anti-IF course, Blogging for busy programmers, Async Remote, TDD video class, Domain-Driven Rails video course and growing!

You might also like