Many order and payment systems still run on Spring Boot 2.7 with Java 8 or 11, because "if it works, don't touch it." Standing still has a price: security patches stop, new libraries drop support for the old version, and the gap to the current release widens every quarter. Spring Boot 3 is the biggest jump in the Spring line in years: a Java 17 baseline, every javax.* package renamed to jakarta.*, Spring Framework 6, and a new observability layer built on Micrometer Observation. This article walks through a real upgrade: what has to change, which tools catch the problems, and how to get both metrics and traces from the same piece of code. Every result comes from an application that actually ran on JDK 21 and Spring Boot 3.4.4.

This article follows Hexagonal Architecture with Spring Boot: ports and adapters: a clean architecture means a framework upgrade only touches the adapter layer. The data access side is covered in Repository pattern and Unit of Work with JPA, where Hibernate 6 causes the most changes.

Why move to Spring Boot 3, and which version to target

Each Spring Boot line (for example 3.4) receives open-source support for only about 13 months. According to the lifecycle summary on endoflife.date, the 2.7 line reached the end of OSS support on June 30, 2023 (commercial support only after that), 3.0 on December 31, 2023, 3.1 on June 30, 2024, and 3.2 on December 31, 2024. At the time of writing (April 2025), the 3.3 line is supported until June 30, 2025 and the 3.4 line until December 31, 2025. A practical rule for picking a Spring Boot 3 version: aim for the newest line, and once you are on Spring Boot 3, take a minor upgrade every 6 months instead of saving them all up for one big jump.

ComponentSpring Boot 2.7Spring Boot 3.4.4 (run in this article)
Minimum Java817 (ran on 21; the docs list compatibility up to Java 24)
Spring Framework5.36.2.5
Java EE packagesjavax.* (Servlet 4, JPA 2.2)jakarta.* (Servlet 6.0, JPA 3.1)
Embedded Tomcat9.010.1.39
Hibernate5.66.6.11.Final
Micrometer1.9 (metrics)Observation 1.14.5, Tracing 1.4.4

The 3.4.4 column is the real list of jars inside boot3-demo-1.0.jar (from unzip -l). The 2.7 column follows the Spring Boot 2.7 dependency-versions table. The behavior changes in the later sections are based on the Spring Boot 3.0 Migration Guide. The system requirements for the 3.4 line (Java 17 or later, Maven 3.6.3, Tomcat 10.1.25 or later) are in the System Requirements documentation.

Upgrade order for Spring Boot 3: step by step, not in one jump

The Migration Guide recommends upgrading to the latest 2.7.x first, because that release already marks most APIs removed in Spring Boot 3 as @Deprecated. This is the process we use when moving a project to Spring Boot 3:

  1. Freeze features: the upgrade branch contains only upgrade-related changes, so whatever breaks has a known cause.
  2. Move to the latest 2.7.x on the Java version you already use, fix every deprecation warning, and run the full test suite.
  3. Raise Java to 17 (or 21) while still on Spring Boot 2.7; Spring Boot 2.7 runs fine on Java 17, so any failure at this step comes from the JDK (modules, reflection), not from Spring.
  4. Switch javax to jakarta and raise the parent to 3.x.
  5. Run spring-boot-properties-migrator to find renamed properties.
  6. Handle behavior changes (trailing slash, Hibernate 6, Actuator, Spring Security 6) with integration tests.
  7. Enable observability last, once the application is stable.

For large projects, OpenRewrite has UpgradeSpringBoot_3_x recipes that automatically rename javax.persistence imports, rename properties in application.properties, and raise java.version in pom.xml. This article does not run OpenRewrite (its guide requires configuring a separate recipe repository), so the sections below do the work by hand to show each change clearly. Even with a tool, you still have to read the git diff and run the tests.

Spring Boot 3 and Jakarta EE: the package rename is not cosmetic

When Java EE moved to the Eclipse Foundation, the right to use the javax name did not move with it, so from Jakarta EE 9 every package became jakarta.*. Spring Boot 3 requires Jakarta EE 10 (Servlet 6.0, JPA 3.1), which means javax.persistence, javax.validation, and javax.servlet all have to change. To see the real errors, we kept the sample application as is (a JPA entity with @NotBlank, a controller with @Valid), changed the imports back to javax, and compiled with the migrate-demo.sh script:

$ mvn -q compile   # code still imports javax.*
src/main/java/demo/sb3/OrderController.java:[3,24] package javax.validation does not exist
src/main/java/demo/sb3/OrderController.java:[4,36] package javax.validation.constraints does not exist
src/main/java/demo/sb3/OrderController.java:[5,36] package javax.validation.constraints does not exist
src/main/java/demo/sb3/OrderEntity.java:[3,25] package javax.persistence does not exist
... (9 errors)

$ sed -i "s/javax\./jakarta./" ...; mvn -q compile
exit=0

The compiler reports 9 missing-package errors on the import lines (plus 9 cannot find symbol errors where the annotations are used, so the excerpt filters only the does not exist lines). After replacing javax.persistence and javax.validation with jakarta.*, mvn -q compile exits with code 0. The easy-to-miss parts are outside your own code:

  • Third-party libraries that still depend on javax.* (old Swagger/springfox versions, some reporting libraries, old drivers) will not work; move to a release with a jakarta suffix or replace the library. Run mvn dependency:tree and search for javax.
  • Do not rename every javax: javax.sql, javax.crypto, and javax.net belong to the JDK and must stay. A project-wide sed replacement will break them.
  • XML configuration files and persistence.xml that contain old class names or namespaces also need to change.

Renamed properties in Spring Boot 3: let a tool find them

Spring Boot 3 removes or renames many configuration keys, and Spring Boot 3 still starts successfully if old keys remain; their values are simply ignored. Instead of cross-checking a table by hand, temporarily add spring-boot-properties-migrator (runtime scope) and start the application. We deliberately added four Spring Boot 2.x style properties to the configuration file and got this report:

$ add spring-boot-properties-migrator + 4 old properties, run the app
Property source 'Config resource 'class path resource [application.properties]' via location 'optional:classpath:/'':
	Key: management.metrics.export.prometheus.enabled
		Replacement: management.prometheus.metrics.export.enabled
	Key: spring.redis.host
		Replacement: spring.data.redis.host
Property source 'Config resource 'class path resource [application.properties]' via location 'optional:classpath:/'':
	Key: server.max-http-header-size
		Reason: Replacement key 'server.max-http-request-header-size' uses an incompatible target type
	Key: spring.jpa.hibernate.use-new-id-generator-mappings
		Reason: Hibernate no longer supports disabling the use of new ID generator mappings.

Two properties were mapped automatically to their new names (management.prometheus.metrics.export.enabled, spring.data.redis.host); the other two need your own decision. For example, server.max-http-header-size becomes server.max-http-request-header-size, but the target type differs, so the tool does not convert it. Remember to remove the migrator when you are done: it analyzes the configuration at startup and does not belong in a production build.

Silent behavior changes in Spring Boot 3

Compile errors are easy to deal with; the dangerous failures are the ones where the code still compiles but the behavior changes. The following three changes cause the most incidents when an application moves to Spring Boot 3.

Trailing slash no longer matches

Starting with Spring Framework 6, /orders/1/ no longer matches the route /orders/{id}. The Migration Guide lists this default as false. The result on the sample application:

$ curl -s -o /dev/null -w "/orders/1  -> %{http_code}\n" localhost:8085/orders/1
/orders/1  -> 200
$ curl -s -o /dev/null -w "/orders/1/ -> %{http_code}\n" localhost:8085/orders/1/
/orders/1/ -> 404

With Spring Boot 3, if a client, a cron job, or a load balancer health check calls a URL with a trailing /, you will get a wave of 404s right after deployment. The best fix is to change the client; a temporary workaround is to declare an extra route or configure PathMatchConfigurer to allow a trailing /, then plan to remove it.

Errors returned as ProblemDetail

Spring Framework 6 supports RFC 7807 (ProblemDetail); with spring.mvc.problemdetails.enabled=true, errors generated by the framework use the application/problem+json content type. Here are both modes on the same endpoint:

$ curl -si -X POST localhost:8085/orders -d '{"customer":"","amountVnd":0}' ...
HTTP/1.1 400 
Content-Type: application/problem+json

{"type":"about:blank","title":"Bad Request","status":400,"detail":"Invalid request content.","instance":"/orders"}

$ curl -si localhost:8085/orders/999
HTTP/1.1 404 
Content-Type: application/problem+json

{"type":"about:blank","title":"Not Found","status":404,"detail":"No order 999","instance":"/orders/999"}

$ java -jar target/boot3-demo-1.0.jar --spring.mvc.problemdetails.enabled=false
$ curl -si localhost:8085/orders/999
HTTP/1.1 404 
Content-Type: application/json
{"timestamp":"...","status":404,"error":"Not Found","path":"/orders/999"}

Enabling ProblemDetail in Spring Boot 3 changes the shape of the error body (it adds type, title, detail, and instance, and drops timestamp, error, and path). If a mobile app or frontend reads the error or path field, this breaks the API contract, so agree on it with the consumers before turning it on. Also note in the example above: the validation error returns detail: "Invalid request content." without listing the invalid fields; if you want a field list, write your own @RestControllerAdvice.

Hibernate 6, Actuator, and Security

  • Hibernate 6 changes how SQL is generated, the default id generation strategy (for example, an unspecified @GeneratedValue uses a per-entity sequence), and makes some HQL rules stricter. Test repositories against real data and read the generated DDL carefully; the use-new-id-generator-mappings property has been removed, as the report above shows.
  • Actuator: httptrace is renamed httpexchanges; /env and /configprops sanitize all values by default; only health is exposed over JMX.
  • Spring Security 6 applies to every dispatch type and removes WebSecurityConfigurerAdapter; you must switch to a SecurityFilterChain bean. Upgrade to Spring Security 5.8 first (while still on Boot 2.7) to see the warnings.

Observability in Spring Boot 3: declare once, get both metrics and traces

In Spring Boot 2.x, metrics used Micrometer, traces used Spring Cloud Sleuth, and logs used MDC: three separate systems. Spring Boot 3 unifies them with the Micrometer Observation API. According to the Micrometer documentation, your code only reports lifecycle events (start, stop, error) for an Observation, and the registered ObservationHandler instances decide whether to create a timer, a span, or a log line. Sleuth is replaced by Micrometer Tracing. According to the Spring Framework documentation, without an ObservationRegistry every observation is a no-op, so there is no cost until you turn it on.

The minimal setup to enable observability in Spring Boot 3 has four parts: spring-boot-starter-actuator (metrics), micrometer-tracing-bridge-brave (the trace bridge; you can swap in the OpenTelemetry bridge), spring-boot-starter-aop (for @Observed), and two lines of configuration. The ObservedAspect bean is required for @Observed to take effect, according to Spring's observability introduction:

package demo.sb3;

import io.micrometer.observation.ObservationRegistry;
import io.micrometer.observation.aop.ObservedAspect;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;

@SpringBootApplication
public class Application {
    public static void main(String[] args) { SpringApplication.run(Application.class, args); }

    // Required for @Observed to take effect (per the Micrometer/Spring Boot docs)
    @Bean
    ObservedAspect observedAspect(ObservationRegistry registry) { return new ObservedAspect(registry); }
}

Put @Observed on the business methods. The channel tag has a small, fixed set of values, so it is low cardinality and goes into the metric:


    OrderService(OrderRepository repo) { this.repo = repo; }

    @Observed(name = "order.create", contextualName = "create-order",
              lowCardinalityKeyValues = {"channel", "web"})
    public OrderEntity create(OrderEntity e) {
        OrderEntity saved = repo.save(e);
        log.info("Saved order {} for customer {}", saved.getId(), saved.getCustomer());
        return saved;
    }

    @Observed(name = "order.find")
    public OrderEntity find(long id) {
        return repo.findById(id).orElseThrow(() ->
            new ResponseStatusException(HttpStatus.NOT_FOUND, "No order " + id));
    }
}

The related configuration (the full file is at code/s6-25/src/main/resources/application.properties):

management.endpoints.web.exposure.include=health,metrics
management.tracing.sampling.probability=1.0
spring.mvc.problemdetails.enabled=true

Send two create-order requests: the first carries a traceparent header following the W3C Trace Context standard (simulating a calling service), and the second does not. The real results, excerpted from the application log and Actuator:

$ curl -X POST /orders  -H "traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
{"id":1,"customer":"Tran Thi B","amountVnd":99000}
$ curl /orders (no traceparent)
{"id":2,"customer":"Le Van C","amountVnd":1000}

$ application log
INFO 71438 --- [orders] [nio-8085-exec-2] [4bf92f3577b34da6a3ce929d0e0e4736-ba65d6544110ad60] demo.sb3.OrderService                    : Saved order 1 for customer Tran Thi B
INFO 71438 --- [orders] [nio-8085-exec-4] [6af1d9ab8bc9ecf52dfa6619852fcf6c-56a75f617e14897c] demo.sb3.OrderService                    : Saved order 2 for customer Le Van C

$ curl /actuator/metrics/order.create   # (shortened: COUNT, TOTAL_TIME, MAX and the tag names)
order.create {'COUNT': 2.0, 'TOTAL_TIME': 0.0299, 'MAX': 0.0286}
['method', 'channel', 'error', 'class']
$ curl /actuator/health
{"status":"UP"}

Three things are worth noticing. First, the OrderService log lines already carry the trace id and span id in square brackets, with no separate logging.pattern.level configuration. Second, the trace id for order 1 is 4bf92f3577b34da6a3ce929d0e0e4736, exactly the value in the incoming traceparent header, so the trace context is propagated across services; order 2 had no header, so Spring generated a new trace id. Third, the order.create metric has COUNT 2.0 and the tags method, channel, error, and class: one annotation produces both a timer and a span. This tag list comes from the unfiltered call; when you add ?tag=channel:web, Actuator drops channel from availableTags because that tag is already filtered, while COUNT is still 2.0.

When you need to ship traces out, add the matching reporter (Zipkin or OTLP) and Boot configures it automatically; this article stops at the application side. How to collect metrics into Prometheus and Grafana is covered in Kubernetes monitoring with kube-prometheus and Grafana, and centralized logs are in Logging on Kubernetes: from promtail and fluentd to Loki; the trace id in the log is the key for jumping from a log line to a trace.

Cardinality: the trap that overloads Prometheus

Micrometer distinguishes low cardinality tags (a finite set of values, such as method, status, channel) from high cardinality tags (unbounded, such as a user id or a full URL). Low cardinality tags go into both metrics and traces; high cardinality tags belong only in traces. If you put customerId into lowCardinalityKeyValues, every customer creates its own time series and Prometheus memory grows with the number of customers. One more note: Spring MVC uses the route template (/orders/{id}) as the uri tag, exactly like the uri list Actuator reported for us (/orders/{id}, not /orders/1), while the full http.url is high cardinality.

One more detail: an exception handled by @ExceptionHandler or ProblemDetail is not recorded in the HTTP observation automatically; for the error tag to be accurate, the Spring Framework documentation tells you to call ServerHttpObservationFilter.findObservationContext(request) and then setError.

When not to upgrade to Spring Boot 3 (or when you do not need to)

  • A dependency has no Jakarta release: an internal or third-party library that is no longer maintained and still uses javax. Replace it first, or split it into a separate process.
  • The application is about to be retired: if the system will be replaced within 6 months, the upgrade costs more than the risk; in that case, write down the shutdown date and block access from the internet.
  • No automated tests yet: upgrading now is a gamble. Write a few integration tests for the main flows first (placing an order, payment, login), then change versions.
  • An old Java EE application server (a WAR running on a Servlet 4 container): you need a container that supports Servlet 6, or you must move to an embedded jar.

Common mistakes when upgrading to Spring Boot 3

  • Forgetting ObservedAspect: @Observed raises no error but produces no metric either. Check /actuator/metrics to see whether the observation name appears.
  • Missing spring-boot-starter-aop: the aspect is never created, with the same result as above.
  • Putting a high cardinality tag at the low level: it makes the number of time series explode.
  • Fixing javax with sed across the whole project: it breaks javax.crypto and javax.sql.
  • Enabling ProblemDetail without telling clients: the error body changes shape.
  • Leaving spring-boot-properties-migrator in the release build.
  • Old Mockito/JUnit tests: JUnit 4 is no longer supported by the default test starter; you need to move to JUnit 5.

Spring Boot 3 upgrade checklist

  • Before moving to Spring Boot 3, you are on the latest 2.7.x, with no deprecation warnings and green tests.
  • JDK 17 or 21 on the build machine, in CI, and in the runtime container.
  • mvn dependency:tree shows no library pulling in javax.persistence, javax.servlet, or javax.validation.
  • On Spring Boot 3, you have run the properties migrator, handled each key, and then removed the migrator.
  • You have checked trailing slash, the error format, and the SQL and DDL generated by Hibernate 6.
  • /actuator/health still returns UP (for readiness probe configuration see Health endpoint for Laravel on Kubernetes: what to check and which probe to use; the same principle applies to Spring).
  • The Spring Boot 3 application has business metrics annotated with @Observed using low cardinality tags, and the logs carry a trace id.
  • You have a plan to take minor upgrades following the 6-month support schedule.

In short, upgrading to Spring Boot 3 is not hard because of any single change, but because the changes pile up at once. Follow the order (2.7.x, Java 17, Jakarta, properties, behavior, observability), let tools detect what they can, and use integration tests for the rest.

Next article

Once the application runs on the new baseline and can be observed, the next question is whether the data model still fits when reads vastly outnumber writes. Continue with CQRS and Event Sourcing in Java: when they are worth it.