Spring Boot : Dynamic Polymorphic Deserialization in using Database Lookups

Polymorphic deserialization in Spring Boot is usually straightforward. You annotate your parent class with @JsonTypeInfo, specify a property like "type", and Jackson handles the rest. But I recently encountered a scenario where the JSON didn't contain any type information.

The incoming JSON looked like this:

{
  "datasetId": "123-abc",
  "payload": "..."
}

To determine whether this was a SQL Dataset or a CSV Dataset, I had to take that datasetId, query a metadata service (or database), and then decide which Java class to instantiate. Since standard Jackson annotations can't access the Spring Service layer, I had to write a custom deserializer.

The Scenario

In a recent Data Ingestion API project, the endpoint accepted a generic payload, but the processing logic varied based on the dataset type.

I wanted the Controller to look clean like this:

@PostMapping("/process")
public ResponseEntity<String> process(@RequestBody BaseDatasetRequest request) {
    if (request instanceof SqlDatasetRequest) {
        // handle SQL
    } else if (request instanceof CsvDatasetRequest) {
        // handle CSV
    }
}

Standard solutions like @JsonTypeInfo failed because we didn't have a discriminator field. Manual parsing felt dirty and it would bypass Spring's validation pipeline as well.

The Solution

Spring Boot provides @JsonComponent, which registers a class as a Jackson deserializer and treats it as a Spring Bean. This allows us to inject services directly into the deserializer.

The Models

We have a parent class and two concrete implementations:

public abstract class BaseDatasetRequest {
    private String datasetId;
    // getters/setters
}

// Concrete Class A
public class SqlDatasetRequest extends BaseDatasetRequest {
    private String connectionString;
}

// Concrete Class B
public class CsvDatasetRequest extends BaseDatasetRequest {
    private String s3BucketUrl;
}

Custom Deserializer

I registered a deserializer for BaseDatasetRequest and injected the MetadataService into the constructor.

@JsonComponent
public class DatasetRequestDeserializer extends JsonDeserializer<BaseDatasetRequest> {

    private final DatasetMetadataService metadataService;

    @Autowired
    public DatasetRequestDeserializer(DatasetMetadataService metadataService) {
        this.metadataService = metadataService;
    }

    @Override
    public BaseDatasetRequest deserialize(JsonParser p, DeserializationContext ctxt) 
            throws IOException {
        
        ObjectCodec codec = p.getCodec();
        // 1. Read the JSON into a tree
        JsonNode node = codec.readTree(p);

        // 2. Extract ID and lookup Type in the DB
        String id = node.get("datasetId").asText();
        DatasetType type = metadataService.getDatasetType(id);

        // 3. Determine the target class
        Class<? extends BaseDatasetRequest> targetClass;
        if (type == DatasetType.SQL) {
            targetClass = SqlDatasetRequest.class;
        } else {
            targetClass = CsvDatasetRequest.class;
        }

        // 4. Convert the tree to the specific class
        return codec.treeToValue(node, targetClass);
    }
}

Infinite Recursion Issue

The code above compiles, but it causes a StackOverflowError at runtime. When codec.treeToValue tries to deserialize the specific subclass, Jackson looks for a deserializer. Since the subclass doesn't have one, it falls back to the parent's deserializer—the one we just wrote—creating an infinite loop.

The Fix

To solve this, we need to explicitly tell Jackson to use the default deserializer for the subclasses. We can do this using the @JsonDeserialize annotation.

import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.JsonDeserializer;

@JsonDeserialize(using = JsonDeserializer.None.class)
public class SqlDatasetRequest extends BaseDatasetRequest {
    // ...
}

@JsonDeserialize(using = JsonDeserializer.None.class)
public class CsvDatasetRequest extends BaseDatasetRequest {
    // ...
}

Even though JsonDeserializer.None.class is the default, adding the annotation explicitly stops Jackson from walking up the inheritance hierarchy to find the custom deserializer.

Conclusion

By combining @JsonComponent for dependency injection and explicit @JsonDeserialize annotations, we can create the proper object at runtime. Adding a new dataset type now only requires updating the Service and the Deserializer, rather than touching every controller.