Polymorphic DTO using Java Record with Jackson

Polymorphic DTO using Java Record with Jackson

With the introduction of Java Record class, it becomes the perfect candidate to use as a Data Transfer Object (DTO). I'm not going to explain more, you can find tons of articles out there with its benefit and code samples.

However, what is missing out there is a code sample of how to use it with a Polymorphic DTO use case where we know that Java Record classes cannot be extended but can implement interfaces.

I'm going to show you how we can achieve this together with Jackson

Scenario

Imagine we have an API endpoint that accepts HomeAddressDto and OfficeAddressDto as the @RequestBody, which would look like the following:

public record HomeAddressDto(String street, String postalCode, String unit) {}

public record OfficeAddressDto(String building, String street, String postalCode, String unit) {}

@RestController
@RequestMapping("/addresses")
public class AddressController {

    @PostMapping("/home")
    public HomeAddressDto create(@RequestBody HomeAddressDto homeAddressDto) {
        return homeAddressDto;
    }

    @PostMapping("/office")
    public OfficeAddressDto create(@RequestBody OfficeAddressDto officeAddressDto) {
        return officeAddressDto;
    }
}

Now, there is nothing wrong with this, and it works, but it can be pretty ugly having to create different endpoints to cater to each different (but of the same type) DTO. It can get messy real fast.

Let's see if we can improve this.

Solution

Step 1: Common Interface

Since we know that both types are address, we can create an empty interface to house both classes.

Remember, Java Record cannot be extended

public interface Address {}

Then we update both classes to implement Address interface

public record HomeAddressDto(String street, String postalCode, String unit) implements Address {}

public record OfficeAddressDto(String building, String street, String postalCode, String unit) implements Address {}

This way, it allows us to specify Address as the @RequestBody

Step 2: Common Endpoint

Given that we have Address interface, we probably can work on having a single endpoint to cater to both types of address classes.

@PostMapping
public Address create(@RequestBody Address address) {
    return address;
}

Let's try to hit that new endpoint

curl -X POST -H "Content-Type: application/json" -d "@address.json" localhost:8080/addresses | jq '.'
  • Perform a curl POST request to localhost:8080/addresses

  • Using address.json content

  • Format output (pretty print) using jq

The content of address.json is

{
    "street": "st",
    "postalCode": "pc",
    "unit": "u"
}

And this is what we will get back

{
  "timestamp": "2022-12-20T14:44:10.836+00:00",
  "status": 500,
  "error": "Internal Server Error",
  "trace": "org.springframework.http.converter.HttpMessageConversionException: Type definition error: [simple type, class com.bwgjoseph.springbootpolymorphicjavarecordwithjackson.deduction.Address]; nested exception is com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Cannot construct instance of `com.bwgjoseph.springbootpolymorphicjavarecordwithjackson.deduction.Address` (no Creators, like default constructor, exist): abstract types either need to be mapped to concrete types, have custom deserializer, or contain additional type information\n at [Source: (org.springframework.util.StreamUtils$NonClosingInputStream); line: 1, column: 1]\r\n\tat", // omitted
  "message": "Type definition error: [simple type, class com.bwgjoseph.springbootpolymorphicjavarecordwithjackson.deduction.Address]; nested exception is com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Cannot construct instance of `com.bwgjoseph.springbootpolymorphicjavarecordwithjackson.deduction.Address` (no Creators, like default constructor, exist): abstract types either need to be mapped to concrete types, have custom deserializer, or contain additional type information\n at [Source: (org.springframework.util.StreamUtils$NonClosingInputStream); line: 1, column: 1]",
  "path": "/addresses"
}

So what happens? Because the @RequestBody is using an interface, Jackson is not able to construct the class, hence the error message "abstract types either need to be mapped to concrete types, have custom deserializer, or contain additional type information"

Is it possible to overcome this? Fortunately, yes, and it is commonly used in Java Classes with inheritance. But in this case, we want to do it in Java Record

Step 3: Jackson Polymorphism Support

Jackson has support for polymorphic classes using @JsonTypeInfo and @JsonSubTypes, but almost all samples (online) are used based on Class instead of Record.

Deduction Based Polymorphism

This is the first approach that I am going to show

@JsonTypeInfo(use = JsonTypeInfo.Id.DEDUCTION)
@JsonSubTypes({
    @Type(HomeAddressDto.class),
    @Type(OfficeAddressDto.class)
})
public interface Address {}
  • Using Deduction based approach (available since 2.12)

  • Declare the various subclasses

If your subclasses have distinct fields, this approach is the easiest, as Jackson will assign to the correct class based on the field.

In this case, the difference between HomeAddressDto and OfficeAddressDto is that OfficeAddressDto has an additional field - building. If we fire the same API request, this is what we will get back

{
  "timestamp": "2022-12-20T14:58:25.515+00:00",
  "status": 400,
  "error": "Bad Request",
  "trace": "org.springframework.http.converter.HttpMessageNotReadableException: JSON parse error: Could not resolve subtype of [simple type, class com.bwgjoseph.springbootpolymorphicjavarecordwithjackson.deduction.Address]: Cannot deduce unique subtype of `com.bwgjoseph.springbootpolymorphicjavarecordwithjackson.deduction.Address` (2 candidates match); nested exception is com.fasterxml.jackson.databind.exc.InvalidTypeIdException: Could not resolve subtype of [simple type, class com.bwgjoseph.springbootpolymorphicjavarecordwithjackson.deduction.Address]: Cannot deduce unique subtype of `com.bwgjoseph.springbootpolymorphicjavarecordwithjackson.deduction.Address` (2 candidates match)\n at [Source: (org.springframework.util.StreamUtils$NonClosingInputStream)", // omitted
  "message": "JSON parse error: Could not resolve subtype of [simple type, class com.bwgjoseph.springbootpolymorphicjavarecordwithjackson.deduction.Address]: Cannot deduce unique subtype of `com.bwgjoseph.springbootpolymorphicjavarecordwithjackson.deduction.Address` (2 candidates match); nested exception is com.fasterxml.jackson.databind.exc.InvalidTypeIdException: Could not resolve subtype of [simple type, class com.bwgjoseph.springbootpolymorphicjavarecordwithjackson.deduction.Address]: Cannot deduce unique subtype of `com.bwgjoseph.springbootpolymorphicjavarecordwithjackson.deduction.Address` (2 candidates match)\n at [Source: (org.springframework.util.StreamUtils$NonClosingInputStream); line: 1, column: 59]",
  "path": "/addresses"
}

The error message, "2 candidates match" tells us that it is not able to automatically deduce based on the given field and thus, it throws the error. If we update the json to include building field

{
    "building": "building",
    "street": "st",
    "postalCode": "pc",
    "unit": "u"
}

And run the same request again. This time, it will be successful and returns the following response.

{
  "building": "building",
  "street": "st",
  "postalCode": "pc",
  "unit": "u"
}

It works, but as I have mentioned earlier, this is great if you have distinct fields to allow Jackson to make the correct deduction. Although, it is also possible to tell Jackson which is the default class to deduce into

@JsonTypeInfo(use = JsonTypeInfo.Id.DEDUCTION, defaultImpl = HomeAddressDto.class)
@JsonSubTypes({
    @Type(HomeAddressDto.class),
    @Type(OfficeAddressDto.class)
})
public interface Address {}
  • Notice the defaultImpl = HomeAddressDto.class, this tells Jackson to fall back to this class if you can't figure out which one to match

If it works for your use case, then great, otherwise, there is also another "strategy" you can use that is Name based approach

Name Based Polymorphism

This approach relies on a separate field to tell Jackson how to map to the correct subclass.

@JsonTypeInfo(use = JsonTypeInfo.Id.NAME)
@JsonSubTypes({
    @Type(value = HomeAddressDto.class, name = "home"),
    @Type(value = OfficeAddressDto.class, name = "office")
})
public interface Address {}
  • We now use NAME instead of DEDUCTION in @JsonTypeInfo

  • We now specify name within the JsonSubTypes, giving its unique identifier

With that, we have to hint Jackson by annotating @JsonTypeName on the individual class

@JsonTypeName("home")
public record HomeAddressDto(String street, String postalCode, String unit) implements Address {}

@JsonTypeName("office")
public record OfficeAddressDto(String building, String street, String postalCode, String unit) implements Address {}

@JsonTypeName has to match the name defined in @JsonSubTypes

Next, update the address.json to include @type field

{
    "street": "st",
    "postalCode": "pc",
    "unit": "u",
    "@type": "home"
}
  • Added @type field where the value should match the value that is defined in @JsonSubTypes

And if we were to run the request via curl again. This would be the response.

{
  "@type": "home",
  "street": "st",
  "postalCode": "pc",
  "unit": "u"
}

Enum Based Polymorphism

This last approach is pretty much the same as the previous method, only that we are going to use enum instead of a string value.

@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
@JsonSubTypes({
    @Type(value = HomeAddressDto.class, name = "home"),
    @Type(value = OfficeAddressDto.class, name = "office")
})
public interface Address {}

public record HomeAddressDto(String street, String postalCode, String unit) implements Address {}

public record OfficeAddressDto(String building, String street, String postalCode, String unit) implements Address {}
  • Notice that we added property

  • It no longer requires adding @JsonTypeName on the respective Dto

We need to create a new enum class

public enum Type {
    HOME,
    OFFICE
}

And if we were to run the request via curl again. This would be the response.

{
  "type": "home",
  "street": "st",
  "postalCode": "pc",
  "unit": "u"
}

Conclusion

We have seen how to use Polymorphic DTO using Java Record combined with Jackson to make our endpoint much cleaner and easier to manage.

I am not aware of any downside to this approach yet, or if it does not work for the more complicated use cases. If you know any, drop a comment below!

Source Code

As usual, the full source code is available on GitHub