Migrating a Spring Boot application to Java 17 – the hard way

No Comments

Java 17 has recently been released, and I’m excited for the many improvements and new features. Instead of starting from a new or recent project (where’s the excitement in that?), we’re going to update an existing Spring Boot application until we can develop new code using Java 17.

Day one

If your situation is anything like ours, you may have older applications, running happily in production, which haven’t been updated in a while. This is unfortunately still common practice. We tend to neglect our existing applications, focusing only on the new ones being actively developed. It’s reasonable: why touch a running system? But there are good reasons for doing so, security being the most important one, and Java 17 can be a great excuse to finally tackle this task.

Many corporations have policies prohibiting non-LTS JDK versions, which is what makes Java 17 so exciting for so many of us. We finally have an LTS version, after so many years, that we can use in our enterprise development.

We would like to use Java 17 with one of our existing projects, so I hope you’ll follow along on our journey. Together we’re going to get our hands dirty and learn some things along the way.

Setup

Our project is a mono-repo containing ~20 Spring Boot applications. They all belong to the same product, which is why they are in a single Maven project. The product consists of an API gateway, exposing REST APIs, multiple back-end applications communicating internally using Kafka and integrating with SAP. All the applications are currently using Java 11 and Spring Boot version 2.3.3-RELEASE.

To give you an idea of what we’re talking about, all of the following Spring projects are used in our project, and by one or more applications:

Let’s get started.

Java 17

Let’s build the project with Java 17. In the IDE, switch the JDK to Java 17 and in the parent POM set the java.version property to 17.

<properties>
  <java.version>17</java.version>
</properties>

Compile the application and let’s see what happens … drum roll please.

$ mvn compile
...
[ERROR] Failed to execute goal org.apache.maven.plugins:maven-compiler-plugin:3.8.1:compile (default-compile) on project app-project: Fatal error compiling: java.lang.IllegalAccessError: class lombok.javac.apt.LombokProcessor (in unnamed module @0x5a47730c) cannot access class com.sun.tools.javac.processing.JavacProcessingEnvironment (in module jdk.compiler) because module jdk.compiler does not export com.sun.tools.javac.processing to unnamed module @0x5a47730c -> [Help 1]

Unfortunately our project failed to compile, but not surprisingly. Let’s take a look at that Lombok error.

Lombok

Lombok is a java library automating the boilerplate code we all love to hate. It can generate the getters, setters, constructors, logging, etc. for us, de-cluttering our classes.

It seems our current version 1.18.12 is not compatible with Java 17, it cannot generate code as expected. Looking at the Lombok change log, Java 17 support was added in 1.18.22.

The version 1.18.12 isn’t managed in our project directly. Like most common dependencies, it’s managed by Spring Boot’s dependency management. We can, however, override the dependency version from Spring Boot.

In the parent pom.xml we can override the Lombok version via a property so:

<properties>
  <lombok.version>1.18.22</lombok.version>
</properties>

Now that we’ve updated the version, let’s see if it compiles:

$ mvn compile
...
[ERROR] Failed to execute goal org.apache.maven.plugins:maven-compiler-plugin:3.5.1:compile (default-compile) on project app-backend: Compilation failure: Compilation failure:
[ERROR] /Users/chris/IdeaProjects/app/src/main/java/de/app/data/ValueMapper.java:[18,17] Unknown property "id" in result type de.app.entity.AppEntity. Did you mean "identifier"?

The ValueMapper class does what the name implies: it maps the Value class to AppEntity, using MapStruct. Strange, we just updated Lombok, so the Java beans should be generated properly. It must be an issue with MapStruct, so let’s take a look.

MapStruct

MapStruct is a Java annotation processor to automatically generate mappers between Java beans. We use to it generate type-safe mapping classes from one Java Bean to another.

We use MapStruct together with Lombok, letting Lombok generate the getters and setters for our Java beans while letting MapStruct generate the mappers between those beans.

MapStruct takes advantage of generated getters, setters, and constructors and uses them to generate the mapper implementations.

After upgrading Lombok to version 1.18.22 the mappers are no longer generated. Lombok made a breaking change in version 1.18.16 requiring an additional annotation processor lombok-mapstruct-binding. Let’s go ahead and add that annotation processor to the maven-compiler-plugin:

<build>
  <plugins>
    <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-compiler-plugin</artifactId>
      <configuration>
        <annotationProcessorPaths>
          <path>
            <groupId>org.mapstruct</groupId>
            <artifactId>mapstruct-processor</artifactId>
            <version>${mapstruct.version}</version>
          </path>
            <path>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>${lombok.version}</version>
          </path>
          <path>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok-mapstruct-binding</artifactId>
            <version>0.2.0</version>
          </path>
        </annotationProcessorPaths>
      </configuration>
    </plugin>
  </plugins>
</build>

That was enough to compile the code and run our unit tests. Unfortunately our integration tests now fail with the following error:

$ maven verify
...
org.springframework.beans.factory.BeanDefinitionStoreException: Failed to read candidate component class: file [ApplicationIT.class];
Caused by: org.springframework.core.NestedIOException: ASM ClassReader failed to parse class file – probably due to a new Java class file version that isn’t supported yet: file [ApplicationIT.class];
Caused by: java.lang.IllegalArgumentException: Unsupported class file major version 61
at org.springframework.asm.ClassReader. (ClassReader.java:196)

Let’s take a look at that ASM error.

ASM

ASM is a framework for Java byte code manipulation. ASM is used by CGLIB, which in turn is used by Spring for AOP.

In the Spring Framework, an AOP proxy is a JDK dynamic proxy or a CGLIB proxy.

Spring, through its use of CGLIB and ASM, is generating proxy classes which are not compatible with Java 17’s runtime. Spring Boot 2.3 has a dependency on Spring Framework 5.2, which uses a version of CGLIB and ASM which is not Java 17 compatible.

Updating the CGLIB or ASM libraries isn’t an option this time, as Spring repackages ASM for internal use. We’ll have to update Spring Boot.

Spring Boot

As mentioned earlier, our project is currently using Spring Boot 2.3.3-RELEASE. At one time, this may have been the latest fix release for Spring Boot 2.3.x, but it’s currently at 2.3.12.RELEASE.

According to the Spring Boot support document, Spring Boot 2.3.x reached EOL in May 2021 (OSS version). That alone is a good enough reason to upgrade, without wanting to use Java 17. See Spring Boot’s support policy for further information.

Spring Boot and Java 17 support

I didn’t find any official Java 17 support statement for Spring Boot 2.5.x nor Spring Framework 5.3.x. They announced that Java 17 will be the baseline in Spring Framework 6, which implies officially supporting Java 17 as of Spring 6, and Spring Boot 3.

That being said, they’ve done a lot of work to support Java 17 in Spring Framework 5.3.x and Spring Boot 2.5.x and list expected support for JDK 17 and JDK 18 in Spring Framework 5.3.x. But which fix release supports Java 17?

I found this GitHub issue Document support for Java 17 #26767, tagged with version 2.5.5. That is awesome and good enough for me.

Release notes

Since we’re upgrading from Spring Boot 2.3 to 2.5, I referenced the release notes for both quite often. You should too.
* Spring Boot 2.4 Release Notes
* Spring Boot 2.5 Release Notes
* Spring Framework Release Notes

Spring Boot 2.5.x

Although Spring Boot 2.6.x arrived a few days ago, let’s stick with Spring Boot 2.5.x. It’s been around for a while, bugs have already been fixed and jumping two minor versions will be enough work. It’s officially supported until May 2022, so we’re good there too. After we’ve upgraded to 2.5.7, the jump to 2.6.x should hopefully be easier.

As of today, the latest Spring Boot 2.5.x version is 2.5.7. We have a Spring Boot version which supports Java 17, let’s do it.

In your parent POM, update the parent to spring-boot-starter-parent:2.5.7.

<parent>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-parent</artifactId>
  <version>2.5.7</version>
</parent>

Notice the -RELEASE suffix missing in the new version. Spring updated their versioning scheme which Spring Boot adopted in version 2.4.0.

IMPORTANT

Before continuing, remove the Lombok dependency override we added earlier, since Spring Boot 2.5 already defines a dependency on Lombok 1.18.22.

We’ve updated the Spring Boot version, and now the real fun begins.

JUnit and the missing spring-boot.version property

My IDE reports that the property spring-boot.version is no longer defined. It was removed from spring-boot-dependencies, seems it was accidentally introduced, and shouldn’t have been there in the first place. Oops.

We use this property to exclude the junit-vintage-engine from our project, since we’ve already updated all our tests to JUnit 5. Doing so prohibits someone from accidentally using JUnit 4.

We excluded the junit-vintage-engine using the spring-boot.version property so:

<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-test</artifactId>
      <version>${spring-boot.version}</version>
      <exclusions>
        <exclusion>
          <groupId>org.junit.vintage</groupId>
          <artifactId>junit-vintage-engine</artifactId>
        </exclusion>
      </exclusions>
    </dependency>
  </dependencies>
</dependencyManagement>

Thankfully we can now remove this block, since Spring Boot 2.4 removed JUnit 5’s Vintage Engine from the spring-boot-starter-test starter. I like it when we can remove code / configuration, less to maintain.

If, however, your project is still using JUnit 4, and you’re seeing compilation errors such as java: package org.junit does not exist, it’s because the vintage engine was removed. The vintage engine is responsible for running JUnit 4 tests alongside JUnit 5 tests. If you cannot migrate your tests, add the following dependency to your pom:

<dependency>
  <groupId>org.junit.vintage</groupId>
  <artifactId>junit-vintage-engine</artifactId>
  <scope>test</scope>
  <exclusions>
    <exclusion>
      <groupId>org.hamcrest</groupId>
      <artifactId>hamcrest-core</artifactId>
    </exclusion>
  </exclusions>
</dependency>

Jackson

Jackson is a library for data-processing tools, for example serializing and deserializing JSON to and from Java beans. It can handle many data formats, but we use it for JSON.

After upgrading to Spring Boot 2.5.7, some of our tests failed with the following error:

[ERROR] java.lang.IllegalArgumentException: Java 8 date/time type `java.time.OffsetDateTime` not supported by default: add Module "com.fasterxml.jackson.datatype:jackson-datatype-jsr310" to enable handling (through reference chain: java.util.LinkedHashMap["updateRecordRequest"]->io.swagger.v3.oas.models.media.ObjectSchema["properties"]->java.util.LinkedHashMap["since"]->io.swagger.v3.oas.models.media.DateTimeSchema["example"])

The issue is already reported on GitHub and as always, the Spring team provides an excellent explanation of the issue and how to resolve it.

With Spring Boot 2.5’s default configuration, serialization of java.time.* types to JSON should work in 2.5 exactly as it did in 2.4 and earlier. The ObjectMapper will be automatically configured with the JSR-310 module and java.time.* types will be serialised to JSON in their expected form.

One thing that has changed here is what happens when the JSR-310 module isn’t available to Jackson. Due to a change in Jackson 2.12, this will now result in a serialization failure rather than Jackson limping along and serializing to an unexpected format.

Yes, you read that right, in previous versions of Jackson, instead of failing, it serialized to something unexpected. Wow. This was fixed in jackson-databind:2.12.0. Faster Jackson now fails faster (thanks @jonashackt for that joke).

Jackson auto-configuration

Spring Boot provides Jackson auto-configuration and automatically declares an ObjectMapper bean, fully configured. Using the IDE, I searched for everywhere we were creating an ObjectMapper instance. In one application, we were declaring our own bean, which I removed, and refactored all code where an instance is created locally. Relying completely on the auto-configured one.

Jackson can be customized without defining your own ObjectMapper bean, using properties or a Jackson2ObjectMapperBuilderCustomizer class. In addition to the official documentation, Baeldung has your back.

But the most important take-away is this:

As described in the documentation, defining an ObjectMapper or your own Jackson2ObjectMapperBuilder will disable the auto-configuration. In these two cases, the registration of the JSR 310 module will depend upon how the ObjectMapper has been configured or the builder has been used.

Double-check that the module com.fasterxml.jackson.datatype:jackson-datatype-jsr310 is on the classpath, and it will be automatically registered in the ObjectMapper.

I’ve seen many projects where the ObjectMapper is customized by re-creating the bean, or it’s created locally within a class or method. This is rarely necessary and can lead to bugs, duplicate configurations. etc. Not to mention that creating the ObjectMapper is expensive. It’s thread-safe so can be created once and reused.

Now that our application is using the ObjectMapper correctly, let’s take a look at one of our libraries.

Atlassian’s Swagger Request Validator

Atlassian’s Swagger Request Validator is a library to validate swagger / OpenAPI 3.0 request/responses. We use this in our test cases, specifically the swagger-request-validator-mockmvc library. If you don’t use this library already, check it out, it’s pretty cool.

We use an older version of this library, which doesn’t use Spring Boot’s Jackson auto-configuration, nor does it register the JavaTimeModule in its own ObjectMapper. They fixed this issue reported in version 2.19.4. After updating the library version, the tests were working again.

This was the only library we were using which had any issues with Jackson, but you may have others. Be sure to use the latest version of our libraries, which usually includes such fixes.

Day one summary

I don’t know about you, but I’m tired and could use a break. This is a great place to stop for today, our code compiles and our unit tests are green. Great work so far.

I hope you join us on day two, as our journey has only just started. When we continue, we’ll see that our integration tests are failing, and we’ll dive deep into why.

I would love to hear about your Spring Boot migration experiences. Please leave comments or feel free to reach out to me. We’re codecentric and we’re here to help.

[Update] December 16, 2021: Forgotten to mention that we’re migrating from Java 11.

Chris fell in love with Java over 15 years ago and hasn’t looked back. His focus is now on Spring, Spring Boot, securing those applications and getting them into the hands of users quickly. He is passionate about DevOps and the potential it unleashes for teams and their users. As a Senior Developer and Consultant, he’s busy coaching his teams, helping them reach their full potential.

Comment

Your email address will not be published.