DEV Community

loading...

Practical Java 16 - Using Jackson to serialize Records

Bruno Oliveira
・7 min read

Introduction

With JDK 16 being in General Availability and JDK 17 already in early access, I think it's finally time to explore what is arguably one of the coolest features offered by the "bleeding edge" Java: records, and how to work with them from a practical perspective.

One of the most common workflows for modern applications, relies on serializing what is commonly referred to as a "Data Transfer Object", shortened to DTO, that is an object used to represent a domain concept that we want to serialize to a common format that can be consumed by clients, usually JSON.

A common library to do this in the Java stack, is called Jackson that offers core functionalities and databinding capabilities to serialize Java classes to JSON and to deserialize from JSON into Java classes.

We will see how Jackson works and how records are so much more compact and easier to use.

Current status and usage before Java 16 and Jackson 2.12.x

Currently, using Java < 16 and Jackson, since records are not supported, the only way we have to serialize a Java object into JSON, is to use the Jackson object mapper, as follows:

Let's create a simple PersonDTO class which has the following information:

public class PersonDTO {
    private final String name;
    private final int age;
    private final double weight;
    @JsonSerialize(using = LocalDateSerializer.class)
    private final LocalDate collegeApplicationDate;

    public PersonDTO(String name, int age, double weight,
        @JsonSerialize(using = LocalDateSerializer.class) LocalDate collegeApplicationDate) {
        this.name = name;
        this.age = age;
        this.weight = weight;
        this.collegeApplicationDate = collegeApplicationDate;
    }

    public String getName() {
        return name;
    }

    public int getAge() {
        return age;
    }

    public double getWeight() {
        return weight;
    }

    @JsonSerialize(using = LocalDateSerializer.class)
    public LocalDate getCollegeApplicationDate() {
        return collegeApplicationDate;
    }
Enter fullscreen mode Exit fullscreen mode

As we can see, this is quite verbose, and arguably, most of this boilerplate could be avoided, and the information conveyed by the class would be exactly the same: it's a person with some personal information and an application to a college date, nothing else.

The constructor, the getters and the attributes, add a lot of clutter, but, it's the only way to have a DTO to be serialized by Jackson, as the getters are required so that the library can write them to JSON internally, if we are below Java 16.

Let's use the below service as a kind of placeholder to exercise Jackson:

public class PersonListingService {

    public PersonListingService() {
    }

    public List<PersonDTO> getPersonList() {
        return Arrays.asList(
            new PersonDTO("John",30,60.7, LocalDate.now()),
            new PersonDTO("Shaw", 33, 70.5, LocalDate.now().minusDays(20L)),
            new PersonDTO("Harold Finch", 50, 70, null));
    }
}
Enter fullscreen mode Exit fullscreen mode

We can write a quick test and verify that this works as expected, using Jackson 2.11.4, that comes bundled by default with Spring Initializr in IntelliJ:

 @Test
    void dto_list_can_be_serialized_via_jackson() throws JsonProcessingException {
        ObjectMapper mapper = new ObjectMapper();
        mapper.setDateFormat(new SimpleDateFormat("yyyy-MM-dd"));

        String serializedList = mapper.writeValueAsString(personListingService.getPersonList());

        Assert.assertEquals("[{\"name\":\"John\",\"age\":30,\"weight\":60.7,\"collegeApplicationDate\":\"2021-04-24\"}"
                + ",{\"name\":\"Shaw\",\"age\":33,\"weight\":70.5,\"collegeApplicationDate\":\"2021-04-04\"},{\"name\":\"Harold "
                + "Finch\",\"age\":50,\"weight\":70.0,\"collegeApplicationDate\":null}]", serializedList);
}
Enter fullscreen mode Exit fullscreen mode

This works in Java < 16 and with Jackson 2.11.4, because, the getters in our DTO are used internally by Jackson to map the fields that need to be translated to the JSON: it knows to write an age field because our DTO has a getAge method, and this correspondence is done based on the getters and attributes names, by default.

Let's see now how to make records work with the Jackson version that comes bundled with the Spring Initializr.

Serializing a record using Jackson 2.11.4

We can now "convert" our PersonDTO into a Record, very easily:

public record PersonDTO(String name, int age, double weight,
                        LocalDate collegeApplicationDate) {
}
Enter fullscreen mode Exit fullscreen mode

A Java Record can be thought of as a data class that has specific fields, and can be created as a normal class, using the standard constructor based on the attributes defined above. This means that our original service works as it is declared above, no changes needed.

However, if we try to serialize this as it is above, our test will fail:

com.fasterxml.jackson.databind.exc.InvalidDefinitionException: No serializer found for class com.example.demo.dto.PersonDTO and no properties discovered to create BeanSerializer (to avoid exception, disable SerializationFeature.FAIL_ON_EMPTY_BEANS) (through reference chain: java.util.Arrays$ArrayList[0])
Enter fullscreen mode Exit fullscreen mode

Let's try to add a @JsonSerialize annotation above the record:

@JsonSerialize
public record PersonDTO(String name, int age, double weight,
                        LocalDate collegeApplicationDate) {
}
Enter fullscreen mode Exit fullscreen mode

and we get:

org.junit.ComparisonFailure: expected:<[{["name":"John","age":30,"weight":60.7,"collegeApplicationDate":"2021-04-24"},{"name":"Shaw","age":33,"weight":70.5,"collegeApplicationDate":"2021-04-04"},{"name":"Harold Finch","age":50,"weight":70.0,"collegeApplicationDate":null]}]> but was:<[{[},{},{]}]>
Expected :[{"name":"John","age":30,"weight":60.7,"collegeApplicationDate":"2021-04-24"},{"name":"Shaw","age":33,"weight":70.5,"collegeApplicationDate":"2021-04-04"},{"name":"Harold Finch","age":50,"weight":70.0,"collegeApplicationDate":null}]
Actual   :[{},{},{}]
Enter fullscreen mode Exit fullscreen mode

So now, we did manage to "serialize" something, but, we get an empty array, meaning that each individual record couldn't be serialized correctly.

Without any real "insight" on how records truly work under the hood, it can be hard to debug this, but, it's worth having a deeper look.

"Debugging" records serialization with "old" Jackson

IntelliJ offers us the possibility to convert a record to a class, so, let's do it and see exactly how the JVM "sees" a record as a true data class under the hood:

@JsonSerialize
public final class PersonDTO {
    private final String name;
    private final int age;
    private final double weight;
    @JsonSerialize(using = LocalDateSerializer.class)
    private final LocalDate collegeApplicationDate;

    public PersonDTO(String name, int age, double weight,
        @JsonSerialize(using = LocalDateSerializer.class) LocalDate collegeApplicationDate) {
        this.name = name;
        this.age = age;
        this.weight = weight;
        this.collegeApplicationDate = collegeApplicationDate;
    }

    public String name() {
        return name;
    }

    public int age() {
        return age;
    }

    public double weight() {
        return weight;
    }

    @JsonSerialize(using = LocalDateSerializer.class)
    public LocalDate collegeApplicationDate() {
        return collegeApplicationDate;
    }

    @Override
    public boolean equals(Object obj) {
        if (obj == this)
            return true;
        if (obj == null || obj.getClass() != this.getClass())
            return false;
        var that = (PersonDTO)obj;
        return Objects.equals(this.name, that.name) &&
            this.age == that.age &&
            Double.doubleToLongBits(this.weight) == Double.doubleToLongBits(that.weight) &&
            Objects.equals(this.collegeApplicationDate, that.collegeApplicationDate);
    }

    @Override
    public int hashCode() {
        return Objects.hash(name, age, weight, collegeApplicationDate);
    }

    @Override
    public String toString() {
        return "PersonDTO[" +
            "name=" + name + ", " +
            "age=" + age + ", " +
            "weight=" + weight + ", " +
            "collegeApplicationDate=" + collegeApplicationDate + ']';
    }
}
Enter fullscreen mode Exit fullscreen mode

So a record is nothing more than a "final class" with final fields, and an auto-generated hashcode and equals methods. Interesting.

It's especially interesting from a debugging perspective, because, as we noted earlier, Jackson 2.11.4 requires the getters to be defined explicitely in order to be able to serialize the class to JSON, and, from this representation, it seems like the field names are being used directly to generate the getters, which opens up the road to the next logical step.....

.....

@JsonSerialize
public record PersonDTO(String getName, int getAge, double getWeight,
                        @JsonSerialize(using = LocalDateSerializer.class) LocalDate collegeApplicationDate) {
}
Enter fullscreen mode Exit fullscreen mode

Don't do this at home!! :D

We can actually trick Jackson's reflection mechanism as well as the JVM record conversion into generating "getters" in the underlying data class, which CAN be picked up by Jackson...

Expected :[{"name":"John","age":30,"weight":60.7,"collegeApplicationDate":"2021-04-24"},{"name":"Shaw","age":33,"weight":70.5,"collegeApplicationDate":"2021-04-04"},{"name":"Harold Finch","age":50,"weight":70.0,"collegeApplicationDate":null}]
Actual   :[{"collegeApplicationDate":"2021-04-24","name":"John","age":30,"weight":60.7},{"collegeApplicationDate":"2021-04-04","name":"Shaw","age":33,"weight":70.5},{"collegeApplicationDate":null,"name":"Harold Finch","age":50,"weight":70.0}]
Enter fullscreen mode Exit fullscreen mode

While the fields of the objects are out of order, we see that we do have the data there, and actually it works because, the values in the fields are exactly the same. We left the custom serializer to handle the date, but, using the "artificial getter" actually was a funny way to leverage record's internal construction structure.

The correct way is to use Jackson's annotation AutoDetect:

@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY)
@JsonSerialize
public record PersonDTO(String name, int age, double weight,
                        @JsonSerialize(using = LocalDateSerializer.class) LocalDate collegeApplicationDate) {
}
Enter fullscreen mode Exit fullscreen mode

this will let Jackson autodetect the fields based on the record's attributes and the test will pass, since the restriction of the getter field will be lifted.

Since records generate the getters directly from the attributes of the record, this is a good way to bypass it, that is perfectly valid and allows us to use records today with Jackson 2.11.4.

How to do it the right way

By upgrading Jackson to it's latest release 2.12.3:

  <!-- https://mvnrepository.com/artifact/com.fasterxml.jackson.core/jackson-core -->
        <dependency>
            <groupId>com.fasterxml.jackson.core</groupId>
            <artifactId>jackson-core</artifactId>
            <version>2.12.3</version>
        </dependency>
        <!-- https://mvnrepository.com/artifact/com.fasterxml.jackson.core/jackson-databind -->
        <dependency>
            <groupId>com.fasterxml.jackson.core</groupId>
            <artifactId>jackson-databind</artifactId>
            <version>2.12.3</version>
        </dependency>
        <!-- https://mvnrepository.com/artifact/com.fasterxml.jackson.core/jackson-annotations -->
        <dependency>
            <groupId>com.fasterxml.jackson.core</groupId>
            <artifactId>jackson-annotations</artifactId>
            <version>2.12.3</version>
        </dependency>
Enter fullscreen mode Exit fullscreen mode

We can make use of the great interoperability between Jackson and records, thanks to the added support for record serialization (more info here ) which means that simply declaring a record in its simplest form:

public record PersonDTO(String name, int age, double weight,
                        @JsonSerialize(using = LocalDateSerializer.class) LocalDate collegeApplicationDate) {
}
Enter fullscreen mode Exit fullscreen mode

will work, and the record will be able to be serialized correctly, leveraging the local date serializer as well.

Conclusion

As Java keeps improving and maturing, some of its features are extremely useful and versatile, and it's always nice to explore them in some detail to understand its nuances and how it works under the hood. Our example of leveraging the knowledge of Jackson together with the way JVM generates a record is a good example of that.

With the right configuration, records can work almost hassle free with Jackson 2.11.4 and it's as seamlessly integrated as normal workflows of today in Jackson's latest releases. Are you using records already? :)

Discussion (0)

Forem Open with the Forem app