When you’re building distributed systems in Java, the problem of serialization naturally arises. Briefly, serialization is the act of creating a representation of an object to store or transmit it and then reconstruct the same object in a different context.
That context could be
The last of these possibilities deserves a bit more thought. On the one hand, working with a non-JVM application opens the possibility of sharing objects with the whole world of network-connected applications. On the other hand, it can be hard to understand what is meant by “same object” when the object is reconstituted in something that isn’t a JVM.
Java has a built-in serialization mechanism that is likely to have been partially responsible for some of Java’s early success. However, the design of this mechanism is today viewed as seriously deficient, as Brian Goetz wrote in this 2019 post, “Towards better serialization.” While the JDK team has researched ways to rehabilitate (or maybe just remove) the inbuilt platform-level serialization in future versions of Java, developers’ needs to serialize and transport objects have not gone away.
In modern Java applications, serialization is usually performed using an external library as an explicitly application-level concern, with the result being a document encoded in a widely deployed serialization format. The serialization document, of course, can be stored, retrieved, shared, and archived. A preferred format was, once upon a time, XML; in recent years, JavaScript Object Notation (JSON) has become a more popular choice.
JSON is an attractive choice for a serialization format. The following are some of the reasons:
These benefits are counterbalanced by some negatives; the biggest is that a document serialized by JSON can be quite large, which can contribute to poor performance for larger messages. Note, however, that XML can create even larger documents.
Also, JSON and Java evolved from very different programming traditions. JSON provides for a very restricted set of possible value types.
Boolean
Number
String
Array
Object
Of these, JSON’s Boolean
, String
, and null map fairly closely to Java’s conception of boolean, String
, and null, respectively. Number
is essentially Java’s double
with some corner cases. Array
can be thought of as essentially a Java List
or ArrayList
with some differences.
(The inability of JSON and JavaScript to express an integer type that corresponds to int or long turns out to cause its own headaches for JavaScript developers.)
The JSON Object
, on the other hand, is problematic for Java developers due to a fundamental difference in the way that JavaScript approaches object-oriented programming (OOP) compared to how Java approaches OOP.
A class comparison. JavaScript does not natively support classes. Instead, it simulates class-like inheritance using functions. The recently added class
keyword in JavaScript is effectively syntactic sugar; it offers a convenient declarative form for JavaScript classes, but the JavaScript class
does not have the same semantics as Java classes.
Java’s approach to OOP treats class files as metadata to describe the fields and methods present on objects of the corresponding type. This description is completely prescriptive, as all objects of a given class type have exactly the same set of methods and fields.
Therefore, Java does not permit you to dynamically add a field or a method to a single object at runtime. If you want to define a subset of objects that have extra fields or methods, you must declare a subclass. JavaScript has no such restrictions: Methods or fields can be freely added to individual objects at any time.
JavaScript’s dynamic free-form nature is at the heart of the differences between the object models of the two languages: JavaScript’s conception of an object is most similar to that of a Map<String, Object>
in Java. It is important to recognize that the type of the JavaScript value here is Object
and not ?
, because JavaScript objects are heterogeneous, meaning their values can have a substructure and can be of Array
or Object
types in their own right.
To help you navigate these difficulties, and automatically bridge the gap between Java’s static view and JavaScript’s dynamic view of the world, several libraries and projects have been developed. Their primary purpose is to handle the serialization and deserialization of Java objects to and from documents in a JSON format. In the rest of this article, I’ll focus on one of the most popular choices: Jackson.
Jackson was first formally released in May 2009 and aims to satisfy the three major constraints of being fast, correct, and lightweight. Jackson is a mature and stable library that provides multiple different approaches to working with JSON, including using annotations for some simple use cases.
Jackson provides three core modules.
jackson-core
) defines a low-level streaming API and includes JSON-specific implementations.jackson-annotations
) contains standard Jackson annotations.jackson-databind
) implements data binding and object serialization.Adding the databind
module to a project also adds the streaming and annotation modules as transitive dependencies.
The examples to follow will focus on these core modules; there are also many extensions and tools for working with Jackson, which won’t be covered here.
The following code fragment from a university’s information system has a very simple class for the people in the system:
public class Person {
private final String firstName;
private final String lastName;
private final int age;
public Person(String firstName, String lastName, int age) {
this.firstName = firstName;
this.lastName = lastName;
this.age = age;
}
public String getFirstName() {
return firstName;
}
public String getLastName() {
return lastName;
}
public int getAge() {
return age;
}
}
Jackson can be used to automatically serialize this class to JSON so that it can, for example, be sent over the network to another service that may or may not be implemented in Java and that can receive JSON-formatted data.
You can set up this serialization with a very simple bit of code, as follows:
var grant = new Person("Grant", "Hughes", 19);
var mapper = new ObjectMapper();
try {
var json = mapper.writeValueAsString(grant);
System.out.println(json);
} catch (JsonProcessingException e) {
e.printStackTrace();
}
This code produces the following simple output:
{"firstName":"Grant","lastName":"Hughes","age":19}
The key to this code is the Jackson ObjectMapper
class. This class has two minor wrinkles that you should know about.
ObjectMapper
expects getter (and setter, for deserialization) methods for all fields.The first point is not immediately relevant (it will be in the next example, which is why I’m calling it out now), but the second could represent a design constraint for designing the classes, because you may not want to have getter methods that obey the JavaBeans conventions.
It is possible to control various aspects of the serialization (or deserialization) process by enabling specific features on the ObjectMapper
. For example, you could activate the indentation feature, as follows:
var mapper = new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT);
Then the output will instead look somewhat more human-readable, but without affecting its functionality.
{
"firstName" : "Grant",
"lastName" : "Hughes",
"age" : 19
}
This example introduces some Java 17 language features to help with the data modelling by making Person
an abstract base class that prescribes its possible subclasses—in other words, a sealed class. I’ll also change from using an explicit age, and instead I’ll use a LocalDate
to represent the person’s date of birth so the student’s age can be programmatically calculated by the application when needed.
public abstract sealed class Person permits Staff, Student {
private final String firstName;
private final String lastName;
private final LocalDate dob;
public Person(String firstName, String lastName, LocalDate dob) {
this.firstName = firstName;
this.lastName = lastName;
this.dob = dob;
}
public String getFirstName() {
return firstName;
}
public String getLastName() {
return lastName;
}
public LocalDate getDob() {
return dob;
}
// ...
}
The Person
class has two direct subclasses, Staff
and Student
.
public final class Student extends Person {
private final LocalDate graduation;
private Student(String firstName, String lastName, LocalDate dob, LocalDate graduation) {
super(firstName, lastName, dob);
this.graduation = graduation;
}
// Simple factory method
public static Student of(String firstName, String lastName, LocalDate dob, LocalDate graduation) {
return new Student(firstName, lastName, dob, graduation);
}
public LocalDate getGraduation() {
return graduation;
}
// equals, hashcode, and toString elided
}
You can serialize with driver code, which will be slightly more complex.
var dob = LocalDate.of(2002, Month.MARCH, 17);
var graduation = LocalDate.of(2023, Month.JUNE, 5);
var grant = Student.of("Grant", "Hughes", dob, graduation);
var mapper = new ObjectMapper()
.enable(SerializationFeature.INDENT_OUTPUT)
.registerModule(new JavaTimeModule());
try {
var json = mapper.writeValueAsString(grant);
System.out.println(json);
} catch (JsonProcessingException e) {
e.printStackTrace();
}
The code above produces the following output:
{
"firstName" : "Grant",
"lastName" : "Hughes",
"dob" : [ 2002, 3, 17 ],
"graduation" : [ 2023, 6, 5 ]
}
As mentioned earlier, Jackson still requires only Java 7 as a minimum version, and it’s geared around that version. This means that if your objects depend on Java 8 APIs directly (such as classes from java.time
), the serialization must use a specific Java 8 module (JavaTimeModule
). This class must be registered when the mapper is created—it is not available by default.
To handle that requirement, you will also need to add a couple of extra dependencies to the Jackson libraries’ default. Here they are for a Gradle build script (written in Kotlin).
implementation("com.fasterxml.jackson.core:jackson-databind:2.13.1")
implementation("com.fasterxml.jackson.module:jackson-modules-java8:2.13.1")
implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.13.1")
The first two examples made it look easy to use Jackson: You created an ObjectMapper
object, and the code was automatically able to understand the structure of the Student
object and render it into JSON.
However, in practice things are rarely this simple. Here are some real-world situations that can quickly arise when you use Jackson in actual production applications.
In some circumstances, you need to give Jackson a little help. For example, you might want or need to remap the field names from your class into different names in the serialized JSON. Fortunately, this is easy to do with annotations.
public class Person {
@JsonProperty("first_name")
private final String firstName;
@JsonProperty("last_name")
private final String lastName;
private final int age;
private final List<string> degrees;
public Person(String firstName, String lastName, int age, List<string> degrees) {
this.firstName = firstName;
this.lastName = lastName;
this.age = age;
this.degrees = degrees;
}
// ... getters for all fields
}
Your code will produce some output that looks like the following:
{
"age" : 19,
"degrees" : [ "BA Maths", "PhD" ],
"first_name" : "Grant",
"last_name" : "Hughes"
}
Note that the field names are now different from the JSON keys and that a List
of Java strings is being represented as a JSON array. This is the first usage of annotations in Jackson that you are seeing—but it won’t be the last.
Everything so far has involved serialization of Java objects to JSON. What happens when you want to go the other way? Fortunately, the ObjectMapper
provides a reading API as well as a writing API. Here is how the reading API works; this example also uses Java 17 text blocks, by the way.
var json = """
{
"firstName" : "Grant",
"lastName" : "Hughes",
"age" : 19
}""";
var mapper = new ObjectMapper();
try {
var grant = mapper.readValue(json, Person.class);
System.out.println(grant);
} catch (JsonProcessingException e) {
e.printStackTrace();
}
When you run this code, you’ll see some output like the following:
com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Cannot construct instance of 'javamag.jackson.ex5.Person' (no Creators, like default constructor, exist): cannot deserialize from Object value (no delegate- or property-based Creator)
at [Source: (String)"{
"firstName" : "Grant",
"lastName" : "Hughes",
"age" : 19
}"; line: 2, column: 3]
at com.fasterxml.jackson.databind.exc.InvalidDefinitionException.from(InvalidDefinitionException.java:67)
at com.fasterxml.jackson.databind.DeserializationContext.reportBadDefinition(DeserializationContext.java:1904)
// ...
at com.fasterxml.jackson.databind.ObjectMapper.readValue(ObjectMapper.java:3597)
at javamag.jackson.ex5.UniversityMain.main(UniversityMain.java:19)
What happened? Recall that ObjectMapper
expects getters for serialization—and it wants them to conform to the JavaBeans get/setFoo()
convention. ObjectMapper
also expects an accessible default constructor, that is, one that takes no parameters.
However, your Person
class has none of these things; in fact, all its fields are final
. This means setter methods would be totally impossible even if you cheated and added a default constructor to make Jackson happy.
How are you going to resolve this? You certainly aren’t going to warp your application’s object model to comply with the requirements of JavaBeans merely to get serialization to work. Annotations come to the rescue again: You can modify the Person
class as follows:
public class Person {
private final String firstName;
private final String lastName;
private final int age;
@JsonCreator(mode = JsonCreator.Mode.PROPERTIES)
public Person(@JsonProperty("first_name") String firstName,
@JsonProperty("last_name") String lastName,
@JsonProperty("age") int age) {
this.firstName = firstName;
this.lastName = lastName;
this.age = age;
}
@JsonProperty("first_name")
public String firstName() {
return firstName;
}
@JsonProperty("last_name")
public String lastName() {
return lastName;
}
@JsonProperty("age")
public int age() {
return age;
}
// other methods elided
}
With these hints, this piece of JSON will be correctly deserialized.
{
"first_name" : "Grant",
"last_name" : "Hughes",
"age" : 19
}
The two key annotations here are
@JsonCreator
, which labels a constructor or factory method that will be used to create new Java objects from JSON@JsonProperty
, which maps JSON field names to parameter locations for object creation or for serializationBy adding @JsonProperty
to your methods, these methods will be used to provide the values for serialization. If the annotation is added to a constructor or method parameter, it marks where the value for deserialization must be applied.
These annotations allow you to write simple code that can round-trip between JSON and Java objects, as follows:
var mapper = new ObjectMapper()
.enable(SerializationFeature.INDENT_OUTPUT);
try {
var grant = mapper.readValue(json, Person.class);
System.out.println(grant);
var parsedJson = mapper.writeValueAsString(grant);
System.out.println(parsedJson);
} catch (JsonProcessingException e) {
e.printStackTrace();
}
The first four examples explored two different approaches to Jackson serialization. The simplest approaches required no changes to your code but relied upon the existence of a default constructor and JavaBeans conventions. This may not be convenient for modern applications.
The second approach offered much more flexibility, but it relied upon the use of Jackson annotations, which means your code now has an explicit, direct dependency upon the Jackson libraries.
What if neither of these is an acceptable design constraint? The answer is custom serialization.
Consider the following class, which has no default constructor, immutable fields, a static factory, and Java’s record convention for getters:
public class Person {
private final String firstName;
private final String lastName;
private final int age;
private Person(String firstName, String lastName, int age) {
this.firstName = firstName;
this.lastName = lastName;
this.age = age;
}
public static Person of(String firstName, String lastName, int age) {
return new Person(firstName, lastName, age);
}
public String firstName() {
return firstName;
}
public String lastName() {
return lastName;
}
public int age() {
return age;
}
}
Suppose you cannot change this code or introduce a direct coupling to Jackson. That’s a real-world constraint: You may be working with a JAR file and might not have access to the source code of this class.
Here is a solution.
public class PersonSerializer extends StdSerializer<person> {
public PersonSerializer() {
this(null);
}
public PersonSerializer(Class<person> t) {
super(t);
}
@Override
public void serialize(Person value, JsonGenerator gen, SerializerProvider provider) throws IOException {
gen.writeStartObject();
gen.writeStringField("first_name", value.firstName());
gen.writeStringField("last_name", value.lastName());
gen.writeNumberField("age", value.age());
gen.writeEndObject();
}
}
Here is the driver code, with exception handling omitted to keep this example simple.
var grant = Person.of("Grant", "Hughes", 19);
var mapper = new ObjectMapper()
.enable(SerializationFeature.INDENT_OUTPUT);
var module = new SimpleModule();
module.addSerializer(Person.class, new PersonSerializer());
mapper.registerModule(module);
var json = mapper.writeValueAsString(grant);
System.out.println(json);
This example is very simple; in more-complex scenarios the need arises to traverse an entire object tree, rather than just handling simple string or primitive fields. Those requirements can significantly complicate the process of writing a custom serializer for your domain types.
To finish on an upbeat note: Jackson handles Java records seamlessly. The following code shows how it works; again, exception handling is omitted.
Public record Person(String firstName, String lastName, int age) {}
var grant = new Person("Grant", "Hughes", 19);
var mapper = new ObjectMapper()
.enable(SerializationFeature.INDENT_OUTPUT);
var json = mapper.writeValueAsString(grant);
System.out.println(json);
var obj = mapper.readValue(json, Person.class);
System.out.println(obj);
This code round-trips the grant
object without any problems whatsoever. Jackson’s record-handling capability, which is important for many modern applications, provides yet another great reason to upgrade your Java version and start building your domain models using records and sealed types wherever it is appropriate to do so.
Jackson is a popular JSON serialization library written in Java, which retains great backwards compatibility at the expense of a small amount of API complications.
Jackson provides three main modes of operation.
Despite the emphasis on backwards compatibility, Jackson has kept up with the latest Java versions and has good support for new language features such as records. This library is a solid bet for anyone choosing a JSON serialization framework, and it probably will be a fixture in the Java ecosystem for years to come.
Ben Evans (@kittylyst) is a Java Champion and Senior Principal Software Engineer at Red Hat. He has written five books on programming, including Optimizing Java (O'Reilly) and The Well-Grounded Java Developer (Manning). Previously he was Lead Architect for Instrumentation at New Relic, a founder of jClarity (acquired by Microsoft) and a member of the Java SE/EE Executive Committee.
Previous Post