Development

10 min read

Flexible Polymorphic Deserialization with Spring and Jackson without @JsonTypeInfo

Sam

Written by Sam

Published on Nov 18, 2024

Java Code

Who should read this?

Any Spring application developer who wants to support polymorphic deserialization without hard-coding @JsonTypeInfo and @JsonSubTypes annotations on their classes.

Motivation

Broadleaf previously announced that we had joined TMForum as a member, and recently, we were awarded OpenAPI certifications for implementing various TMForum APIs (more on that here). 

Schemas in the TMForum APIs have a @type field, where an API caller can declare a well-known 'type' to which their request payload structure conforms. The backend is expected to use this type for polymorphic deserialization.

Typically, when using the popular Jackson library, polymorphic deserialization is implemented through the @JsonTypeInfo annotation. Baeldung has an excellent article that provides an overview of this approach if you are unfamiliar with it. 

Convenient as it is, a significant drawback of this approach is that it requires hard-coding the type and subtype information at the class level. The Broadleaf framework is designed with extensibility and customization as a top priority, so during our TMForum implementation efforts, we developed a much more flexible pattern for this problem, which we'll share with you below.

Our solution leverages the power of Spring beans to eliminate hard-coded configuration in favor of dynamic components that are much more maintainable and customizable.

Tutorial

This tutorial will use the CharacteristicValueSpecification from TMForum's TMF 620 Product Catalog Management API as an example. In TMF, this class contains many subtypes, but for our example, we will consider BooleanCharacteristicValueSpecification, NumberCharacteristicValueSpecification, and StringCharacteristicValueSpecification. We expect that when we get a @type value that matches one of those names, we will deserialize to the corresponding type.

If you want to jump past the setup and skip to the solution, click here.

Payload Structure

Below, we can see the payload structure (note that we use Lombok annotations for readability):

/**
 * A lightweight copy of TMF's CharacteristicValueSpecification class.
 */
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public class CharacteristicValueSpecification {

	/**
 	* When sub-classing, this defines the sub-class extensible name
 	*/
	@JsonProperty("@type")
	private String _atType = null;

	/**
 	* If true, the Boolean indicates if the value is the default value for a characteristic
 	*/
	@JsonProperty("isDefault")
	private Boolean isDefault = null;

}
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
public class BooleanCharacteristicValueSpecification extends CharacteristicValueSpecification {
	/**
 	* Value of the characteristic
 	*/
	@JsonProperty("value")
	private Boolean value = null;
}
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
public class NumberCharacteristicValueSpecification extends CharacteristicValueSpecification {
	/**
 	* Value of the characteristic
 	*/
	@JsonProperty("value")
	private BigDecimal value = null;
}
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
public class StringCharacteristicValueSpecification extends CharacteristicValueSpecification {
	/**
 	* Value of the characteristic
 	*/
	@JsonProperty("value")
	private String value = null;
}

In TMF, CharacteristicValueSpecification instances are held inside a CharacteristicSpecification:

/**
 * A lightweight copy of TMF's CharacteristicSpecification
 */
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public class CharacteristicSpecification {

	@JsonProperty("id")
	private String id = null;

	@JsonProperty("name")
	private String name = null;

	@JsonProperty("characteristicValueSpecification")
	private List<CharacteristicValueSpecification> characteristicValueSpecification = null;
}

Ultimately, we expect that when a CharacteristicSpecification payload comes in, we perform polymorphic deserialization on CharacteristicSpecification.characteristicValueSpecification such that each element is instantiated to the appropriate subtype of CharacteristicValueSpecification.

Endpoint and Service

For our example, let's expose an API to create a CharacteristicSpecification. To achieve this, we'll introduce a controller and a service. The service is just a stub; we will rely on mocks for testing. 

(Remember to register these as Spring beans!)

@RestController
public class CharacteristicSpecificationEndpoint {

	@Autowired
	private CharacteristicSpecificationService characteristicSpecificationService;

	@PostMapping(path = "/characteristic-specifications",
        	consumes = MediaType.APPLICATION_JSON_VALUE)
	public void postCharacteristicSpecification(@RequestBody CharacteristicSpecification characteristicSpecification) {
    	characteristicSpecificationService.createCharacteristicSpecification(characteristicSpecification);
	}
}
public class CharacteristicSpecificationService {

	public void createCharacteristicSpecification(CharacteristicSpecification characteristicSpecification) {
    	// do nothing by default
	}
}

Integration Tests

As attached below, we will set up an integration test to verify the behavior is implemented correctly.

The idea is to submit a JSON payload to the endpoint via MockMvc. The controller will deserialize the request body and pass the result to our service component. We use 'MockBean' to mock out the service component so that we can capture the deserialized argument and verify its state.

Since we have yet to add the actual polymorphic support, the tests will fail for now.

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.tuple;
import static org.mockito.Mockito.verify;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@SpringBootTest
@AutoConfigureMockMvc
class PolymorphicDeserializationIT {

	@MockBean
	private CharacteristicSpecificationService charSpecServiceMock;

	@Autowired
	private MockMvc mockMvc;

	@Test
	void deserializesToString() throws Exception {
    	String inputJson = """
            	{
              	"id": "charSpecIdVal",
              	"name": "charSpecNameVal",
              	"characteristicValueSpecification": [
                	{
                  	"@type": "StringCharacteristicValueSpecification",
                  	"value": "stringVal1"
                	},
                	{
                  	"@type": "StringCharacteristicValueSpecification",
                  	"value": "stringVal2",
                  	"isDefault": true
                	}
              	]
            	}
            	""";

    	performPostCharSpecRequest(inputJson).andExpect(status().isOk());
    	CharacteristicSpecification capturedPayload = getCapturedDeserializedPayload();
    	assertThat(capturedPayload)
            	.extracting(CharacteristicSpecification::getId, CharacteristicSpecification::getName)
            	.contains("charSpecIdVal", "charSpecNameVal");
    	assertThat(capturedPayload.getCharacteristicValueSpecification())
            	.allMatch(valueSpec -> valueSpec instanceof StringCharacteristicValueSpecification)
            	.allMatch(valueSpec -> "StringCharacteristicValueSpecification".equals(valueSpec.get_atType()))
            	.extracting(CharacteristicValueSpecification::getIsDefault,
                    	valueSpec -> ((StringCharacteristicValueSpecification) valueSpec).getValue())
            	.containsExactlyInAnyOrder(
                    	tuple(null, "stringVal1"),
                    	tuple(true, "stringVal2"));
	}

	@Test
	void deserializesToNumber() throws Exception {
    	String inputJson = """
            	{
              	"id": "charSpecIdVal",
              	"name": "charSpecNameVal",
              	"characteristicValueSpecification": [
                	{
                  	"@type": "NumberCharacteristicValueSpecification",
                  	"value": 123
                	},
                	{
                  	"@type": "NumberCharacteristicValueSpecification",
                  	"value": 456,
                  	"isDefault": true
                	},
                	{
                  	"@type": "NumberCharacteristicValueSpecification",
                  	"value": 789.123
                	}
              	]
            	}
            	""";

    	performPostCharSpecRequest(inputJson).andExpect(status().isOk());
    	CharacteristicSpecification capturedPayload = getCapturedDeserializedPayload();
    	assertThat(capturedPayload)
            	.extracting(CharacteristicSpecification::getId, CharacteristicSpecification::getName)
            	.contains("charSpecIdVal", "charSpecNameVal");
    	assertThat(capturedPayload.getCharacteristicValueSpecification())
            	.allMatch(valueSpec -> valueSpec instanceof NumberCharacteristicValueSpecification)
            	.allMatch(valueSpec -> "NumberCharacteristicValueSpecification".equals(valueSpec.get_atType()))
            	.extracting(CharacteristicValueSpecification::getIsDefault,
                    	valueSpec -> ((NumberCharacteristicValueSpecification) valueSpec).getValue())
            	.containsExactlyInAnyOrder(
                    	tuple(null, new BigDecimal("123")),
                    	tuple(true, new BigDecimal("456")),
                    	tuple(null, new BigDecimal("789.123")));
	}

	@Test
	void deserializesToBoolean() throws Exception {
    	String inputJson = """
            	{
              	"id": "charSpecIdVal",
              	"name": "charSpecNameVal",
              	"characteristicValueSpecification": [
                	{
                  	"@type": "BooleanCharacteristicValueSpecification",
                  	"value": true
                	},
                	{
                  	"@type": "BooleanCharacteristicValueSpecification",
                  	"value": false,
                  	"isDefault": true
                	}
              	]
            	}
            	""";

    	performPostCharSpecRequest(inputJson).andExpect(status().isOk());
    	CharacteristicSpecification capturedPayload = getCapturedDeserializedPayload();
    	assertThat(capturedPayload)
            	.extracting(CharacteristicSpecification::getId, CharacteristicSpecification::getName)
            	.contains("charSpecIdVal", "charSpecNameVal");
    	assertThat(capturedPayload.getCharacteristicValueSpecification())
            	.allMatch(valueSpec -> valueSpec instanceof BooleanCharacteristicValueSpecification)
            	.allMatch(valueSpec -> "BooleanCharacteristicValueSpecification".equals(valueSpec.get_atType()))
            	.extracting(CharacteristicValueSpecification::getIsDefault,
                    	valueSpec -> ((BooleanCharacteristicValueSpecification) valueSpec).getValue())
            	.containsExactlyInAnyOrder(
                    	tuple(null, true),
                    	tuple(true, false));
	}

	private ResultActions performPostCharSpecRequest(String json) throws Exception {
    	return mockMvc.perform(post("/characteristic-specifications")
            	.content(json)
            	.contentType(MediaType.APPLICATION_JSON));
	}

	private CharacteristicSpecification getCapturedDeserializedPayload() {
    	ArgumentCaptor<CharacteristicSpecification> charSpecArgCaptor = ArgumentCaptor.forClass(CharacteristicSpecification.class);
    	verify(charSpecServiceMock).createCharacteristicSpecification(charSpecArgCaptor.capture());
    	return charSpecArgCaptor.getValue();
	}
}

Polymorphic Deserialization Components

At this point, we've got our classes defined, our endpoint defined, and integration tests set up to match our expectations of behavior. Now, let's get to the reason you clicked on this blog!

At a high level, this is the pattern we will follow:

  • We will introduce a new PolymorphicCharacteristicValueSpecificationDeserializer component, a simple extension of Jackson's JsonDeserializer.
    • Crucially, thanks to Spring's SpringHandlerInstantiator, we can autowire beans into this deserializer!
  • We will introduce an interface called CharacteristicValueSpecificationDeserializationHandler.
    • For each CharacteristicValueSpecification sub-type we want to deserialize, we will implement this interface and register it as a bean.
    • The responsibility of this component is to check the @type value from the payload and (if it recognizes the @type) return the Java type to deserialize the payload to.
  • PolymorphicCharacteristicValueSpecificationDeserializer will inject all of the available CharacteristicValueSpecificationDeserializationHandler beans.
    • When it receives a JSON payload, it will go through the handlers until one of them successfully gives it a Java type to deserialize the payload to.
    • The deserializer will then pass this Java type to the standard Jackson ObjectMapper to perform the actual deserialization.

Below, you will find the full implementations of each component.

PolymorphicCharacteristicValueSpecificationDeserializer

public class PolymorphicCharacteristicValueSpecificationDeserializer
    	extends JsonDeserializer<CharacteristicValueSpecification> {

	@Getter(AccessLevel.PROTECTED)
	private List<CharacteristicValueSpecificationDeserializationHandler> charValueSpecDeserializationHandlers =
        	Collections.emptyList();

	/**
 	* @param charValueSpecDeserializationHandlers the value specification
 	*    	deserialization handler beans from the Spring context
 	* @see SpringHandlerInstantiator
 	*/
	@Autowired(required = false)
	public void setCharValueSpecDeserializationHandlers(
        	@Nullable List<CharacteristicValueSpecificationDeserializationHandler> charValueSpecDeserializationHandlers) {
    	this.charValueSpecDeserializationHandlers =
            	ListUtils.emptyIfNull(charValueSpecDeserializationHandlers);
	}

	@Override
	public CharacteristicValueSpecification deserialize(JsonParser parser,
        	DeserializationContext ctxt) throws IOException {
    	ObjectMapper mapper = (ObjectMapper) parser.getCodec();
    	// We have to read to advance the parser
    	JsonNode node = mapper.readTree(parser);
    	Class<? extends CharacteristicValueSpecification> deserializationType =
            	charValueSpecDeserializationHandlers.stream()
                    	.map(handler -> handler
                            	.determineDeserializedCharacteristicValueSpecificationType(node))
                    	.filter(Objects::nonNull)
                    	.findFirst()
                    	.orElse(null);
    	if (deserializationType == null) {
        	return handleNoEligibleCharacteristicValueSpecDeserializationHandler(node,
                	parser,
                	ctxt);
    	} else {
        	return mapper.readValue(
                	// Restore it to a JSON string for a more 'pure' deserialization
                	node.toString(),
                	deserializationType);
    	}
	}

	protected CharacteristicValueSpecification handleNoEligibleCharacteristicValueSpecDeserializationHandler(
        	JsonNode source,
        	JsonParser parser,
        	DeserializationContext ctxt) throws JacksonException {
    	throw new JsonMappingException(parser,
            	"""
                    	Could not find a CharacteristicValueSpecificationDeserializationHandler capable of \
                    	deserializing CharacteristicValueSpecification from:\s"""
                    	+ source.toString());
	}
}

CharacteristicValueSpecificationDeserializationHandler interface + implementations

(Don't forget to register these as Spring beans!)

public interface CharacteristicValueSpecificationDeserializationHandler extends Ordered {

	/**
 	* @param jsonRepresentation the JSON object representation of a
 	*    	{@link CharacteristicValueSpecification} payload (or a subtype of it)
 	* @return the specific subtype of {@link CharacteristicValueSpecification} that the JSON can be
 	*     	deserialized to, if this handler knows how to deal with it. If this handler does not
 	*     	know how to deal with it, return {@code null}
 	*/
	@Nullable
	Class<? extends CharacteristicValueSpecification> determineDeserializedCharacteristicValueSpecificationType(
        	JsonNode jsonRepresentation);

	@Override
	default int getOrder() {
    	return 0;
	}
}

To follow 'DRY' principles, we will use an abstract class for our implementations:

public abstract class AbstractCharacteristicValueSpecificationDeserializationHandler
    	implements CharacteristicValueSpecificationDeserializationHandler {

	@Override
	public Class<? extends CharacteristicValueSpecification> determineDeserializedCharacteristicValueSpecificationType(
        	JsonNode jsonRepresentation) {
    	String type = safeGetTypeForCharacteristicValueSpecificationJson(jsonRepresentation);
    	if (canDeserializeCharacteristicValueSpecBasedOnType(type)) {
        	return getDefaultDeserializedCharacteristicValueSpecificationType();
    	}
    	// We don't support this
    	return null;
	}

	@Nullable
	protected String safeGetTypeForCharacteristicValueSpecificationJson(
        	JsonNode characteristicValueSpecification) {
    	return characteristicValueSpecification.path("@type").asText(null);
	}

	/**
 	* @param typeValue the {@link CharacteristicValueSpecification#get_atType()} found in a
 	*    	candidate {@link JsonNode} to deserialize
 	* @return whether (just based on this type value) this handler knows how to deserialize the
 	*     	JSON
 	*/
	protected abstract boolean canDeserializeCharacteristicValueSpecBasedOnType(
        	@Nullable String typeValue);

	/**
 	* @return the {@link CharacteristicValueSpecification} type/subtype that this handler supports
 	*     	deserialization to
 	*/
	protected abstract Class<? extends CharacteristicValueSpecification> getDefaultDeserializedCharacteristicValueSpecificationType();
}
public class BooleanCharacteristicValueSpecificationDeserializationHandler
    	extends AbstractCharacteristicValueSpecificationDeserializationHandler {

	@Override
	protected boolean canDeserializeCharacteristicValueSpecBasedOnType(String typeValue) {
    	return "BooleanCharacteristicValueSpecification".equals(typeValue);
	}

	@Override
	protected Class<? extends CharacteristicValueSpecification> getDefaultDeserializedCharacteristicValueSpecificationType() {
    	return BooleanCharacteristicValueSpecification.class;
	}
}
public class NumberCharacteristicValueSpecificationDeserializationHandler
    	extends AbstractCharacteristicValueSpecificationDeserializationHandler {

	@Override
	protected boolean canDeserializeCharacteristicValueSpecBasedOnType(String typeValue) {
    	return "NumberCharacteristicValueSpecification".equals(typeValue);
	}

	@Override
	protected Class<? extends CharacteristicValueSpecification> getDefaultDeserializedCharacteristicValueSpecificationType() {
    	return NumberCharacteristicValueSpecification.class;
	}
}
public class StringCharacteristicValueSpecificationDeserializationHandler
    	extends AbstractCharacteristicValueSpecificationDeserializationHandler {

	@Override
	protected boolean canDeserializeCharacteristicValueSpecBasedOnType(String typeValue) {
    	return "StringCharacteristicValueSpecification".equals(typeValue);
	}

	@Override
	protected Class<? extends CharacteristicValueSpecification> getDefaultDeserializedCharacteristicValueSpecificationType() {
    	return StringCharacteristicValueSpecification.class;
	}
}

Using the new deserializer

To engage our new deserializer, we can simply annotate our CharacteristicSpecification.characteristicValueSpecification field like so:

	@JsonProperty("characteristicValueSpecification")
	@JsonDeserialize(contentUsing = PolymorphicCharacteristicValueSpecificationDeserializer.class)
	private List<CharacteristicValueSpecification> characteristicValueSpecification = null;

(Note that since we want the deserializer to operate on each element of the list rather than the list itself, we use contentUsing.)

And that's it! If you run the integration tests now, you will see that they pass, and each element will have been deserialized to the appropriate sub-type.

Conclusion

As you can see, this approach is highly flexible:

  • You can dynamically add/remove as many handlers as you want without ever touching the main class or any subtype of it. The @JsonTypeInfo approach requires that every time you introduce a new subtype, you have to explicitly update the Jackson annotations to include it. This approach removes that requirement.
  • The bean-based approach means you can use all the standard Spring patterns to enable/suppress/override handler implementations dynamically.
    • For example, if you want to gate support for a certain subtype on an application property value, you can simply define your handler bean with @ConditionalOnProperty. Lots of possibilities!
  • Because you have the full JSON object structure available to you in the handler, the implementation can be dynamic in which field(s) it considers and can apply more complex conditional checks.

I hope you found this helpful! Happy deserializing!

Related Resources