Upgrade Errors That Named the Wrong Layer

A compiler error is supposed to work like an address. It names a file, a line and a symbol, and the fix lives somewhere near what it names.

One morning in July, five Maven repos put that to the test. They had last been modernized in August 2025, and now all of them needed their frameworks dragged forward at once: Spring Boot 3.4.1 to 4.1.0 in two, Quarkus 3.17.4 to 3.37.4 in one, and two Micronaut projects from 3.x to 5.0.6. The JDK doing the compiling was Temurin 25.0.3. Every pom still targeted Java 17.

Most of what broke named a layer the fix didn't live in: my own getters, a version number, a processor path. One error named its cause exactly and still invited the wrong fix. The first failure named nothing, because it printed nothing.

Assumes you've bumped a framework version in a Maven build before.

Incremental compile: the green build that skipped two files#

irc-bot's parent went from Spring Boot 3.4.1 to 4.1.0. mvn -q -DskipTests compile came back clean, exit zero, and I committed the bump as verified.

A final mvn clean compile across all five repos disagreed:

[ERROR] .../service/DataExporter.java:[7,38] package com.fasterxml.jackson.databind does not exist

JsonDataLoader.java failed the same way. Neither file had changed. The classpath under them had: Boot 4 no longer puts com.fasterxml.jackson.databind on it at all (more on that below).

maven-compiler-plugin 3.15.0, the version Boot 4.1.0's parent manages, decides whether to recompile from four checks in AbstractCompilerMojo: changed source, added or removed source files, an output it can't update in place, or a changed dependency. "Changed dependency" means a .jar or .class on the classpath whose modification time is later than the build start.

A dependency that left the classpath isn't there to be checked. The new jars don't count either, because they don't look new. In my ~/.m2, spring-boot-4.1.0.jar carries an mtime of 2026-06-10, six weeks before it was downloaded; its .sha1 sibling was written at 04:19 on the morning of the upgrade. jackson-databind-3.1.4.jar had been sitting in the cache since June. Nothing tripped, so nothing recompiled.

After any dependency or framework change, run mvn clean compile. The next commit message had to correct the one before it.

Lombok on JDK 25: cannot find symbol on generated getters#

Before the Boot 4 work started, irc-bot already failed on JDK 25:

[ERROR] .../configuration/IrcConfiguration.java:[26,58] cannot find symbol
  symbol:   method getHostname()
  location: variable sc of type ...IrcConfiguration.ServerConfig
[…]
[ERROR] .../listener/Command.java:[13,50] cannot find symbol
  symbol:   method getLevel()
  location: variable user of type com.ztomic.ircbot.model.User

Every missing method is a Lombok accessor: @Getter on the field, method at compile time. The output reads as if someone had gone through and deleted the getters by hand.

First guess: stale Lombok. irc-bot set no lombok.version of its own, so the Boot 3.4.1 parent's pin applied, and that resolves 1.18.36, too old for JDK 25. barnslig, on the same parent and the same pin, showed what that looks like when Lombok runs:

Fatal error compiling: java.lang.ExceptionInInitializerError: com.sun.tools.javac.code.TypeTag :: UNKNOWN

irc-bot never got that far. Its Lombok was stale, but a processor can't crash if it never starts.

The reason it never started landed in JDK 23, two releases before the one on this machine. JDK 23 stopped running annotation processors it merely discovers on the compile classpath. Up to JDK 22, javac found any processor sitting on the classpath and ran it. From 23 that is off by default: a processor has to be requested, with -proc:full or an <annotationProcessorPaths> entry, or it's skipped. Skipped Lombok generates nothing, and every call to a generated method fails to resolve. There's no warning and no "processor not run" note.

It hit one repo of five. Three of the other four already listed Lombok under <annotationProcessorPaths>, so javac kept running it. The Quarkus repo doesn't use Lombok at all. Same JDK, five projects, one broken: the shape that sends you hunting through the project instead of the platform.

Neither error message contains the word "Lombok."

The fix needed both halves. First, name the processors so javac runs them on purpose:

<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-compiler-plugin</artifactId>
  <configuration>
    <annotationProcessorPaths>
      <path>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <version>${lombok.version}</version>
      </path>
      <path>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-configuration-processor</artifactId>
        <version>${project.parent.version}</version>
      </path>
    </annotationProcessorPaths>
  </configuration>
</plugin>

Second, override <lombok.version> to 1.18.46, since that property feeds the path entry. Naming the processor alone would have woken up the same 1.18.36 that crashed barnslig. irc-bot's fix commit carries both changes. The three repos that already had a processor path needed only the override.

Micronaut 5: three failures nested inside each other#

Micronaut stacked three failures, and each one showed up only after the one above it was fixed. This was the same Micronaut 5 whose moved coordinates had already caught me out on another repo.

The parent coordinate was dead. The pom inherited from:

<parent>
  <groupId>io.micronaut</groupId>
  <artifactId>micronaut-parent</artifactId>
  <version>3.7.3</version>
</parent>

Ask Maven Central for the latest io.micronaut:micronaut-parent and it answers 4.0.0-M1, a milestone from April 2023. That is the highest version number on the coordinate, not its newest upload: the 3.10.x line there kept getting patch releases (3.10.10 came out on 2026-07-13), but no 4.x GA ever shipped under that groupId. The GA line had moved to io.micronaut.platform:micronaut-parent, whose newest release on 2026-07-26 was 5.0.6. A "latest version" lookup on the old coordinate returns a three-year-old pre-release and presents it as an answer.

The validation coordinate never reached 5.x. With the parent on 5.0.6:

Could not find artifact io.micronaut:micronaut-validation:jar:5.0.6 in central

The processor paths pinned every entry to ${micronaut.version}, which is the platform version, 5.0.6. Platform and modules don't share version numbers: the 5.0.6 BOM manages core at 5.0.7. That makes the pin wrong for core. For validation it made the pin impossible. io.micronaut:micronaut-validation never left 3.x (its newest release on 2026-07-26 was 3.10.10), and the module now lives in io.micronaut.validation, which the BOM manages at 5.0.0. The error names a missing version. What was missing was the group. The fix: new groupId, the processor renamed to micronaut-validation-processor, and no <version> elements, so the BOM chooses.

The compiler plugin was too old. Deleting those <version> elements surfaced the third layer:

Resolution of annotationProcessorPath dependencies failed: version can neither be null, empty nor blank

Resolving processor-path versions from dependencyManagement needs maven-compiler-plugin 3.12.0 or newer. This repo had pinned 3.11.0, a stopgap for a ${maven-compiler-plugin.version} property the old parent never defined. Removing the pin let the new parent supply 3.15.0, and the chain resolved.

Other modules moved or died in the same jump:

  • micronaut-validation moved to group io.micronaut.validation; the processor is micronaut-validation-processor
  • micronaut-tracing split per backend, with micronaut-tracing-core as the base
  • micronaut-netflix-hystrix is gone: the module was dropped in Micronaut 4, and Hystrix is end of life
  • jaeger-thrift is gone: the standalone Jaeger client is end of life, and OpenTelemetry replaces it
  • org.testcontainers:junit-jupiter stops at 1.21.4; for Testcontainers 2.x the module is testcontainers-junit-jupiter, and the BOM manages Testcontainers 2.0.5

One runtime-shaped surprise compiled clean: Micronaut Data stopped parsing the Top keyword in derived query methods.

Unable to implement Repository method: ReleaseRepository.findTopBySubCategoryAndName(String,String).
Cannot project on non-existent property: top

It now reads Top as a projection and goes looking for a property named top. Renamed to findFirstBy…, the method parses.

Jackson 3 in Boot 4: the right error, the wrong obvious fix#

Back to package com.fasterxml.jackson.databind does not exist. This error named its cause exactly. Spring Boot 4 ships Jackson 3, and Jackson 3 changed its top-level package. Here is what the starter pulls in now:

+- org.springframework.boot:spring-boot-starter-json:jar:4.1.0:compile
|  \- org.springframework.boot:spring-boot-jackson:jar:4.1.0:compile
|     \- tools.jackson.core:jackson-databind:jar:3.1.4:compile
|        +- com.fasterxml.jackson.core:jackson-annotations:jar:2.21:compile
|        \- tools.jackson.core:jackson-core:jar:3.1.4:compile

Jackson 3 lives under tools.jackson. Only jackson-annotations stays on 2.x, shared between the two major versions.

The tempting fix is to put com.fasterxml.jackson.core:jackson-databind back. It compiles. Whether it runs is a question the compiler can't see. DataExporter takes an injected ObjectMapper, and Boot 4 auto-configures a Jackson 3 mapper. Ask for the Jackson 2 type and something has to provide a Jackson 2 bean. Spring supports that only as its last-ranked option, a temporary stepping stone on deprecated auto-configuration. I reasoned that a bare re-add would leave the constructor with no matching bean, and never started the app to check. It wouldn't have been quick to see anyway: DataExporter is annotated @ConditionalOnProperty("data.export"), so an ordinary start never builds it.

The migration itself was small. DataExporter needed only its import moved to tools.jackson.databind.ObjectMapper. JsonDataLoader configured its mapper in place, and Jackson 3 mappers are immutable:

 	private ObjectMapper getObjectMapper() {
-		return new ObjectMapper()
-				.enable(JsonParser.Feature.ALLOW_COMMENTS)
-				.enable(SerializationFeature.INDENT_OUTPUT);
+		// Jackson 3 mappers are immutable, so features are set on the builder
+		// rather than enabled on an instance.
+		return JsonMapper.builder()
+				.enable(JsonReadFeature.ALLOW_JAVA_COMMENTS)
+				.enable(SerializationFeature.INDENT_OUTPUT)
+				.build();
 	}

JsonParser.Feature.ALLOW_COMMENTS became JsonReadFeature.ALLOW_JAVA_COMMENTS. I took those signatures from the jar, not from search results:

unzip -l jackson-databind-3.1.4.jar | grep ObjectMapper
javap -cp jackson-databind-3.1.4.jar tools.jackson.databind.ObjectMapper | grep writeValue
javap -cp jackson-core-3.1.4.jar tools.jackson.core.json.JsonReadFeature | grep 'public static final'

javap on the artifact you resolved can't be stale relative to itself. It confirmed each signature in seconds, including that the new writeValue and readValue throw JacksonException.

Version lookups: two ways to get "newest" wrong#

Two of these traps came down to "what is the newest version," and the lookups failed in two different ways:

  1. The search.maven.org index is stale. Its Solr endpoint named Spring Boot 3.5.3 as the newest release while 4.1.0 was already on Central. The index stops in June 2025: that 3.5.3 record is stamped 2025-06-20, and a query for 4.0.0 finds nothing. repo1.maven.org/maven2/<path>/maven-metadata.xml lists what was deployed.
  2. The <release> field in that metadata can be a candidate. On 2026-07-26, Quarkus's metadata said <release>3.38.0.CR1</release> while the newest stable was 3.37.4. The field holds the highest version number deployed, CR suffix included, not the newest upload: Micronaut's old parent coordinate still answers 4.0.0-M1 from 2023. Filter pre-releases yourself.

What "migrated" means here#

All five pass mvn clean compile on Temurin 25.0.3: Boot 4.1.0 with spring-cloud 2025.1.2, Quarkus 3.37.4, Micronaut 5.0.6, log4j 2.26.0, Lombok 1.18.46. Ten commits got them there. Two touched Java, five files between them.

At 04:32, when the last migration commit landed, four of the five repos had no test sources. The fifth had one test, a @MicronautTest that boots music-sorter-boosted and asserts it's running. It passed. That single boot was all the runtime evidence behind five migrations. On the other four, a green mvn test proved only that no tests ran: the most reassuring output in software and, there, the most meaningless.

It stayed that way for under an hour. At 04:50 a new context test in barnslig found the app couldn't start at all — a NoClassDefFoundError traced to an old snakeyaml pin. At 05:13 irc-bot got fourteen tests, and its context test found two startup bugs of its own.

Nothing has gone through the new Jackson 3 mapper. Jackson 3 changed defaults, and Spring's migration notes single out MapperFeature.SORT_PROPERTIES_ALPHABETICALLY, now on. Whether DataExporter writes the same JSON it used to was, at 04:32, a question no test in these repos could answer. Some bugs only exist once a real dependency and a real request are behind them.

What broke when irc-bot was finally launched for real that evening, after its port to Micronaut, is the next post.

Filed under: java, spring-boot, micronaut, quarkus, migration, lombok, jackson

Sources#