Rails upgrade: why it’s hard and a single trick on simplifying it.

Evgeniy Demin
4 min readJun 27, 2023

--

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.

Ruby on Rails logo

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.

Please consider subscribing or adding me in social networks!

And don’t forget to share what you think about the topic. Just write me a message. I would be happy to discuss the topic or anything else with you!

--

--

Evgeniy Demin
Evgeniy Demin

Written by Evgeniy Demin

Ruby & Golang practitioner. Remote expert. Open-source contributor. Beginner blogger. Join my network to grow your expertise.