Ruby and Rails upgrade: personal experience

Ruby and Rails upgrade: personal experience

Good day, everyone! Today, I want to share my personal experience upgrading Ruby and Rails in a project. This is not a tutorial, but rather a reflection on the process, challenges, and lessons learned. I also share the instances where I used AI tools to speed up the process. Where they were useful and where they weren’t. The post is quite long, so grab a cup of coffee and enjoy the read.

I’ve organized my journey into two logs. The first log is about the Ruby upgrade, and the second pertains to the Rails upgrade. Each log consists of the issues I encountered along the way and the solutions I implemented. I’ve included links for each issue that direct you to the relevant section of the page, making it easy to share with your colleagues.

Why upgrade Ruby and Rails?

Upgrading Ruby and Rails is necessary to ensure your app operates without issues and maintains security. For me, a more undeniable motivation was that Heroku deprecates older Ruby versions. The next Heroku stack will most likely not support Ruby 3.1. Deployments on Heroku would fail after some time without an upgrade.

The client will continue using Heroku. This is true even with the recent major outage and rising prices. So, I had to upgrade Ruby and Rails to keep the app running on Heroku.

Current stack of the project

The project ran on Ruby 3.1.4 and Rails 6.1.7.6. Upgrade targets to Ruby 3.4.4 and Rails 7.2.

It’s a Rails monolith that serves primarily as a GraphQL API backend. The frontend was once part of a full-stack app but is now a separate React app. Some old legacy controllers, views, helpers, and gems are still around, even though they are not used.

The codebase isn’t huge — about 100 models, 3 GraphQL schemas, 200 service objects, 60 background jobs, and around 5 actively used controllers.

PostgreSQL is the main DB; Sidekiq is for background jobs. No caching. RSpec/FactoryBot is for testing; Rubocop for code linting.

Some places in the app use Dry-Rb gems, including monads and the auto-injector. The app has had many developers over the years. So, it has picked up different approaches and patterns. Pretty average Rails app, I’d say.

Iterative changes

I prefer to make small, incremental changes rather than big-bang upgrades. This way, I can test each change and ensure everything works as expected. For that reason, I started the upgrade with Ruby only, leaving Rails for later. Even though some people might suggest upgrading both Ruby and Rails at the same time, I find it easier to isolate issues when I tackle them one at a time.

The log of upgrading Ruby

Ruby upgrade in a Rails project step-by-step log

I install Ruby 3.4.4 locally and update the Gemfile to use it:

ruby '3.4.4'

Next, I delete the Gemfile.lock file. Then, I run bundle install to create a new lock file. This file includes the updated Ruby version and all dependencies that work with it. If some gems are not compatible, I will update them to the latest versions that support Ruby 3.4.4.

All gems were installed without any issues. But installed gems don’t guarantee compatibility. So, right after that, I run the test suite to ensure everything works as expected. Besides tests, I have set for myself the following checks to pass:

  • Assets precompilation;
  • Rails console should work;
  • Rails server should start without errors;
  • Sidekiq should start without errors;
  • Rubocop should pass without errors;

But first, I need to fix all tests.

Next, you see the list of issues/exceptions I encountered while running tests and the fixes I made.

💣 issue 1 🔗

uninitialized constant GraphQL::Compatibility::ExecutionSpecification::SpecificationSchema::OpenStruct

✅ add require 'ostruct' in config/application.rb.

New Ruby no longer loads OpenStruct (and some more standard libraries) by default.

💣 issue 2 🔗

uninitialized constant ActiveSupport::LoggerThreadSafeLevel::Logger

✅ add require 'logger' in config/application.rb.

New Ruby no longer loads Logger by default.

💣 issue 3 🔗

Bundler::GemRequireError:
  There was an error while trying to load the gem 'bootstrap'.
  Gem Load Error is: bootstrap-rubygem requires a Sass engine. Please add dartsass-sprockets, sassc-rails, dartsass-rails or cssbundling-rails to your dependencies.

✅ add sassc-rails gem to the Gemfile.

The project has bootstrap gem. Even though it’s useless since the frontend is now a separate React app, I decide to keep it for now. Removing the legacy code is a separate task, and I don’t want to mix it with the Ruby upgrade.

💣 issue 4 🔗

Bundler::GemRequireError:
  There was an error while trying to load the gem 'sidekiq-cron'.
  Gem Load Error is: cannot load such file -- sidekiq/util
  Backtrace for gem load error is:

✅ upgrade sidekiq-cron gem to the latest version.

💣 issue 5 🔗

TSort::Cyclic:
  topological sort failed: [...a long list of output...]

✅ removing dartsass-sprockets and dartsass-rails from the Gemfile.

I remove neither dart nor sass engines from the project to avoid the cyclic dependency error. The recommendation of the bootstrap gem added the gems. But those gems are not needed for the project, so I remove them.

At this point, the tests started to run; not all of them passed, but at least I could see the progress. I left the tests fixing for later and moved on to the next step — assets precompilation. On that step, I encountered the following issues.

💣 issue 6 🔗

LoadError: cannot load such file -- drb (LoadError)

✅ add gem drb to the Gemfile.

The rails gem requires drb for assets precompilation, but New Ruby no longer loads it by default.

💣 issue 7 🔗

error Command "build:css" not found.

✅ remove cssbundling-rails gem from the Gemfile.

The cssbundling-rails gem is not used in the project, so I remove it to avoid the error. The project uses sassc-rails for CSS processing, so I don’t need cssbundling-rails. This gem was also added by the recommendation of the bootstrap gem.

💣 issue 8 🔗

Assets precompilation failed on Heroku with the following error:


[webpack-cli] Error: error:0308010C:digital envelope routines::unsupported - click to expand!


[webpack-cli] Error: error:0308010C:digital envelope routines::unsupported
           at new Hash (node:internal/crypto/hash:79:19)
           at Object.createHash (node:crypto:139:10)
           at CompressionPlugin.taskGenerator (/tmp/build_8801e356/node_modules/compression-webpack-plugin/dist/index.js:163:38)
           at taskGenerator.next (<anonymous>)
           at /tmp/build_8801e356/node_modules/compression-webpack-plugin/dist/index.js:216:49
           at CompressionPlugin.runTasks (/tmp/build_8801e356/node_modules/compression-webpack-plugin/dist/index.js:236:9)
           at /tmp/build_8801e356/node_modules/compression-webpack-plugin/dist/index.js:270:18
           at _next0 (eval at create (/tmp/build_8801e356/node_modules/tapable/lib/HookCodeFactory.js:33:10), <anonymous>:37:17)
           at eval (eval at create (/tmp/build_8801e356/node_modules/tapable/lib/HookCodeFactory.js:33:10), <anonymous>:53:1)
           at WebpackAssetsManifest.handleEmit (/tmp/build_8801e356/node_modules/webpack-assets-manifest/src/WebpackAssetsManifest.js:486:5)
           at AsyncSeriesHook.eval [as callAsync] (eval at create (/tmp/build_8801e356/node_modules/tapable/lib/HookCodeFactory.js:33:10), <anonymous>:49:1)
           at AsyncSeriesHook.lazyCompileHook (/tmp/build_8801e356/node_modules/tapable/lib/Hook.js:154:20)
           at Compiler.emitAssets (/tmp/build_8801e356/node_modules/webpack/lib/Compiler.js:491:19)
           at onCompiled (/tmp/build_8801e356/node_modules/webpack/lib/Compiler.js:278:9)
           at /tmp/build_8801e356/node_modules/webpack/lib/Compiler.js:681:15
           at AsyncSeriesHook.eval [as callAsync] (eval at create (/tmp/build_8801e356/node_modules/tapable/lib/HookCodeFactory.js:33:10), <anonymous>:6:1)
           at AsyncSeriesHook.lazyCompileHook (/tmp/build_8801e356/node_modules/tapable/lib/Hook.js:154:20)
           at /tmp/build_8801e356/node_modules/webpack/lib/Compiler.js:678:31
           at AsyncSeriesHook.eval [as callAsync] (eval at create (/tmp/build_8801e356/node_modules/tapable/lib/HookCodeFactory.js:33:10), <anonymous>:6:1)
           at AsyncSeriesHook.lazyCompileHook (/tmp/build_8801e356/node_modules/tapable/lib/Hook.js:154:20)
           at /tmp/build_8801e356/node_modules/webpack/lib/Compilation.js:1423:35
           at AsyncSeriesHook.eval [as callAsync] (eval at create (/tmp/build_8801e356/node_modules/tapable/lib/HookCodeFactory.js:33:10), <anonymous>:6:1)
           at AsyncSeriesHook.lazyCompileHook (/tmp/build_8801e356/node_modules/tapable/lib/Hook.js:154:20)
           at /tmp/build_8801e356/node_modules/webpack/lib/Compilation.js:1414:32
           at eval (eval at create (/tmp/build_8801e356/node_modules/tapable/lib/HookCodeFactory.js:33:10), <anonymous>:14:1)
           at process.processTicksAndRejections (node:internal/process/task_queues:105:5) {
         opensslErrorStack: [
           'error:03000086:digital envelope routines::initialization error',
           'error:0308010C:digital envelope routines::unsupported'
         ],
         library: 'digital envelope routines',
         reason: 'unsupported',
         code: 'ERR_OSSL_EVP_UNSUPPORTED'
       }

✅ add NODE_OPTIONS=--openssl-legacy-provider to the Heroku config: heroku config:set NODE_OPTIONS=--openssl-legacy-provider.

The assets:precompile task forced me to upgrade Node.js to a newer version, which in turn caused the compression-webpack-plugin to fail. The error message says that the OpenSSL version in Node.js can’t handle some cryptographic tasks needed by the plugin. Set the NODE_OPTIONS environment variable to --openssl-legacy-provider. This lets it work with the old OpenSSL provider. I plan to upgrade the compression-webpack-plugin to a version that works with the new OpenSSL. For now, this workaround is enough. The current version of compression-webpack-plugin is 11.1.0 but the app has installed 4.0.1. Even though the previous upgrade was a few years ago. The number of releases between is scary. The JavaScript ecosystem is a nightmare 😢.

The precompilation of assets was successful in the end. Hooray! 🎉 Switching back to the failed tests.

💣 issue 9 🔗

I receive these errors on some scopes defined in the models:

ArgumentError: wrong number of arguments (given 1, expected 0)

✅ change scope :something, ->(from:, to:) { where(from:, to:) } in several models to scope :something, ->(kwargs = {}) { where(**kwargs) }.

I don’t understand the reasons for its failure. But it seems that the issue was in the incompatibility of Ruby 3.4.4 and Rails 6.1. Rails 6.1 does not officially support Ruby 3.4. Consider this fix a temporary workaround. When I upgrade Rails to 7.2, I will revisit this code to ensure a thorough refactor.

💣 issue 10 🔗

'Regexp#initialize': wrong number of arguments (given 3, expected 1..2) (ArgumentError)

✅ change Regexp.new('...', nil, 'n') with Regexp.new('...', Regexp::FIXEDENCODING | Regexp::NOENCODING).

New Ruby has changes in the Regexp class, which caused this error. I personally never use Regexp.new for regexps. I don’t know why someone used it in the project like that. But it was failing, so I fixed it. I must admit, it was pretty tricky to figure out which parameters the new way of initializing Regexp accepts. I had to check Ruby sources to come up with this solution.

💣 issue 11 🔗

✅ monkey-patch

# frozen_string_literal: true

module ActionDispatch
  module Routing
    module UrlFor
      def initialize(*args)
        @_routes = nil
        super(*args)
      end
    end
  end
end

module ActionController
  class Metal
    def initialize(*_args)
      @_request = nil
      @_response = nil
      @_routes = nil
      super()
    end
  end
end

module ActionView
  module Layouts
    def initialize(*_args)
      @_action_has_layout = true
      super()
    end
  end
end

At this moment I thought it was incompatibility between Ruby 3.4.4 and Rails 6.1. I even created a discussion on Reddit. But later I found out that it was actually a problem with dry-auto_inject gem. See more details in the comment. Later, I took out those monkey-patches and changed the auto-injection to an explicit class initialization. The project had only one controller that used auto-injection, which made the situation manageable.

All tests pass. Assets precompilation doesn’t fail. The Rails console works. The Rails server starts without errors. Sidekiq also starts without issues. I can now deploy the app to Heroku.

Moving on to the next step — upgrading Rubocop.

💣 issue 12 🔗

I upgraded Rubocop and got many violations. They say RSpec/BeEq: Prefer be over eq. This happens in lines like expect(some_value).to eq(expected_value).

✅ disable RSpec/BeEq cop in the .rubocop.yml file.

I decide not to fix them right now. I would like to have the upgrade task contain as few changes as possible. Moreover, I have doubts that using be instead of eq is a good idea. I prefer to use eq for equality checks; it’s been working in this project for 7 years, and everything has been fine. Why should we change everything now because someone has a different opinion? I will revisit this later, after the Rails upgrade.

So I disabled the RSpec/BeEq cop in the .rubocop.yml file:

RSpec/BeEq:
  Enabled: false

💣 issue 13 🔗

Some cops are failing with undefined method 'empty?' for an instance of Integer (NoMethodError).

✅ Do not use Rubocop v1.76.0.

It turned out the Lint/EmptyInterpolation cop was failing not only for me but also in general. The issue was reproducible in Rubocop v1.76.0. The cop checks for empty interpolations like #{} and raises an error if it finds one. But in Ruby 3.4, the empty? method is not defined for Integer and other primitives, which causes the error.

I downgraded Rubocop to the previous version and made a pull request to the Rubocop team. They merged it almost immediately, and they released the fix in Rubocop v1.76.1. Kudos to the Rubocop team for their quick response! Moreover, I became the 900th contributor to RuboCop - congrats to me! 🎉

There were some other cops that were failing. To avoid too much changes in the code, I regenerate the .rubocop_todo.yml file:

rubocop --auto-gen-config

And push the changes to the repository.

There is an approach to fix all the violations now. But I prefer not to do that. This kind of effort usually results in wasted time and fails to improve code quality. Instead, I ask myself: is it worth it? To justify my decision, I prefer the 80/20 rule: 20% of the effort should yield 80% of the results. In this case, spending 80% of the effort for a questionable 20% gain isn’t worth it.

Ruby upgrade is done! 🎉

The log of upgrading Rails

Live Rails project upgrade step-by-step log

Now that I have upgraded Ruby, I can move on to upgrading Rails. I start by updating the Gemfile to use Rails 7.2:

gem 'rails', '7.2.2.1'

I delete the Gemfile.lock file. Then, I run bundle install to create a new lock file. This file has the updated Rails version and all dependencies that work with it. If some gems are not compatible, I will update them to the latest versions that support Rails 7.2.

This time, I had to update several gems to make them compatible with Rails 7.2. I will list them below, along with the fixes I made.

Note, I have not dived deep into each issue. When I encountered an issue, I attempted to upgrade to the largest version that is compatible with the current stack. In all cases, that worked well. If an upgrade didn’t help, I would investigate the issue in greater depth. But in this case, I didn’t have to do that.

💣 issue 14 🔗

PaperTrail 12.0.0 is not compatible with ActiveRecord 7.2.2.1. We allow PT
contributors to install incompatible versions of ActiveRecord, and this
warning can be silenced with an environment variable, but this is a bad
idea for normal use. Please install a compatible version of ActiveRecord
instead (>= 5.2, < 6.2). Please see the discussion in paper_trail/compatibility.rb
for details.

✅ upgrade paper_trail gem to 16.0.0.

💣 issue 15 🔗

Because paranoia >= 2.5.0, < 2.6.3 depends on activerecord >= 5.1, < 7.1
  and rails >= 7.2.2.1, < 8.0.0.beta1 depends on activerecord = 7.2.2.1,
  paranoia >= 2.5.0, < 2.6.3 is incompatible with rails >= 7.2.2.1, < 8.0.0.beta1.
So, because Gemfile depends on rails = 7.2.2.1
  and Gemfile depends on paranoia = 2.6.2,
  version solving has failed.

✅ upgrade paranoia gem to 3.0.1.

💣 issue 16 🔗


ActiveSupport::Dependencies.reference is deprecated - click to expand!

NoMethodError:
  undefined method 'reference' for module ActiveSupport::Dependencies
# ./config/application.rb:12:in '<top (required)>'
# ./config/environment.rb:2:in 'Kernel#require_relative'
# ./config/environment.rb:2:in '<top (required)>'
# ./spec/rails_helper.rb:3:in '<top (required)>'
# ./spec/mailers/mailer_spec.rb:1:in '<top (required)>'
~/.rbenv/versions/3.4.4/lib/ruby/gems/3.4.0/gems/devise-4.7.0/lib/devise.rb:52: warning: already initialized constant Devise::ALL
~/.rbenv/versions/3.4.4/lib/ruby/gems/3.4.0/gems/devise-4.7.0/lib/devise.rb:52: warning: previous definition of ALL was here
~/.rbenv/versions/3.4.4/lib/ruby/gems/3.4.0/gems/devise-4.7.0/lib/devise.rb:53: warning: already initialized constant Devise::CONTROLLERS
~/.rbenv/versions/3.4.4/lib/ruby/gems/3.4.0/gems/devise-4.7.0/lib/devise.rb:53: warning: previous definition of CONTROLLERS was here
~/.rbenv/versions/3.4.4/lib/ruby/gems/3.4.0/gems/devise-4.7.0/lib/devise.rb:54: warning: already initialized constant Devise::ROUTES
~/.rbenv/versions/3.4.4/lib/ruby/gems/3.4.0/gems/devise-4.7.0/lib/devise.rb:54: warning: previous definition of ROUTES was here
~/.rbenv/versions/3.4.4/lib/ruby/gems/3.4.0/gems/devise-4.7.0/lib/devise.rb:55: warning: already initialized constant Devise::STRATEGIES
~/.rbenv/versions/3.4.4/lib/ruby/gems/3.4.0/gems/devise-4.7.0/lib/devise.rb:55: warning: previous definition of STRATEGIES was here
~/.rbenv/versions/3.4.4/lib/ruby/gems/3.4.0/gems/devise-4.7.0/lib/devise.rb:56: warning: already initialized constant Devise::URL_HELPERS
~/.rbenv/versions/3.4.4/lib/ruby/gems/3.4.0/gems/devise-4.7.0/lib/devise.rb:56: warning: previous definition of URL_HELPERS was here
~/.rbenv/versions/3.4.4/lib/ruby/gems/3.4.0/gems/devise-4.7.0/lib/devise.rb:59: warning: already initialized constant Devise::NO_INPUT
~/.rbenv/versions/3.4.4/lib/ruby/gems/3.4.0/gems/devise-4.7.0/lib/devise.rb:59: warning: previous definition of NO_INPUT was here
~/.rbenv/versions/3.4.4/lib/ruby/gems/3.4.0/gems/devise-4.7.0/lib/devise.rb:62: warning: already initialized constant Devise::TRUE_VALUES
~/.rbenv/versions/3.4.4/lib/ruby/gems/3.4.0/gems/devise-4.7.0/lib/devise.rb:62: warning: previous definition of TRUE_VALUES was here

✅ upgrade devise gem to 4.9.4.

💣 issue 17 🔗

I get this error during the assets precompilation step when deploying on Heroku:

Uglifier::Error: Unexpected token: keyword (const). To use ES6 syntax, harmony mode must be enabled with Uglifier.new(:harmony => true)

✅ replace uglifier gem with terser and configure the new js compressor in the environment files:

config.assets.js_compressor = :terser # instead of :uglifier used before

💣 issue 18 🔗

Rails console fails with the following error:

~/.rbenv/versions/3.4.4/lib/ruby/site_ruby/3.4.0/bundler/rubygems_integration.rb:215:in 'block (2 levels) in Kernel#replace_gem': can't activate listen (~> 3.5), already activated listen-3.0.8. Make sure all dependencies are added to Gemfile. (Gem::LoadError)

✅ upgrade listen gem to 3.9.0.

💣 issue 19 🔗

RuntimeError:
  Attach interfaces using `implements(SomeType)`, not `include(SomeType)`

✅ upgrade graphql gem to 1.13.25.

At that point, I updated to the latest versions compatible with Rails 7.2.

💣 issue 20 - Zeitwerk 🔗

Next, I encounter a lot of issues related to code eager loading. Rails 7.2 made Zeitwerk the default code loader, which is stricter than the previous classic loader. You must define all classes and modules in files that have matching names and paths. This means that if you define a class Foo::Bar, you must place it in a file foo/bar.rb.

Unfortunately, the project has a lot of code that doesn’t follow this convention. I could not come up with any Zeitwerk configuration that would make it work without changing the code. So, I decided to load those failing files manually in an initializer. I made a new initializer file called config/initializers/zeitwerk.rb. I added this code (it’s obfuscated for security reasons):

# These are classes that don't follow the current inflection rules, and hence Zeitwerk conventions.
custom_classes = %w[
  AlphaProcessJob
  BetaNotificationMailer
  GammaSyncService
]

# Map underscored file names to class names that don't follow Zeitwerk conventions
mapping = custom_classes.map { |klass| [klass.gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase, klass] }.to_h

# Register inflection mapping
Rails.autoloaders.main.inflector.inflect(mapping)

# Ignore specific folders
%w[
  app/modules/foo
  app/modules/bar
  app/modules/baz
].each do |folder|
  Rails.autoloaders.main.ignore(Rails.root.join(folder))
end

# Manually require a few specific files
require Rails.root.join("app/modules/foo/core_update_service.rb")

# Load all Ruby files in nested folders
Dir[Rails.root.join("app/modules/bar/**/*.rb")].each { |f| require f }
Dir[Rails.root.join("app/modules/baz/**/*.rb")].each { |f| require f }
Dir[Rails.root.join("app/modules/shared/**/*.rb")].each { |f| require f }

In the inflections config (config/initializers/inflections.rb), the project had set up abbreviations like this:

ActiveSupport::Inflector.inflections(:en) do |inflect|
  inflect.acronym 'ALPHA'
  inflect.acronym 'BETA'
  inflect.acronym 'GAMMA'
end

Having these inflections, Zeitwerk expects ALPHAProcessJob class defined in alpha_process_job.rb file. But the current class name in this file is AlphaProcessJob. The same applies to some more classes. I don’t want to jeopardize the existing code by making the class renames. Especially, when some of these classes are Sidekiq jobs. Renaming job classes could fail the existing jobs in production. When it comes to other files, I want to mitigate any possible risks. Again, I want Ruby /Rails upgrade to be only an upgrade, but not a refactoring task.

Some folders in the project break Zeitwerk rules so much that inflections and autoloading can’t fix them. For example, the same folder, say app/modules/foo has a file bar.rb with a class Bar (without the required Foo namespace!). At the same time, there is another file, say qux.rb, in the same folder app/modules/foo with a class under the namespace Foo::Qux. Crazy stuff! I don’t know how it has been working before.

For that reason, I ignore these tricky folders and load the files from them by hand. Some files need a specific loading order. For example, class B depends on class A. So, I required them in the initializer. Then, I looped through the files that depend on base classes.

If a module wraps a class, Zeitwerk expects the file to have the same name as the module. For example, if you define a class Foo::Bar, you must place it in a file foo/bar.rb, and the foo.rb file must define the Foo module/class. The project has several modules missing like that. So, I added the necessary files that define these missing modules.

💣 issue 21 🔗

There were a lot of to_s methods in the codebase that were failing with the following error:

wrong number of arguments (given 1, expected 0) (ArgumentError)

✅ change to_s(:utc) to to_fs(:utc).

This is a change in Rails 7.2, where to_s no longer accepts a format argument. Instead, you should use to_fs method for formatting dates and times.

💣 issue 22 🔗

Rails 7 has made some changes on schema.rb. Read this for more details.

I regenerate schema.rb with rails db:migrate and it generates a new schema. The changes I got:

| -ActiveRecord::Schema.define(version: 2025_04_29_203724) do
| +ActiveRecord::Schema[7.2].define(version: 2025_04_29_203724) do


| -    t.datetime "created_at", precision: 6, null: false
| +    t.datetime "created_at", null: false

| -    t.datetime "updated_at", null: false
| +    t.datetime "updated_at", precision: nil, null: false

And so on. As you can see, the schema is now versioned, and the datetime columns have had their precision removed.

💣 issue 23 🔗

The codebase has several places iterating over ActiveRecord errors like this:

errors.each do |attribute, message|
  puts "#{attribute}: #{message}"
end

This fails, as instead of attribute and message, it returns an instance of ActiveModel::Error class now. So, I changed the code to: A code like this has stopped working:

errors.each do |error|
  puts "#{error.attribute}: #{error.message}"
end

💣 issue 24 🔗

A code like this has stopped working:

object.errors[:base] << 'Some error message'

✅ change it to:

object.errors.add(:base, 'Some error message')

This is a change in Rails 7.2, where you should use add method to add errors to the base attribute.

💣 issue 25 🔗

The alias_attribute method no longer works on deleted attributes that are columns of the delegated object.

Say, we have the following code:

class User < ApplicationRecord
  belongs_to :address

  delegate :my_column, to: :address

  alias_attribute :new_my_column, :my_column
end

It would fail with the following error:

User model aliases `my_column`, but `my_column` is not an attribute. Use `alias_method :new_my_column, :my_column` or define the method manually. (ArgumentError)

          raise ArgumentError, "#{self.name} model aliases `#{old_name}`, but `#{old_name}` is not an attribute. " \

✅ change alias_attribute to the plain Ruby code like this:

def new_my_column
  my_column
end

Fortunately, I had to fix only a few places like this.

💣 issue 26 🔗

PaperTrail stopped working at the deserialization step with errors like this:

Psych::DisallowedClass:
        Tried to load unspecified class: Symbol

✅ I added the following to the config/application.rb file:

config.active_record.use_yaml_unsafe_load = true

Some changes in YAML deserialization. See more details on StackOverflow here.

💣 issue 27 🔗

Custom initializers (in config/initializers folder) that use some code defined in the lib folder started to fail with an error like this:

NameError:
  uninitialized constant Foo::Bar

✅ I tried to make Zeitwerk load the lib folder, but it didn’t help. So, I had to require the necessary files in the initializers manually.

require 'lib/foo/bar'

...

There were few initializers like this, so I fixed them by hand.

And… that’s the final point of my story: Ruby/Rails got upgraded; the app works well! 🎉

Reflection on AI tools

Using AI tools in Ruby/Rails upgrade task

I have GitHub Copilot with the GPT-4.1 model inside VS Code that I tried to use in my task. However, the assistance was good only in some routine tasks. For example, when I had to change to_s(<time format>) to to_fs(<time format>) everywhere in the codebase. I had only 2 such issues out of 27. These issues are 21 and 23. It could also be helpful in 24. But for me, the old fashioned search and replace worked fine. So roughly, AI helped me with less than 10% of the issues.

AI tools were not helpful with more complex issues at all. That even led to time wasted. When I had to fix Zeitwerk configuration (20), the AI suggested some several bizarre solutions that didn’t work. At the end I gave up and stopped wasting time on it. Another example of time wasted is on the issue 11 with the DRY auto-inject. These kinds of issue is still a great challenge for AI.

In conclusion, it is unlikely that AI will fully replace software engineers. Instead, I anticipate that AI will serve as a valuable tool for developers, much like how robots have assisted factory workers in improving efficiency and productivity.

First impressions after the upgrade

Live Rails project upgrade first impression

The code autocompletion in the Rails console works better now. The suggestions are much faster and more accurate. Rails server boots also faster. At first glance, the response time for HTTP requests is much lower on Heroku. But these are just my subjective opinions.

The changes aren’t in production yet. I’ll measure the performance later and compare the results.

I like Zeitwerk as the default code loader. It is much stricter than the classic loader, but it helps catch issues early.

Conclusion

Live Rails project upgrade conclusion

Upgrading Rails is not a trivial task, and the process can vary significantly from one project to another. In another project where we are upgrading Ruby and Rails, the plan is somewhat different. That project uses the Paperclip gem for storing and processing attachments. But it’s not compatible with the newer versions of Rails. As a result, it will need to be replaced with Active Storage, which is a tricky migration. Expect more details in the future in our blog.

Each project may have its own set of prerequisites, making the upgrade plan unique to that specific project. This uniqueness primarily stems from the dependencies and the codebase involved. I hope my experience helps you better understand the upgrade process and gives you some ideas on how to approach your own upgrades.

AI tools can be useful, but they are not a complete solution. They can accelerate the process, but it’s still essential to understand the code and the changes you are making. I find AI particularly helpful for repetitive tasks, such as fixing the same issue in multiple locations. However, for more complex problems, I relied on my own knowledge and experience. If I could sense when AI is unhelpful before making a prompt, I wouldn’t waste time on it. This ability seems to be a skill that an engineer needs to develop independently in order to use AI tools more effectively.

I followed the 20/80 rule focusing on the key changes that would bring the biggest benefits. I didn’t try to fix every single issue or violation. Instead, I concentrated on the essential changes that would make the app work on the new Ruby and Rails versions.

Making iterative changes allowed me to identify and test issues in isolation. This way, I could ensure that everything worked as expected before moving on to the next step. As a result, I believe I achieved my goal earlier.

Fortunately, the upgrading app didn’t have much of the Rails “standard” front-end. I’ve been working with Rails since 2009. So I have gone through many Rails upgrades, and the front-end part has always been a pain in the ass. Nowadays, using Rails as an API-only backend with a separate front-end framework (like React or Vue.js) has become a preferred approach for me.

During the upgrade process, I contributed to the open-source community by reporting an issue in dry-auto_inject and making a pull request to Rubocop. I believe contributing to open-source projects is crucial because it connects me to something greater.

Be the first to catch all the exciting new content! 🚀

Read also