Skip to main content

Command Palette

Search for a command to run...

Polymorphic DTO using Java Record with Jackson

Published
7 min readView as Markdown
Polymorphic DTO using Java Record with Jackson
J

Advocate for better developer's productivity and experience

Documenting my learnings and sharing it

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

K

Great Blog Post. I also have been dealing with something similar but a bit more you could say weirder :D. My case still revolves around old classes instead of records since I want to have some fields that are shared and controlled separately. I'll appreciate to have your comments about it :DD....

The Scenario

My scenario revolves around a system in which I'm trying to create an entity of type "Item". Items in my system have different types which for each type we have a separate table which results in a separate entity. As an example, let's say we have an Item of type Book and Disk. Both of these classes/entities will extend a base class called BaseItem in which it contains shared fields for both of them and also all other future Item types. Other then that I also have an enum in which we record all item types called ItemType which right now it will look like this:

public enum ItemType {
    BOOK,
    DISK
}

Till this part everything is okay but stuff get a bit complex when we dive into the specific policies related to the request for creating a new Item.

The API Call

The JSON for this API call goes as follows:

{
    "type": <string of which it should indicate value for the enum>
    "detail": <object in which it should contain feilds related to matched class that was specified by the value of "type" property>
}

As I explained above, both Book and Disk have some base fields that are shared in both of them which they take those fields by extending the BaseClass, but each will have their own set of fields that is not shared. To get those fields we have a property called "detail" in which we accept an object that will later map this object to the correct class by using the value of type field as a helper. Till this section everything is explained but the implementation becomes a bit more complex on a specific rule.

The Challenge

Let's on deserialization of the type field using the annotations we want to use the enum itself directly as the type (Basically going by the @RequestBody logic in Spring) so on wrong values given for the "type" property key we raise an error for the user giving them hints on what the allowed values are. At the same time we also want to make life easier for the called (and harder for the developer at the same time :D) so we also want to allow them to present the values in a case insensitive manner, so that both "type": "book" and "type":"BOOK" are resolved to ItemType.BOOK. At first glance this might be simple (since you can configure your mapper to accept case insensitive values for enum in jackson) but it becomes complex when we remember that we also want to use @JsonSubTypes so we map the value given in the "type" property to be used to help us determine and find the correct class for the detail field. To show an overview of the code on why this will create an issue which led me to work on custom type id resolvers:

public record CreateItem {
    ItemType type,
    @JsonTypeInfo(
        use = JsonTypeInfo.Id.NAME,
        include = JsonTypeInfo.As.EXTERNAL_PROPERTY,
        property = "type"
    )
    @JsonSubTypes(
        @JsonSubTypes.Type(value = Book.class, name = "BOOK"),
        @JsonSubTypes.Type(value = Disk.class, name = "DISK")
    )
    ItemDetail detail // Interface in which both the Book and Disk class implement
}

As you can see if the value given in the type field even bypasses the case insensitivity we still have a problem as the @JsonSubTypes annotation requires it to have all capital letters so we won't actually have case insensivity.

Final Note

First of all thanks for reading my comment(both the authors and anyone else :)). I'm still in the middle of trying to find an elegant way in which I can solve this issue cleanly without having to write heavy amounts of boilerplate code but still I'm not such an expert on Spring and Jackson so if you see any inconsistencies or wrong explanation I really really appreciate it that you inform me so I can learn more.

More from this blog

Joseph Gan's Blog

32 posts