The Moderne Platform, the automated code refactoring and analysis SaaS solution we offer, is implemented with microservices written in Spring Boot. We are a small engineering team managing this important platform for our customers, and we must carefully execute code updates and migrations to minimize any disruption.
We had spent the better part of 2023 telling our customers and the software community that Spring Boot 2 was nearing end of life and that they should be focusing on upgrading to Spring Boot 3.x. As the open source support window for Spring Boot 2 closed in November, we decided to move forward with our own migration using OpenRewrite recipes and the Moderne Platform.
We consider two types of changes that can be accomplished via OpenRewrite recipes on the Moderne Platform: push-based and pull-based. Push-based changes are simple upgrades that we do routinely such as Gradle wrapper upgrades, static code analysis, code formatting, and stylistic consistency. Overtime we have built trust in their accuracy, and we can readily commit, approve, and merge them from the Moderne Platform.
Spring Boot 3.2 is what we’d call a pull-based migration. We had to carefully plan it, evaluate the implications, and examine third-party dependencies and infrastructure changes required. We had to pick the time when we had downtime in feature delivery (December!). We ran Spring Boot 3 recipes from our platform and committed back the results, but before merging and deploying, different team members reviewed and discussed the changes. We ran and tweaked this recipe multiple times to arrive on the best combination of automated and manual changes.
This article details our process, challenges along the way, and the ultimately successful migration.
Moderne Platform architecture and software stack
Before we get into the details of the migration, I wanted to share a quick review of our architecture and important software components involved in this migration.
We have microservices running both in the public cloud and in our customers' environments. All these services run with Spring Boot and connect either over GraphQL or RSocket. We share common components via libraries with Spring AutoConfiguration.
The most complex product edition for us is our single tenant SaaS. In addition to SaaS components that we fully control, there is an on-premises component that is managed on the customer side. We needed to be careful that the changes on the framework level didn’t cause incompatibilities in agent communication to the API gateway.
About the Spring Boot 3.2 migration recipe
Spring Boot 3.2 is a declarative recipe composed of other migrations including Java 8 to 17 and JUnit 4 to 5. It chains previous version upgrades all the way back to Spring Boot 1. Moderne and the Spring team started to work on this recipe at the Spring Boot 1 to 2 migration, which was a major pain, and have kept up with the version bumps since then. This recipe will meet you where you are and lift you to the latest versions. You can also upgrade gradually using sub-recipes defined here one at a time. This can make it easier to review and evaluate changes if your codebase is changed significantly from the migration.
Recipes can deliver not only necessary changes but also best practices and very pleasing changes, such as the adoption of multi-line strings in Java 17. They also educate developers about the new features and best practices of the newer versions. When a recipe makes a change to a codebase, you can see which individual recipe made a change and why it was done from the recipe description.
The Spring Boot 3.2 migration recipe was made possible by the sheer number of open source contributions from many individuals and organizations.
Running the Spring Boot 3.2 migration recipe
After the initial planning and recipe customizations for our environment, running the automated migration was straightforward (basically a click of the ‘Dry Run’ button). For 37 repositories with 2,700 total files, it took the Moderne Platform one minute to execute the migration (that’s right, one minute!). That was a savings of 36 hours for our small development team. All told, it took the Moderne team under a day from recipe run to merging pull requests (PRs).
Then after some manual work we had our code building again.
In the Activity view in the Moderne Platform, you can see the progress of the migration from PR to mass approval. While this automation does cover most of the changes, there were still some manual fixes that needed to be made after.
The other 10-20% of work after the automated migration
Let’s walk through some cleanup and additional things we had to do after executing the Spring Boot 3.2 migration recipe to get our code back up and running even better than before. These may or may not be relevant to your environment, but they show the types of things to consider—including how to take advantage of new capabilities from the new versions.
Adding JAXB dependencies
The Java Architecture for XML Binding (JAXB) dependencies were added to virtually all projects. This is because the recipe is not always able to detect availability and usage of JAXB when reflection is used. Instead of working on fixing the recipe (the issue is not so easy to solve), we decided that it would be faster to just fix the issues introduced by hand. It’s an example of not expecting perfection, but exercising judgment when to improve automation versus fix issues introduced by a recipe manually. If we had 1,000s of repositories, it would make sense to improve the recipe. Instead, at our scale, we removed these lines by hand in dozens of the repositories.
This was solved by removing the dependencies from the build.gradle when not needed:
runtimeClasspath "org.glassfish.jaxb:jaxb-runtime:4.0.4"
Removing escaped newlines that were added
When migrating concatenated strings to Java text blocks the recipe added escaped newlines to the end of the lines. In a text block these backslashes mean that the newline should be ignored and in fact not added to the actual string. Even though this was technically correct in the recipe (the text block version was equal as the initial string also did not have new lines in it), in our case these SQL queries actually did not need these newlines escaped so we cleaned it up.
INSERT INTO events (user_id, target, outcome, action, action_type, description, timestamp) \
VALUES (:userId, :target, :outcome, :action, :actionType, :description, :timestamp)\
DGS framework upgrade
One of the nice side-effects of upgrading to Spring Boot 3 was that we could also upgrade other libraries that had depended on the older version. In this case, the DGS framework was bumped up by three major versions and came with the advantage of bi-directional schema validation.
We also found some data fetchers that we had long ago removed from the schema but the code itself was still there. We cleaned these up as well.
Spring classes clean-up
With Spring Boot 3 we also get Spring Framework 6. Quite a few things have changed in the framework, including some low level classes, such as HttpMethod becoming records and changes to the interfaces. Since we are sending these as serialized fields over our RSocket connection we needed to make some changes to our (de)serialization.
There were also some other small code changes that we found in many places like the method isError() that moved from HttpError to HttpErrorCode.
- .onStatus(HttpStatus::isError, response -> response.bodyToMono(String.class).flatMap(ErrorHelper::fromBody))
+ .onStatus(HttpStatusCode::isError, response -> response.bodyToMono(String.class).flatMap(ErrorHelper::fromBody))
Missing transitive dependencies fixed
Because of all the upgraded libraries we found that a few transitive dependencies we needed were missing (they were included in older libraries but not in the newer versions). We added them as direct dependencies where needed. An example is: io.grpc:grpc-netty.
Adding Micrometer observability
One of the bigger impacts we found from the Spring Boot 3.2 migration was inclusion of the migration to the new Micrometer observability facade. We had a few places where we needed to change our code to use it. An example was moving from MetricsWebClientFilterFunction to DefaultClientRequestObservationConvention.
Changing out our Keycloak wrapper
Keycloak is an open source software product that we use for single sign-on with identity and access management. To run Keycloak with our own observability and auditing we were using a wrapper that did not have support for Spring Boot 3.2. After some searching we landed on a slightly different solution to re-implement the wrapper. An additional advantage is that we were able to get back to the latest version of Keycloak, and it is now a lot easier to keep up to date with the latest security patches.
Merging and deploying the migrated code
We have set up all our services to always use the latest version of our libraries. This makes upgrading a library a lot less tedious, but also has the side-effect of not being able to temporarily opt out of bigger changes like this migration. Since we were not going to be able to deploy and test everything properly in a single working day, we had to have a good plan to execute this deployment.
Infrastructure change
Because of the new Java 17 requirement, we looked at all our GitHub workflows to ensure that all of them were using Java 17. Most were already updated, but some manual updates were needed. Since all our OCI base images were already on Java 17 we did not need to change anything there.
Pinning libraries and plugins
To be able to release any of the services with Spring Boot 3.2, we first needed to release our libraries. However, once the libraries were released, our Spring Boot 2 services would no longer compile. To allow the team to still deploy fixes where needed, we provided a set of pinned dependency versions of libraries and build plugins to the earlier Spring Boot 2 versions. This way if a fix was required, we could still compile and deploy using the older library versions.
Deployment order matters
Because part of the single tenant SaaS is running inside the customer environment, we had no control over when exactly that service would be upgraded. Consequently, we had to make sure that everything still worked with a mixed Spring Boot 2 and 3 setup. We made a small change in our API gateway to make sure everything was backwards compatible and the order of deployment would not matter.
We then chose to deploy the most-changed services first so that we would have the least impact on other team members working on features in those services. Even though this increased the risk of things going wrong with our main functionality, this ensured we could keep developing at the high speed we’re used to.
Lessons learned from our Spring Boot 3 migration
The OpenRewrite recipes automated 80-90% of the migration work for us (saving us about a week of work on our relatively small codebase). For larger teams and codebases, this savings can be in the weeks and months. But the recipes simply could not capture every corner case and things unique to us. We did end up doing manual work that was specific to our environment to fully complete our migration.
Finally, like with manual migration efforts, automated migrations must also be done in the context of the best timing for your team to ensure the least amount of disruption. There are always some unexpected things and new opportunities that come up with a migration, and we hope this blog has surfaced some of those areas for you in the Spring Boot 3.2 upgrade.
Book a demo with us to see how the Moderne Platform can enable large scale migrations and updates for your codebase.