Code Major

Java Records and Jackson

Introduction

Java records which were introduced as a preview feature in Java 14 and stabilized in Java 16 were intended to be used as concise, immutable data carriers. They are concise because they do automatic generation of boilerplate code for constructors, accessors, equals(), hashCode(), and toString() for us without us having to bother ourselves about the aforementioned methods. They are a good fit for use as DTOs and other plain data aggregates. This article explores how Jackson works with records, the benefits it brings, and potential pitfalls you should watch out for.

Records and Jackson’s Default Behavior

Jackson was originally designed to work with JavaBeans conventions, relying on public constructors and getter/setter methods. Records are implicitly final, and their fields are implicitly private and final but they expose their fields via accessor methods. Say we have the Person record below:

public record Person(String name, int age) { }
private Record james = new Person("James", 23);

We will be able to access the private fields of james name and age like so: james.name(), james.age(). Although not exactly the same as JavaBeans, this design makes them inherently compatible with Jackson’s default serialization and deserialization mechanisms. Starting with Jackson 2.12, Jackson can serialize and deserialize records with no extra plugins.

When Jackson encounters a record:

  • Serialization: It treats each record component like a regular property.
  • Deserialization: It tries to call the canonical constructor using argument names.

Jackson leverages the ParameterNamesModule module to read constructor parameter names via java.lang.reflect

Below is the serialization and deserialization of a person instance.

import com.fasterxml.jackson.databind.ObjectMapper;

public class JacksonRecordSerialization {
        public static void main(String[] args) throws Exception {
            record Person(String name, int age) { }
            Person person = new Person("Joe", 30);
            ObjectMapper objectMapper = new ObjectMapper();

            String json = objectMapper.writeValueAsString(person);
            System.out.println(json); // Expected output: {"name":"Joe","age":30}

            Person deserializedUser = objectMapper.readValue(json, Person.class);
            System.out.println(deserializedUser); // Expected output: Person[name=Joe, age=30]
        }

}

Above we used the same familiar ObjectMapper to serialize and deserialize exactly as we would do with JavaBeans.

Customizing Serialization with Jackson Annotations

As we saw in the previous section, for simple use cases we do not need to do anything other than using the ObjectMapper to serialize or deserialize. Beyond that, we can use Jackson’s annotations to customize the serialization process.

package com.greenroots.gulliver.core.dto;

import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonPropertyOrder;
import com.fasterxml.jackson.databind.ObjectMapper;

public class JacksonRecordSerialization {
        public static void main(String[] args) throws Exception {
            
            @JsonPropertyOrder({"email", "name","age"})
            record Person(@JsonProperty("first_name") String name,
                          @JsonProperty(required=true)Integer age,
                          @JsonIgnore String password,
                          String email) { }
            
            Person person = new Person("Joe", 30 , "secret", "me@yahoo.com");
            ObjectMapper objectMapper = new ObjectMapper();

            String json = objectMapper.writeValueAsString(person);
            System.out.println(json); // Expected output: {"email":"me@yahoo.com","first_name":"Joe","age":30}

            Person deserializedUser = objectMapper.readValue(json, Person.class);
            System.out.println(deserializedUser); // Expected output: Person[name=Joe, age=30, password=null, email=me@yahoo.com]

            final Person person1 = new Person("Mike", null, "secret", "ull@yahoo.com");
            final String personWithoutAge = objectMapper.writeValueAsString(person1);
            System.out.println(personWithoutAge); //{"email":"ull@yahoo.com","first_name":"Mike","age":null}

            //Jackson interprets null as a value and does not fail
            final Person person2 = objectMapper.readValue(personWithoutAge, Person.class);
            System.out.println(person2); //Person[name=Mike, age=null, password=null, email=ull@yahoo.com]

            //Fail expect MismatchedInputException: Missing required creator property 'age' (index 1)
            String json3 = """
                    {"email":"me@yahoo.com","first_name":"Joe"}
                    """;
            objectMapper.readValue(json3, Person.class);
        }

}

The annotation on line 11 makes jackson put the serialized components in the order specified. As a result the output of line 21 is in the requested order: {"email":"me@yahoo.com","first_name":"Joe","age":30} . On line 12 the annotation @JsonProperty("first_name")maps name to “first_name” when converting to json and vice versa. The annotation @JsonProperty(required=true)simply means fail when the given property is missing. It is worth noting that the validation will not fail if the property is explicitly passed in as null. Use JSR 303 bean validation annotations or the style below to provide reliable validation.

            record Person(String name, Integer age) {
                public Person {
                    if (age == null) {
                        throw new IllegalArgumentException("age cannot be null");
                    }
                }
            }

Conclusion

Java Records and Jackson form a powerful combination for building robust and maintainable Java applications that interact with JSON data. Records simplify data modeling with their conciseness and immutability, while Jackson provides the flexible and performant serialization/deserialization capabilities needed for modern APIs. By understanding their natural synergy and leveraging Jackson’s extensive annotation system, Java developers can efficiently manage their data exchange needs with minimal boilerplate and maximum clarity.

Leave a comment

Navigation

About

Writing on the Wall is a newsletter for freelance writers seeking inspiration, advice, and support on their creative journey.