Inconsistent behaviour with package private constructors in Jackson 3
Search before asking
- [x] I searched in the issues and found nothing similar.
Describe the bug
Given
package x.y;
public class ThreeValuedPackagePrivateCtor {
private final String a;
private final int b;
private final Double c;
ThreeValuedPackagePrivateCtor(String a, int b, Double c) {
this.a = a;
this.b = b;
this.c = c;
}
public String getA() {
return a;
}
public int getB() {
return b;
}
public Double getC() {
return c;
}
}
I can map this with Jackson 2.20 from another package as this:
@Test
void shouldDeserialize() throws Exception {
var json = """
{
"a": "A String",
"b": 1,
"c": 23.42
}
""";
var om = new ObjectMapper();
om.registerModule(new ParameterNamesModule());
var z = om.readValue(json, ThreeValuedPackagePrivateCtor.class);
assertThat(z.getA()).isEqualTo("A String");
assertThat(z.getB()).isEqualTo(1);
assertThat(z.getC()).isEqualTo(23.42);
}
Jackson 3 in the same setup fails with
tools.jackson.databind.exc.InvalidDefinitionException: Cannot construct instance of `x.y.ThreeValuedPackagePrivateCtor` (no Creators, like default constructor, exist): cannot deserialize from Object value (no delegate- or property-based Creator)
at [Source: REDACTED (`StreamReadFeature.INCLUDE_SOURCE_IN_LOCATION` disabled); byte offset: #UNKNOWN]
Throwing in a bunch of features
@Test
void shouldDeserialize() {
var json = """
{
"a": "A String",
"b": 1,
"c": 23.42
}
""";
var om = JsonMapper.builder()
.enable(MapperFeature.ALLOW_FINAL_FIELDS_AS_MUTATORS)
.enable(MapperFeature.CAN_OVERRIDE_ACCESS_MODIFIERS)
.enable(MapperFeature.OVERRIDE_PUBLIC_ACCESS_MODIFIERS)
.build();
var z = om.readValue(json, ThreeValuedPackagePrivateCtor.class);
assertThat(z.getA()).isEqualTo("A String");
assertThat(z.getB()).isEqualTo(1);
assertThat(z.getC()).isEqualTo(23.42);
}
does not help.
I also tried various variants of .constructorDetector() without success.
Looked at those tickets / changes already:
https://github.com/FasterXML/jackson-databind/issues/5246 https://github.com/FasterXML/jackson-databind/pull/5308 https://github.com/FasterXML/jackson-databind/issues/5318
I was able to fix my problem with @JsonCreator (original problem is reading Spring Boot Metrics endpoints, see https://github.com/neo4j/neo4j-jdbc/blob/76201e59488bd2f6fe5031b1c76500f5cfedb915/neo4j-jdbc-it/spring-boot-smoke-tests/src/test/java/org/neo4j/jdbc/it/sb/ApplicationIT.java#L87, for which I know have this… https://github.com/neo4j/neo4j-jdbc/blob/76201e59488bd2f6fe5031b1c76500f5cfedb915/neo4j-jdbc-it/spring-boot-smoke-tests/src/test/java/org/neo4j/jdbc/it/sb/ApplicationIT.java#L121 which is fine for me, but I'm sure this will cause issue somewhere else. The metrics data classes are the same like the ones above: Package private, non default constructor, no other constructor )
Version Information
Working: Jackson 2.20.1, Failing Jackson 3.0.2. Plain Java 25, no Framework. See attached reproducer:
Reproduction
See reproducer, 2nd commit.
Expected behavior
See reproducer, 1st commit.
Additional context
Funny enough, quite similar class, like this:
package x.y;
public class OneValuedPackagePrivateCtor {
private final String a;
OneValuedPackagePrivateCtor(String a) {
this.a = a;
}
public String getA() {
return a;
}
}
will map just fine.
I suspect the option that matters is Visibility for CREATORS. By default only public constructors should be auto-detectable so not sure how 2.x would detect (it really shouldn't).
I don't remember off-hand method in JsonMapper.builder() for changing visibility; let me know if you can't locate it and I'll go find it. But that should allow auto-detection.
Ok, this is not obvious:
var json = """
{
"a": "A String",
"b": 1,
"c": 23.42
}
""";
var om = JsonMapper.builder()
.changeDefaultVisibility(i -> i.withCreatorVisibility(JsonAutoDetect.Visibility.NON_PRIVATE))
.build();
var z = om.readValue(json, ThreeValuedPackagePrivateCtor.class);
assertThat(z.getA()).isEqualTo("A String");
assertThat(z.getB()).isEqualTo(1);
assertThat(z.getC()).isEqualTo(23.42);
But that works.
However, the inconsistency still holds (it works without that for a ctor taking only one argument), and in Jackson 2 it worked without the checker.
This (multi-argument Constructor) should not have worked in 2.x, as far I can see. I'd typically expect @JsonCreator for such case, although Visibility is an alternative.
So different behavior between 2.x and 3.x in itself is not necessarily considered bug.
Hi, could I add to the case? for org.springframework.boot.actuate.audit.AuditEvent
@Test
void deserialize() {
String json = """
{
"data":{
"keyA":"ValueA",
"KeyB":"ValueB"
},
"principal":"user",
"timestamp":"2025-12-18T14:40:33.333719Z",
"type":"type"
}
""";
AuditEvent auditEvent = jsonMapper.readValue(json, AuditEvent.class);
Assertions.assertNotNull(auditEvent);
Assertions.assertEquals("user", auditEvent.getPrincipal());
Assertions.assertEquals("type", auditEvent.getType());
Assertions.assertEquals(1, auditEvent.getData().size());
}
Result:
tools.jackson.databind.exc.InvalidDefinitionException: Cannot construct instance of org.springframework.boot.actuate.audit.AuditEvent (no Creators, like default constructor, exist): cannot deserialize from Object value (no delegate- or property-based Creator)
at [Source: REDACTED (StreamReadFeature.INCLUDE_SOURCE_IN_LOCATION disabled); byte offset: #UNKNOWN]
at tools.jackson.databind.exc.InvalidDefinitionException.from(InvalidDefinitionException.java:70)
at tools.jackson.databind.DeserializationContext.reportBadDefinition(DeserializationContext.java:1954)
at tools.jackson.databind.DatabindContext.reportBadDefinition(DatabindContext.java:448)
at tools.jackson.databind.DeserializationContext.handleMissingInstantiator(DeserializationContext.java:1484)
at tools.jackson.databind.deser.bean.BeanDeserializerBase.deserializeFromObjectUsingNonDefault(BeanDeserializerBase.java:1432)
at tools.jackson.databind.deser.bean.BeanDeserializer.deserializeFromObject(BeanDeserializer.java:480)
at tools.jackson.databind.deser.bean.BeanDeserializer.deserialize(BeanDeserializer.java:200)
at tools.jackson.databind.deser.DeserializationContextExt.readRootValue(DeserializationContextExt.java:265)