Blog

We upgraded Spring Boot 3.5 to 4.1. Every test passed. The API clients broke.

Spring Boot 3.5's open source support ended on 30 June 2026, and 4.0's ends on 31 December. A lot of teams are planning the jump to 4.x now, and most plans end the same way: run the automated upgrade, fix what doesn't compile, and ship when the tests are green.

We wanted to know what "green" leaves out, so on 4 November 2026 we did exactly that to Spring PetClinic's REST API and compared how the API behaved before and after.

What we did

We took PetClinic REST at Spring Boot 3.5.7 and ran OpenRewrite, a free tool that rewrites code automatically, with its Spring Boot upgrade recipe (a packaged set of changes). The free recipes go up to 4.0, so we applied that and then moved the version to 4.1.2. Three things didn't compile afterwards, and we fixed each one the smallest way we could:

  • The generated API classes still imported javax.validation.
  • One class imported a Hibernate 5 type that no longer exists, in a Javadoc comment.
  • One call used a Jackson builder method that isn't in Jackson 3.

Then the project's own test suite: 216 tests, 0 failures.

At that point most upgrades are done. We kept going and sent the same 59 requests to the old and new versions: every endpoint, plus the cases upgrades tend to move without anyone noticing, such as validation failures, malformed JSON, the wrong content type, unknown paths and a resource that doesn't exist. We recorded the status, headers and raw body of each, and did the same for the PetClinic team's own hand migration to 4.0 as a second reference.

What stayed the same

All 59 status codes were identical, in both the automated and the hand-migrated version. Every value in every response was the same. If you only check that the API still answers correctly, this upgrade passes.

What changed

The published API contract. The OpenAPI document the app serves at /v3/api-docs changed in eight places. Seven fields and one query parameter, including Owner.id, Visit.date and User.enabled, went from a plain type to "this type or null". The hand migration did not change them.

As far as we can tell, the cause is one line the recipe changed in the code generator's settings. In its Spring Boot 4 mode, the generator marks optional fields with JSpecify's @Nullable instead of Spring's older one, and springdoc 3 publishes JSpecify's as "can be null". The generated classes do show the different annotation; we did not test the chain step by step.

Field order. 23 responses came back with the same values in a different order. Error responses are now alphabetical, and in owners and pets the id moved to the end. JSON doesn't care, but snapshot tests, signed payloads and anything that compares raw text do.

Smaller things. Error messages for unknown paths and bad dates are worded differently, a Vary header disappeared from 404-style responses, and the health endpoint now lists its probe groups.

Does the contract change matter?

We checked. We generated TypeScript types from the old and new API documents and compiled the same short client against each, written the way a lot of front-end code is: helpers that take optional values.

Against the 3.5 types it compiled. Against the 4.1 types, three errors:

Type 'null' is not assignable to type 'number | undefined'.

and the same for a string and a boolean.

Running clients are fine, because the server still sends the same values. The break comes later, the next time a front-end or partner team regenerates its client from your API document. By then the backend upgrade has shipped, its tests were green, and nobody connects the two.

What this means for your upgrade

The automated recipe did most of the work here, and the three compile fixes were small. The tests did their job too: they check what the code does, and that didn't change. What they don't check is what the service promises to the people who call it. That covers the published contract, the error bodies, the headers and the order clients see things in.

Before you call a Spring Boot 4 upgrade done, compare the old and new versions from the outside:

  • the OpenAPI document, field by field, not just whether it loads
  • error responses for bad input, not just the happy path
  • headers your gateway, cache or clients depend on
  • anything a client generates code from

That check covers what changed. A Spring Boot 4 upgrade can also drop things silently: if you add a library like Flyway directly, without its new Spring Boot module, its auto-configuration stops running and the tests still pass. We published a free check for that: boot4-guard.

PetClinic is small. A real service has more endpoints, more libraries with their own upgrade defaults and more clients, and every one of those is a place for the same kind of change.

If you want this check run against your own service before or after your upgrade, we do that. Get in touch.