Rails upgrade: why it’s hard and a single trick on simplifying it.
I’m working on a Rails upgrade for a giant monolith application. It’s been a while already, and while I’m encountering many issues trying to jump from 5.2+ right to 6.1, I want to share some of the stories.
You may know that Rails 6 has introduced a new application loader: Zeitwerk. There are some guidelines and incredibly well-put documentation on the gem itself, so you shouldn’t be lost. But yet, for enormous projects with hundreds of engineers working together, there is something that you won’t find obvious.
I have prepared for you a minimal example to outline the problem. So, without further ado, let’s get started.
Imagine your project has two modules/domains/packages or whatever you call it that two teams/engineers work on simultaneously. In our example, their names will be Discord
and Skype
. And let’s say that every package has its own class to deal with API.
module Discord
class Api
# code here
end
end
module Skype
class API
# code here
end
end
You may notice that the Discord
package has Api
class while Skype
has API
. If you’re suspicious, that’s good; however, please consider that everything is alright so far for Rails 5.2 with classic loader. And because there is no automation to prevent such naming from happening (for pure Rails applications), teams may want to have their names even though they are inconsistent with others.
Now, you decided to upgrade your application (massive monolith, I remind you) to Rails 6.1. You are even brave enough to turn on Zeitwerk immediately because you know that classic loader will be killed anyway, so why not deal with this problem right now?
After resolving all dependency conflicts, you try to start the project console.
You see that Discord::Api
can be resolved fine, but Skype::API
is failing.
Discord::Api
=> Discord::Api
Skype::API
Traceback (most recent call last):
1: from (irb):2
NameError (uninitialized constant Skype::API)
Did you mean? Skype::Api
Api
After looking for a few documents, which I mentioned above, and even chatting on Twitter with the Zeitwerk author, you realize it’s a good tool, and you can override an inflector to map different packages correctly.
You may end up with code like that:
# config/initializers/zeitwerk.rb
class CustomInflector < Zeitwerk::Inflector
CUSTOM = {
'discord/api.rb' => 'Api',
'skype/api.rb' => 'API'
}
def camelize(basename, abspath)
custom = CUSTOM.find { |(custom, _)| abspath.ends_with?(custom) }&.last
custom || super
end
end
Rails.autoloaders.each do |loader|
loader.inflector = CustomInflector.new
end
This code allows us to change the inflection for file names based on their absolute path. In our example, discord/api.rb
expects to have an Api
constant, while slack/api.rb
expects to define an API
constant.
P.S. We don’t need a custom mapping for Discord
, but I added it for visibility.
Let’s try it out in the console now.
Discord::Api
=> Discord::Api
Skype::API
=> Skype::API
Both constants are accessible without any issues.
Now, you only need to handle every failing case for your giant application by adding a custom mapping based on its absolute path. Unfortunately, that’s not enough; there is a surprise waiting.
One of your failing cases was a controller like the one below.
module API
class BasesController < ApplicationController
def show
head :ok
end
end
end
And you know how to fix it already, right? So you added a custom mapping too:
class CustomInflector < Zeitwerk::Inflector
CUSTOM = {
'discord/api.rb' => 'Api',
'skype/api.rb' => 'API',
'controllers/api' => 'API', # controllers under API scope
}
def camelize(basename, abspath)
custom = CUSTOM.find { |(custom, _)| abspath.ends_with?(custom) }&.last
custom || super
end
end
Rails.autoloaders.each do |loader|
loader.inflector = CustomInflector.new
end
And the constant is accessible fine in the console:
API::BasesController
=> API::BasesController
But here is a twist, it won’t work still!
As soon as you would try to reach that controller action via any of its routes, it will fail with:
uninitialized constant Api Did you mean? API
This happens due to the following piece of code from ActionPack (responsible for routing/controllers).
# gems/actionpack-6.1.7.3/lib/action_dispatch/http/request.rb
def controller_class_for(name)
if name
controller_param = name.underscore
const_name = controller_param.camelize << "Controller"
begin
ActiveSupport::Dependencies.constantize(const_name)
rescue NameError => error
if error.missing_name == const_name || const_name.start_with?("#{error.missing_name}::")
raise MissingController.new(error.message, error.name)
else
raise
end
end
else
PASS_NOT_FOUND
end
end
So, when a router tries to find a corresponding controller for a path, it uses the camelize
method.
And as you can see, it is converted to Api
, not API
.
"api/bases".camelize
=> "Api::Bases"
To fix it, you have to adjust ActiveSupport::Inflector
:
ActiveSupport::Inflector.inflections(:en) do |inflect|
inflect.acronym 'API'
end
And after that, camelize
will work out for you as required:
"api/bases".camelize
=> "API::Bases"
As well as the controller will respond adequately to a request.
P.S. This is true that ActiveSupport::Inflector has to have the same acronym defined for Rails 5.2. The point is that it isn’t enough to have only updated Zeitwerk inflection because it’s responsible for loading only. However, with a classic loader, you could have only one adjusted.
Now, you may wonder if we have the same “double” situation with API
and Api
for controllers, then, we have a problem, we would need to monkey patch ActiveSupport inflection to distinguish between different prefixes/namespaces to decide how to convert the string.
However, I don’t think we should go further than we already did.
I wanted to show you how many problems can be brought by certain decisions when the Rails convention is not honored.
All of the above can be avoided if you don’t have constant names requiring custom inflection.