jackson-databind icon indicating copy to clipboard operation
jackson-databind copied to clipboard

Deserialization fails with version after 2.17.3 when DTO doesn't have no-args constructor

Open oleksiimiroshnyk opened this issue 2 months ago • 10 comments

Search before asking

  • [x] I searched in the issues and found nothing similar.

Describe the bug

Looks like DTO without no-ars constructor is no longer able to to used for desirialization.

I'm upgrading my spring boot app and it seems jackson is also upgraded from 2.17.3 to 2.18+ My DTO/Bean looks like this:

public class Foo {
    private final String id;
    private String name;

!!!! NO DEFAULT CONSTRUCTOR !!!!!

    public Foo(String id) {
        this.id = id;
    }
    public Foo(String id, String name) {
        this.id = id;
        this.name = name;
    }
------------------- getters/setters
}

Here it is the isolated code that demonstrates the issue. It works fine with 2.17.3 but doesn't with anything beyond that (2.18, 2.19, 2.20)

        JsonMapper objectMapper = JsonMapper.builder()
                .constructorDetector(ConstructorDetector.DEFAULT).build();
        objectMapper.registerModule(new ParameterNamesModule());
        objectMapper.configure(FAIL_ON_UNKNOWN_PROPERTIES, false);

        Foo foo = objectMapper.readValue("{}", Foo.class);

It also fails to deserialize

        String content = objectMapper.writeValueAsString(new Foo("blah", "qqq"));
        Foo foo1 = objectMapper.readValue(content, Foo.class);

The exception is the following:

Exception in thread "main" com.fasterxml.jackson.databind.exc.MismatchedInputException: Cannot construct instance of `org.example.Foo` (although at least one Creator exists): cannot deserialize from Object value (no delegate- or property-based Creator)
 at [Source: REDACTED (`StreamReadFeature.INCLUDE_SOURCE_IN_LOCATION` disabled); line: 1, column: 2]
	at com.fasterxml.jackson.databind.exc.MismatchedInputException.from(MismatchedInputException.java:63)
	at com.fasterxml.jackson.databind.DeserializationContext.reportInputMismatch(DeserializationContext.java:1754)
	at com.fasterxml.jackson.databind.DeserializationContext.handleMissingInstantiator(DeserializationContext.java:1379)
	at com.fasterxml.jackson.databind.deser.BeanDeserializerBase.deserializeFromObjectUsingNonDefault(BeanDeserializerBase.java:1512)
	at com.fasterxml.jackson.databind.deser.BeanDeserializer.deserializeFromObject(BeanDeserializer.java:348)
	at com.fasterxml.jackson.databind.deser.BeanDeserializer.deserialize(BeanDeserializer.java:185)
	at com.fasterxml.jackson.databind.deser.DefaultDeserializationContext.readRootValue(DefaultDeserializationContext.java:342)
	at com.fasterxml.jackson.databind.ObjectMapper._readMapAndClose(ObjectMapper.java:4931)

```

If I add no-args constructor it works. But in my application modifying DTO/Bean is not always possibe.

Is this expected ? I can't find the docs explaining why it should not work.

### Version Information

2.18.3

### Reproduction

My DTO/Bean looks like this:
```java
public class Foo {
    private final String id;
    private String name;

!!!! NO DEFAULT CONSTRUCTOR !!!!!

    public Foo(String id) {
        this.id = id;
    }
    public Foo(String id, String name) {
        this.id = id;
        this.name = name;
    }
------------------- getters/setters
}
```

Here it is the isolated code that demonstrates the issue. It works fine with 2.17.3 but doesn't with anything beyond that (2.18, 2.19, 2.20)
```java
        JsonMapper objectMapper = JsonMapper.builder()
                .constructorDetector(ConstructorDetector.DEFAULT).build();
        objectMapper.registerModule(new ParameterNamesModule());
        objectMapper.configure(FAIL_ON_UNKNOWN_PROPERTIES, false);

        Foo foo = objectMapper.readValue("{}", Foo.class);
```
It also fails to deserialize 
```
        String content = objectMapper.writeValueAsString(new Foo("blah", "qqq"));
        Foo foo1 = objectMapper.readValue(content, Foo.class);
```

### Expected behavior

_No response_

### Additional context

_No response_

oleksiimiroshnyk avatar Oct 03 '25 07:10 oleksiimiroshnyk

Seems to have been affected by https://github.com/FasterXML/jackson-databind/issues/4515. Have you found work around yet @oleksiimiroshnyk

JooHyukKim avatar Oct 03 '25 12:10 JooHyukKim

Adding @JsonCreator does the trick in the test. The issue is that it is not always possible to do that in source code. In theory I can do instrumentation and it on fly. But I would prefer if this continue working without need of any changes.

oleksiimiroshnyk avatar Oct 03 '25 12:10 oleksiimiroshnyk

So you haven't found one yet.

JooHyukKim avatar Oct 03 '25 12:10 JooHyukKim

Here is Jackson-friendly reproduction that indeed works in 2.17 but fails starting 2.18. Although I am not familiar with configurations around constructor. One thing for sure is, we need Mapper-configuration, to meet your requirements (of not touching DTO's)

package com.fasterxml.jackson.databind;

import com.fasterxml.jackson.databind.cfg.ConstructorDetector;
import com.fasterxml.jackson.databind.json.JsonMapper;
import com.fasterxml.jackson.databind.testutil.DatabindTestUtil;
import org.junit.jupiter.api.Test;

import static com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES;

public class JacksonTest extends DatabindTestUtil {
    public static class Foo {
        private final String id;
        private String name;

        public Foo(@ImplicitName("id") String id) {
            this.id = id;
        }

        public Foo(@ImplicitName("id") String id, @ImplicitName("name") String name) {
            this.id = id;
            this.name = name;
        }

        public String getId() {
            return id;
        }

        public String getName() {
            return name;
        }

        public void setName(String name) {
            this.name = name;
        }
    }

    @Test
    public void testFoo()
            throws Exception {
        JsonMapper objectMapper = JsonMapper.builder()
                .annotationIntrospector(new ImplicitNameIntrospector())
                .constructorDetector(ConstructorDetector.USE_PROPERTIES_BASED).build();
        objectMapper.configure(FAIL_ON_UNKNOWN_PROPERTIES, false);


        String content = objectMapper.writeValueAsString(new Foo("blah", "qqq"));

        Foo foo1 = objectMapper.readValue(content, Foo.class);

    }

}

JooHyukKim avatar Oct 03 '25 13:10 JooHyukKim

I think setup in the test reflects what we have and what spring boot configures OOB

.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.disable(MapperFeature.DEFAULT_VIEW_INCLUSION);
.registerModule(new JavaTimeModule());
.registerModule(new ParameterNamesModule());
.registerModule(new Jdk8Module());

I can change objectMapper setup if needed, like a new feature etc...

oleksiimiroshnyk avatar Oct 03 '25 13:10 oleksiimiroshnyk

I can change objectMapper setup if needed, like a new feature etc...

Sadly that is one thing that I do not know :-/

  • What to configure
  • Or the possibility of it

@cowtowncoder might?

JooHyukKim avatar Oct 03 '25 14:10 JooHyukKim

Any chance that the final field mutator config mentioned in #5291 might help as a workaround?

Jackson not being able to find the public Foo(String id, String name) constructor in @oleksiimiroshnyk 's example does seem like a bug to me though.

public class Foo {
    private final String id;
    private String name;

!!!! NO DEFAULT CONSTRUCTOR !!!!!

    public Foo(String id) {
        this.id = id;
    }
    public Foo(String id, String name) {
        this.id = id;
        this.name = name;
    }

pjfanning avatar Oct 03 '25 14:10 pjfanning

Ok, given example should NOT have worked as is. So unfortunately 2.18+ behavior seems as-intended. So: although Jackson does allow Creator auto-detection in some cases, it should not work here because there are 2 alternatives: this is not allowed (i.e. Jackson will not try to guess what to use). (this is assuming parameter names would be available with jackson-module-parameter-names).

To make this work in 2.18+ something needs to be added:

  1. Mark one of constructors with @JsonIgnore (leaving one to be used) OR
  2. Mark intended constructor with @JsonCreator OR
  3. Add default constructor and rely on MapperFeature.ALLOW_FINAL_FIELDS_AS_MUTATORS to use Fields for binding values.

Behavior in 2.17.3 or before is odd; I don't know why Creator auto-detection would select one of constructors. That seems like a bug (unfortunate one). But intended rules are clear.

... speculating a bit; I think this:

public Foo(String id) { ... }

may have been detected as legacy delegating Creator (that is, binding from JSON String, not Object). Leaving just one visible (public) constructor to auto-detect.

Be that as it may, the new behavior is the intended one and I don't think it should be changed.

cowtowncoder avatar Oct 03 '25 15:10 cowtowncoder

@cowtowncoder jackson-module-scala works pretty well without generally requiring JsonCreator annotations. One thing that works fairly well for me in jackson-module-scala's introspection code is defaulting to the constructor with the most params. I'm sure this can cause its own problems but it seems to work fairly well in jackson-module-scala. Going forward, I would expect Java records to eventually predominate for serde use cases and if we can at least support records where you rarely need to specify JsonCreator annotations that would be great.

pjfanning avatar Oct 03 '25 17:10 pjfanning

Based on historical issues, my tolerance for logic changes in this area is bit limited. There are complexities with equal number of parameters etc.

However, if this was added as an opt-in MapperFeature that could work (earliest in 3.1)?

So maybe worth filing an RFE issue with clear ask.

cowtowncoder avatar Oct 03 '25 17:10 cowtowncoder