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
contentFormat 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 tellsJackson
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 ofDEDUCTION
in@JsonTypeInfo
We now specify
name
within theJsonSubTypes
, 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 respectiveDto
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