How to Migrate Micronaut 4 to Micronaut 5 with JDK 25
Writing
JAVA DEVELOPMENT
Published August 12, 202610 min read

How to Migrate Micronaut 4 to Micronaut 5 with JDK 25

Migrate Micronaut 4 to Micronaut 5 step by step. JDK 25 setup, Gradle 9.5 + Maven snippets, Jackson 3, JSpecify, retry API, and real upgrade pitfalls.

Rabinarayan Patra - Software Development Engineer

By Rabinarayan Patra

SDE II at Amazon

migrate-micronaut-4-to-micronaut-5micronaut-5micronautjdk-25jspecifyjvm-migration

Micronaut 5.0.0 hit GA on May 20, 2026. First major Micronaut release in roughly three years, and the migration is heavier than the version number suggests.

I ran the upgrade on a Micronaut 4.7 service last week. The BOM bump is one line. Everything after that is real work: Java 25, Jackson 3, JSpecify imports, a renamed security processor, RxJava 2 gone, MicroStream replaced, and a new programmatic retry API that the old annotation flow quietly redirects to. This guide walks the path I took, with the exact Gradle and Maven snippets that compiled cleanly on my service.

What changed between Micronaut 4 and Micronaut 5?

Micronaut 5 is a platform-wide refresh across 70+ modules, not just a framework bump. The headline changes that affect real services are: JDK 25 baseline (was JDK 17), Kotlin 2.3, Groovy 5, a refactored IoC container with tighter qualifier semantics, JSpecify nullability replacing the older annotation soup, Jackson 3 in micronaut-jackson-databind, HTTP/3 promoted to stable on the Netty stack, and a new programmatic retry and circuit breaker API alongside the existing annotation model.

On the deprecation side, Bootstrap Configuration is marked for removal in Micronaut 6, RxJava 2 is fully dropped (you go to RxJava 3), and MicroStream support is replaced by EclipseStore. If you used micronaut-eclipsestore-annotations, that artifact is renamed too.

The piece I underestimated was the annotation processor rename. Security in particular silently broke my build until I swapped the artifact ID. More on that below.

How do you install JDK 25 for the migration?

Use SDKMAN. It is the cleanest way to flip JDK versions per project without touching system PATH.

sdk install java 25-tem
sdk default java 25-tem
 
# Verify
java -version
# openjdk version "25" 2025-09-16

Add a .sdkmanrc to your project root so the JDK pins automatically when you cd into it:

java=25-tem

If you run on a Docker base image, switch your runtime stage to eclipse-temurin:25-jre or bellsoft/liberica-openjdk-alpine:25. I ran a quick perf check after the JDK bump alone, before touching Micronaut: startup dropped roughly 8% on my service from JDK 21 to JDK 25, before any framework gains. Your numbers will vary, but it is a real improvement.

For more on what is coming after JDK 25, see my deep-dive on Java 26 Structured Concurrency.

How do you update Gradle builds for Micronaut 5?

Bump three coordinates: the Micronaut platform BOM, the Micronaut Gradle plugin, and Gradle itself. Then add the Kotlin and Shadow plugin updates if you use them.

Before (Micronaut 4.7.x):

plugins {
  id("io.micronaut.application") version "4.4.4"
  id("com.gradleup.shadow") version "8.3.5"
  id("org.jetbrains.kotlin.jvm") version "2.0.21"
  id("org.jetbrains.kotlin.plugin.allopen") version "2.0.21"
  id("com.google.devtools.ksp") version "2.0.21-1.0.27"
}
 
micronaut {
  version("4.7.6")
  runtime("netty")
  testRuntime("junit5")
}
 
java {
  sourceCompatibility = JavaVersion.VERSION_17
  targetCompatibility = JavaVersion.VERSION_17
}

After (Micronaut 5.0.0):

plugins {
  id("io.micronaut.application") version "5.0.0"
  id("com.gradleup.shadow") version "9.4.1"
  id("org.jetbrains.kotlin.jvm") version "2.3.21"
  id("org.jetbrains.kotlin.plugin.allopen") version "2.3.21"
  id("com.google.devtools.ksp") version "2.3.7"
}
 
micronaut {
  version("5.0.0")
  runtime("netty")
  testRuntime("junit5")
}
 
java {
  sourceCompatibility = JavaVersion.VERSION_25
  targetCompatibility = JavaVersion.VERSION_25
}

Then run ./gradlew wrapper --gradle-version=9.5.0 --distribution-type=bin once to upgrade the wrapper. Commit the changed wrapper files in the same PR as the Micronaut bump so CI runs against the matching Gradle.

If you read the version catalog instead of inline versions, you only update gradle/libs.versions.toml:

[versions]
micronaut = "5.0.0"
micronaut-plugin = "5.0.0"
kotlin = "2.3.21"
ksp = "2.3.7"
shadow = "9.4.1"

How do you update Maven builds for Micronaut 5?

For Maven, swap the micronaut-parent coordinate and set the Java release properties to 25. Nothing else in the POM should change for a clean Micronaut 4 → 5 upgrade.

Before:

<parent>
  <groupId>io.micronaut.platform</groupId>
  <artifactId>micronaut-parent</artifactId>
  <version>4.7.6</version>
</parent>
 
<properties>
  <jdk.version>17</jdk.version>
  <release.version>17</release.version>
</properties>

After:

<parent>
  <groupId>io.micronaut.platform</groupId>
  <artifactId>micronaut-parent</artifactId>
  <version>5.0.0</version>
</parent>
 
<properties>
  <jdk.version>25</jdk.version>
  <release.version>25</release.version>
</properties>

Make sure your Maven version is 3.9 or later. If you use the Micronaut Maven plugin for native-image builds, it pulls the matching version from the parent BOM, so you do not need to pin it separately.

How do you adopt JSpecify nullability annotations?

Micronaut 5 standardizes on JSpecify for nullability. If your codebase mixes org.jetbrains.annotations.Nullable, javax.annotation.Nullable, and io.micronaut.core.annotation.Nullable, this is the cleanup moment.

The replacement is consistent: any package-level, class-level, or method-level nullability comes from org.jspecify.annotations.

// Before (mixed across the codebase)
import org.jetbrains.annotations.Nullable;
import io.micronaut.core.annotation.NonNull;
 
public Optional<User> findUser(@NonNull String id, @Nullable String tenant) { ... }
 
// After
import org.jspecify.annotations.NonNull;
import org.jspecify.annotations.Nullable;
 
public Optional<User> findUser(@NonNull String id, @Nullable String tenant) { ... }

For broader scope, mark whole packages as non-null by default with a package-info.java:

@NullMarked
package com.example.users;
 
import org.jspecify.annotations.NullMarked;

That single annotation says every reference in the package is non-null unless explicitly marked @Nullable. It cuts annotation noise and gives Kotlin call sites cleaner platform types. Add JSpecify as a direct dependency only if you compile against it outside Micronaut classes; Micronaut transitively brings it in.

IntelliJ has a built-in inspection called "Migrate nullability annotations". Set the target to JSpecify and run it on your src/main tree. Review every change, then commit. Do not let the inspection touch generated code.

Why does Jackson 3 break existing serialization?

micronaut-jackson-databind now uses Jackson 3 exclusively, and Jackson 3 has real wire-compatible differences from Jackson 2. The two ones that bit me:

First, the package root changed. Custom JsonSerializer and JsonDeserializer classes that import com.fasterxml.jackson.databind.* need to move to tools.jackson.databind.*. The class names are the same, the package is different.

// Before (Jackson 2)
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;
 
// After (Jackson 3)
import tools.jackson.databind.JsonSerializer;
import tools.jackson.databind.SerializerProvider;

Second, Jackson 3 fails fast on unknown properties by default. If you relied on lenient deserialization in production, set the right deserialization feature on your ObjectMapper at startup, or annotate the DTO:

@JsonIgnoreProperties(ignoreUnknown = true)
public record OrderEvent(String id, String status, Instant createdAt) { }

I had three DTO classes that were silently dropping unknown fields in Micronaut 4 and started throwing UnrecognizedPropertyException in Micronaut 5. Run integration tests against real payloads before merging.

If you prefer to stay on the Micronaut serialization stack (micronaut-serde-jackson), this section does not apply. Serde is unchanged across the upgrade.

What changed in the security annotation processor?

The annotation processor artifact ID was renamed for several modules in Micronaut 5. The most important one is micronaut-security-annotations, which became micronaut-security-processor.

The compiler error is misleading. You get a stack of "cannot find symbol" or "annotation processor failed to load" messages that point at your own code, not at the missing processor.

<!-- Before (Maven, annotationProcessorPaths) -->
<path>
  <groupId>io.micronaut.security</groupId>
  <artifactId>micronaut-security-annotations</artifactId>
</path>
 
<!-- After -->
<path>
  <groupId>io.micronaut.security</groupId>
  <artifactId>micronaut-security-processor</artifactId>
</path>
// Before (Gradle)
annotationProcessor("io.micronaut.security:micronaut-security-annotations")
 
// After
annotationProcessor("io.micronaut.security:micronaut-security-processor")

The same rename pattern applies to micronaut-eclipsestore-annotations, which becomes micronaut-eclipsestore-processor. If you use Views Turbo, the dependency moved out of the main views module: add micronaut-views-turbo, and rename @TurboView to @TurboStreamView at every usage.

How do the new programmatic retry and circuit breaker APIs work?

Micronaut 4 only let you opt in to retry and circuit breakers through annotations like @Retryable and @CircuitBreaker. Micronaut 5 adds a programmatic, typed API in io.micronaut.retry that you can call without an annotation, which is what you want when the retry policy depends on runtime config.

import io.micronaut.retry.RetryPolicy;
import io.micronaut.retry.RetryState;
import jakarta.inject.Singleton;
 
import java.time.Duration;
 
@Singleton
public class PaymentClient {
 
  private final RetryPolicy retryPolicy = RetryPolicy.builder()
    .maxAttempts(5)
    .delay(Duration.ofMillis(200))
    .multiplier(2.0)
    .includes(IOException.class)
    .build();
 
  public PaymentResult charge(ChargeRequest req) {
    return retryPolicy.execute(() -> gateway.charge(req));
  }
}

The circuit breaker variant uses the same builder shape with circuitBreaker(true), an open-state duration, and a half-open probe count. It lets you wire policy from application.yml:

@ConfigurationProperties("payments.retry")
public record PaymentsRetryConfig(int maxAttempts, Duration delay, double multiplier) { }

I switched two services from annotation-only to programmatic retry during the upgrade. The win is testability: you inject a stub RetryPolicy in unit tests instead of waiting on real backoff timers.

The old annotations still work. You only need the programmatic API where the policy is dynamic. Mixing both in the same service is fine.

What other breaking changes should you plan for?

A few smaller items can stall the build if you do not catch them up front.

  • RxJava 2 is dropped. Add micronaut-rxjava3 and migrate io.reactivex.* imports to io.reactivex.rxjava3.*. If you only used RxJava 2 for HTTP client return types, switching to Mono and Flux via Reactor is the cleaner long-term move.
  • MicroStream is replaced by EclipseStore. Same APIs in most places, but the artifact and package roots moved to org.eclipse.store. If you use the Micronaut integration, depend on the eclipsestore modules.
  • Testcontainers wiring changed. Use org.testcontainers:testcontainers-junit-jupiter for JUnit 5 integration, not the bare junit-jupiter artifact. The old coordinate compiles but produces a NoClassDefFoundError at test time.
  • Data embedded fields need an annotation. Annotate embedded fields with @MappedProperty so the column names stay stable. If you want the old behavior, set micronaut.data.embedded.naming.strategy=LEGACY in application.yml.
  • Bootstrap Configuration deprecated. Still works in Micronaut 5, scheduled for removal in Micronaut 6. If you load critical secrets through bootstrap, plan the migration to PropertySourceImporter SPI.

After fixing these, a clean rebuild should pass. If your test suite still fails on dependency injection, regenerate the IDE project files. IntelliJ caches the old annotation-processor outputs and will mislead you for an hour before you give up and reimport.

What should you do after the Micronaut 5 upgrade is live?

Once the service compiles and tests pass, do four things before merging:

  1. Run an integration test pass against real upstream payloads, especially anything that hits Jackson. The strict unknown-property default will catch dirty data you forgot you were ignoring.
  2. Re-baseline startup time and memory in production. The IoC refactor and JDK 25 combined cut my startup by ~14% on a six-controller service. You want a number you can show to your platform team.
  3. Audit your @Replaces, @Requires, and qualifier annotations. The compile-time semantics tightened, and a couple of ambiguous-bean errors that Micronaut 4 silently resolved by ordering now throw at startup. The error message names both candidates, which is the fix.
  4. Update your CI base images to JDK 25. I forgot this for one repo and got a green local build with a broken mvn deploy job. Pin the image, not just the toolchain.

The platform refresh is the biggest Micronaut release since 4.0 in 2023, and it sets the floor for what a Java microservice baseline looks like for the next two years.

For more on the Micronaut 5 release, see the official Micronaut 5.0.0 announcement, the Update to Micronaut 5 guide, and the Java News Roundup May 18, 2026 on InfoQ.

Keep Reading

Frequently Asked Questions

What is Micronaut 5?

Micronaut 5 is the May 20, 2026 major release of the Micronaut Framework. It moves the JDK baseline to Java 25, Kotlin to 2.3, and Groovy to 5, refactors the IoC container, adopts JSpecify nullability, and ships HTTP/3 as stable. It is the first major Micronaut release in roughly three years.

How do you migrate a Micronaut 4 project to Micronaut 5?

Migrate Micronaut 4 to Micronaut 5 in five steps: install JDK 25, bump the platform BOM to 5.0.0, upgrade Gradle to 9.5.0 (or set the Maven parent to micronaut-parent 5.0.0), switch nullability imports to JSpecify, and rename the deprecated annotation-processor artifacts. Then run your build and fix the Jackson 3 and security-processor compile errors before touching code.

Does Micronaut 5 still support Java 17?

No. Micronaut 5 requires Java 25 as the minimum baseline at both compile and runtime. If you cannot move off Java 17 yet, stay on the latest Micronaut 4.x line until your JDK upgrade is ready. RxJava 2 and MicroStream support are also gone, so plan those migrations before bumping the BOM.

What changed in the IoC container in Micronaut 5?

Micronaut 5 rewrote bean resolution, qualifier handling, and replacement metadata at the compile-time layer. The annotation processor produces leaner bean introspections, which shaves more startup time and cuts native-image footprint. Most application code does not need to change, but custom @Replaces, @Requires, and qualifier annotations should be retested because edge-case ordering rules tightened.

Rabinarayan Patra - Software Development Engineer

Rabinarayan Patra

SDE II at Amazon. Previously at ThoughtClan Technologies building systems that processed 700M+ daily transactions. I write about Java, Spring Boot, microservices, and the things I figure out along the way. More about me →

X (Twitter)LinkedIn

Stay in the loop

Get the latest articles on system design, frontend and backend development, and emerging tech trends, straight to your inbox. No spam.