Jackson’s serialization isn’t just about turning objects into JSON; it’s about precise control over how that transformation happens, often in ways that defy initial intuition.
Let’s see it in action. Imagine you have a User object and you want to serialize it, but with specific fields included or excluded, and maybe even renamed.
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
class User {
private String username;
private String email;
private int age;
public User(String username, String email, int age) {
this.username = username;
this.email = email;
this.age = age;
}
public String getUsername() { return username; }
public String getEmail() { return email; }
public int getAge() { return age; }
}
public class SerializationDemo {
public static void main(String[] args) throws Exception {
User user = new User("johndoe", "john.doe@example.com", 30);
ObjectMapper mapper = new ObjectMapper();
// Default serialization
System.out.println("Default:\n" + mapper.writeValueAsString(user));
// Using @JsonProperty to rename fields
@JsonIgnoreProperties({"age"}) // Let's ignore age for this specific user object
class UserWithRenamedField extends User {
public UserWithRenamedField(String username, String email, int age) {
super(username, email, age);
}
@JsonProperty("user_name")
@Override
public String getUsername() {
return super.getUsername();
}
}
UserWithRenamedField userRenamed = new UserWithRenamedField("janedoe", "jane.doe@example.com", 25);
System.out.println("\nWith @JsonProperty and @JsonIgnoreProperties:\n" + mapper.writeValueAsString(userRenamed));
}
}
Running this code produces:
Default:
{"username":"johndoe","email":"john.doe@example.com","age":30}
With @JsonProperty and @JsonIgnoreProperties:
{"user_name":"janedoe","email":"jane.doe@example.com"}
This demonstrates how annotations directly on your POJOs or on extending classes can sculpt the JSON output. The ObjectMapper is the core engine, but it’s these annotations that guide its behavior, allowing you to selectively expose, rename, or even completely hide fields without altering the underlying object’s structure.
Jackson’s power lies in its modularity. You can achieve complex serialization logic by composing different components. At its heart, Jackson uses ObjectMapper as the primary interface. This ObjectMapper configures and uses SerializerFactory instances, which in turn create JsonSerializer objects. For basic POJOs, Jackson inspects the class at runtime, identifies getters (or public fields), and uses default serializers. Annotations like @JsonProperty, @JsonIgnore, and @JsonInclude are checked during this inspection, modifying the default serialization behavior.
When you need more than annotations can offer, you turn to SimpleModule. A SimpleModule allows you to register custom serializers or deserializers for specific types. This is crucial for handling complex types like Date, Enum, or custom data structures where the default JSON representation isn’t suitable. You create a class that extends SimpleModule and then use its addSerializer method.
import com.fasterxml.jackson.databind.module.SimpleModule;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.ser.std.StdSerializer;
import java.io.IOException;
import java.util.Date;
class Event {
private String name;
private Date timestamp;
public Event(String name, Date timestamp) {
this.name = name;
this.timestamp = timestamp;
}
public String getName() { return name; }
public Date getTimestamp() { return timestamp; }
}
class CustomDateSerializer extends StdSerializer<Date> {
public CustomDateSerializer() {
super(Date.class);
}
@Override
public void serialize(Date value, JsonGenerator gen, SerializerProvider provider) throws IOException {
// Serialize Date as a Unix timestamp (long)
gen.writeNumber(value.getTime());
}
}
// ... inside main method ...
ObjectMapper mapperWithModule = new ObjectMapper();
SimpleModule module = new SimpleModule();
module.addSerializer(Date.class, new CustomDateSerializer());
mapperWithModule.registerModule(module);
Event event = new Event("System Start", new Date());
System.out.println("\nWith Custom Date Serializer:\n" + mapperWithModule.writeValueAsString(event));
This would output something like:
With Custom Date Serializer:
{"name":"System Start","timestamp":1678886400000}
Here, the CustomDateSerializer intercepts any Date object and converts it into its millisecond-since-epoch representation, overriding Jackson’s default ISO 8601 string format. You then register this module with the ObjectMapper.
The real magic of Jackson’s customization comes from understanding how the SerializerFactory and SerializerProvider interact. When writeValueAsString is called, the ObjectMapper asks its SerializerFactory for a serializer for the given type. The factory consults annotations and, if a custom serializer is registered for that type (via a module), it uses that. The SerializerProvider then passes this serializer the value to be serialized. This layered approach means you can have global rules (modules) and type-specific rules (annotations) work together seamlessly.
What most people miss is that you can also create serializers that wrap existing serializers. This is incredibly powerful for adding cross-cutting concerns like logging or validation around the default serialization logic without reimplementing the core serialization for the type. You can achieve this by extending StdSerializer and in your serialize method, calling provider.defaultSerialize(value, gen) to delegate to the default serializer if you need to. This allows for incremental customization.
The next step in mastering Jackson is exploring how to handle polymorphic serialization, where the runtime type of an object needs to be determined and serialized correctly, often involving type information embedded in the JSON.