TL;DR

Jackson 3 changed the default creator visibility from ANY to PUBLIC_ONLY. Lombok’s @Builder generates a package-private all-args constructor, so a class annotated only with @Data @Builder has no constructor Jackson can use anymore. To fix it, add this to a lombok.config at the root of the repository:

config.stopBubbling = true
lombok.anyConstructor.addConstructorProperties = true

Then run a clean build, as an incremental Gradle build may not pick up the new lombok.config.

Full Tale

A while after we upgraded our services to Spring Boot 4, one service started failing some requests with this error when decoding a response from another service:

feign.codec.DecodeException: Type definition error: [simple type, class Balance]
...
Cannot construct instance of `Balance` (no Creators, like default constructor, exist)

The weird part was that it was not failing for every request. Most of them were working fine, so it did not look like an upgrade problem at first.

The class was very simple:

@Data
@Builder
public class Balance implements Serializable {
    ...
}

As it turns out, Spring Boot 4 moves from Jackson 2 to Jackson 3, and Jackson 3 changed the default visibility for creators. I could not find it in the Jackson 3.0 release notes, but the Jackson 3 VisibilityChecker source code has a note about it:

Visibility.PUBLIC_ONLY, // creator -- NOTE: was `ANY` for 2.x
Jackson 3 VisibilityChecker source on GitHub showing the default creator visibility set to PUBLIC_ONLY with a note that it was ANY in 2.x

Default creator visibility in Jackson 3

With @Data @Builder and no other constructor annotation, Lombok generates only one constructor, an all-args constructor that is package-private. The Lombok @Builder docs say it is as if you added @AllArgsConstructor(access = AccessLevel.PACKAGE) to the class. Jackson 2 was happy to use it because the creator visibility was ANY. Jackson 3 only auto-detects public constructors, so as far as Jackson is concerned, the class cannot be created.

The class holding the map had @NoArgsConstructor @AllArgsConstructor and deserialized fine, which is why the error pointed only at Balance.

Why Only Some Requests Failed

Jackson only complains when it actually needs to create an instance of the class. Balance was the value of a Map<String, Balance> field, so:

PayloadResult
balances absentOK
"balances": nullOK
"balances": {}OK
"balances": {"currentDay": {...}}Fails

Only the responses with balance data failed. That is why it did not show up right after the deployment and looked random.

And the funny part of this story: the balances field turned out to be dead code. It was never read anywhere in the service, so the fix for the incident itself was to delete the field and the class. Jackson 3 ignores unknown properties by default, so the field that still comes in the response is just skipped.

How to Fix It

There are a few options.

1. Add the constructor annotations to the class. This is the smallest fix, and the one that matches the classes that were already working:

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Balance implements Serializable {
    ...
}

2. Change the creator visibility on the object mapper back to the Jackson 2 behavior:

.changeDefaultVisibility(vc -> vc.withCreatorVisibility(JsonAutoDetect.Visibility.ANY))

The problem is that it only applies to that mapper. In our case, the Feign clients had their own mapper, separate from the one Spring configures, so we would need it in more than one place.

3. Add a lombok.config. This is what we did to prevent it in every service:

config.stopBubbling = true
lombok.anyConstructor.addConstructorProperties = true

It makes Lombok add @java.beans.ConstructorProperties to the constructors it generates. Jackson treats that as an explicit creator (MapperFeature.INFER_CREATOR_FROM_CONSTRUCTOR_PROPERTIES, enabled by default), so the visibility rule does not apply anymore, and the constructor can stay package-private. It fixes all the Lombok classes in the repository at once.

Interestingly, this is why our shared libraries were never affected: they already had this setting in their lombok.config. The service that broke had no lombok.config at all.

One gotcha: in our case, Gradle did not recompile when we added the lombok.config, as it is not a compile input, so the incremental build did not pick it up. If you add it and nothing changes, run ./gradlew clean build before concluding it did not work.

Also, @Value is not affected, as it generates a public all-args constructor.

Checking the Other Services

Since we upgraded many services, I wanted to know if we had missed this anywhere else. Searching for @Builder classes without a constructor annotation gives a lot of results, but most of them are not a problem: classes that are only serialized (built and sent) do not need a creator. Only the classes that Jackson reads back are at risk, and only in services without the lombok.config setting.

I opened PRs adding the lombok.config to the services that did not have it, so the next DTO with only @Data @Builder does not fail in production.

In another tale, I wrote about how Spring Boot 3.3.1 broke our Jackson object mapper. Before the upgrade, I wrote in my Spring Boot 4 migration analysis that Jackson 3 was the change that worried me the most. It turns out I was right. Jackson upgrades are the gift that keeps on giving.

Cheers.