Sometimes, you need to get your hands dirty.

In this case, that means you might need to manually manage the process of serializing and deserializing some properties yourself instead of letting Jackson do all the heavy lifting.

The classic example is with a date field. You might want it formatted a certain way. The default Jackson serialization/deserialization process won't cut it.

In this guide, I'll show you how create a custom serializer and deserializer that you can use in your Spring Boot application.

It's at the Property Level

First: understand that the solution I'll describe here works at the property level. In other words, it's not something you'd use if you're looking for a custom serialization/deserialization option for the whole object.

In fact, there's a 1:1 mapping between the solution I'm sharing here and a single property in the Java class. 

That's something you need to keep in mind as you're going through this guide.

An Example Class

Let's take a look at a simple Employee class:

public class Employee {

    private String id;    
    private String lastName;
    private String firstName;
    private Long employeeStartDate;
    
    public String getLastName() {
        return lastName;
    }

    public void setLastName(String lastName) {
        this.lastName = lastName;
    }

    public String getFirstName() {
        return firstName;
    }

    public void setFirstName(String firstName) {
        this.firstName = firstName;
    }

    public String getId() {
        return id;
    }

    public void setId(String id) {
        this.id = id;
    }
    
    public Long getEmployeeStartDate() {
        return employeeStartDate;
    }

    public void setEmployeeStartDate(Long employeeStartDate) {
        this.employeeStartDate = employeeStartDate;
    }

    public String toString() {
        return ReflectionToStringBuilder.toString(this);
    }
}

Note that the class above stores the employee start date as a Long object. It's the number of milliseconds since the current epoch began.

(The current epoch began on Jan. 1, 1970 UTC time, by the way.)

Now let's instantiate that class with some dummy data and serialize it:

LocalDate employmentDate = LocalDate.parse("2021-04-19");            
ZonedDateTime zdt = employmentDate.atStartOfDay(ZoneId.of("UTC"));

ObjectMapper mapper = new ObjectMapper();            

Employee employee = new Employee();
employee.setId("A17");
employee.setFirstName("Frank");
employee.setLastName("Smith");
employee.setEmployeeStartDate(zdt.toInstant().toEpochMilli());

System.err.println(mapper.writerWithDefaultPrettyPrinter().writeValueAsString(employee));

That creates an Employee object for an employee named Frank Smith whose first day of work started on 4/19/2021 UTC time.

If you need to work with another time zone, see my guide on converting from one time zone to another using the Java 8 date and time library.

FInally, the code serializes the object and outputs the JSON result:

{
  "id" : "A17",
  "lastName" : "Smith",
  "firstName" : "Frank",
  "employeeStartDate" : 1618790400000
}

But what the heck does that "employeeStartDate" number mean to anybody?

Not too many people can translate the number of milliseconds in the current epoch to a date.

In this case it's a great idea to serialize that field into something more human-readable.

Let's do that with @JsonSerialize.

But First, a New Class

Before we can use @JsonSerialize, though, we need to create a class that handles the custom serialization.

And here's what that class should look like:

public class StartDateSerializer extends JsonSerializer<Long> {
    
    static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("MM/dd/yyyy");

    @Override
    public void serialize(Long value, JsonGenerator gen, SerializerProvider provider) throws IOException {
        try {
            LocalDate date = Instant.ofEpochMilli(value)
                                    .atZone(ZoneId.of("UTC"))
                                    .toLocalDate();
            
            String formattedString = date.format(DATE_FORMATTER);

            gen.writeString(formattedString);
        } catch (DateTimeParseException e) {
            e.printStackTrace();
        }
    }
}

The class extends JsonSerializer. Unsurprisingly, that's the class used for custom serialization.

But... JsonSerializer isn't alone here. 

Nope. It's using a type parameter as well. That type is Long.

That means this serializer is expecting a Long input. That's fine because that's exactly the type used to specify the starting date in the Employee class.

That static DATE_TIME_FORMATTER specifies the desired output format for the starting date. In this case, it's "MM/dd/yyyy" format.

And yes, those letters are case-sensitive.

Next, the class overrides the serialize() method from the parent class. That's where it handles the custom serialization.

Note that the first parameter in serialize() must match the parameter type you saw earlier.

And, indeed, the first parameter in serialize() is a Long. So it matches.

Once more: that Long object represents the employee's starting date.

Look inside the try block and you'll see that the code:

  1. Converts the Long object to a LocalDate object
  2. Converts the LocalDate object to a formatted string that looks like "04/19/2021"
  3. Uses the passed in JsonGenerator object to spit out that string as the "employeeStartDate" property value

That's it. That's the class.

But now you have to tell Jackson to use that serializer for a specific property. That's where our friend @JsonSerialize comes into play.

Decorate the employeeStartDate field as follows:

...
    @JsonSerialize(using = StartDateSerializer.class)
    private Long employeeStartDate;
...

Take a look inside the parentheses next to the annotation and you'll see that it's referencing the class you just created.

Now run this serialization code again:

LocalDate employmentDate = LocalDate.parse("2021-04-19");            
ZonedDateTime zdt = employmentDate.atStartOfDay(ZoneId.of("UTC"));

ObjectMapper mapper = new ObjectMapper();            

Employee employee = new Employee();
employee.setId("A17");
employee.setFirstName("Frank");
employee.setLastName("Smith");
employee.setEmployeeStartDate(zdt.toInstant().toEpochMilli());

System.err.println(mapper.writerWithDefaultPrettyPrinter().writeValueAsString(employee));

And you should see this output:

{
  "id" : "A17",
  "lastName" : "Smith",
  "firstName" : "Frank",
  "employeeStartDate" : "04/19/2021"
}

There you go. The employee start date is now formatted and much more readable.

But What About the Other Way?

Okay, so you've got the serialization process figured out. But what about the deserialization process?

That's just as easy.

Start with a deserialization class:

public class StartDateDeserializer extends JsonDeserializer<Long> {
    
    static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("MM/dd/yyyy");

    @Override
    public Long deserialize(JsonParser p, DeserializationContext ctx) throws IOException {
        
        LocalDate localDate = LocalDate.parse(p.getText(), DATE_FORMATTER);
        
        ZonedDateTime zdt = localDate.atStartOfDay(ZoneId.of("UTC"));
        
        Long millis = zdt.toInstant().toEpochMilli();
        
        return millis;
    }
}

That's pretty much the reverse of the serializer class.

Once again: it's using a type parameter. This time, the type parameter must match return type of the deserialize() method.

And, sure enough, it does. They're both Long types.

That deserialize() method just takes in the formatted date string and translates it to the number of milliseconds since the epoch began. That's not hard at all.

Now just add a @JsonDeserialize annotation to the Employee class like so:

...
    @JsonSerialize(using = StartDateSerializer.class)
    @JsonDeserialize(using = StartDateDeserializer.class)
    private Long employeeStartDate;
...

And then run this bit of deserialization code to test it out:

ObjectMapper mapper = new ObjectMapper();  
            
String json = "{\r\n"
        + "  \"id\" : \"A17\",\r\n"
        + "  \"lastName\" : \"Smith\",\r\n"
        + "  \"firstName\" : \"Frank\",\r\n"
        + "  \"employeeStartDate\" : \"04/19/2021\"\r\n"
        + "}";
            
Employee employee = mapper.readerFor(Employee.class).readValue(json);
System.err.println(employee);

And you'll get this result:

Employee@75d4a5c2[employeeStartDate=1618790400000,firstName=Frank,id=A17,lastName=Smith]

Awesome, the employee start date is back in Long format.

Wrapping It Up

There you go. Now you know how to create custom serializers and deserializers.

But chances are pretty good that you don't need to translate a Long object into a formatted date string. So you'll have to adapt what you've learned here to your own requirements.

Fortunately, all you need to do is follow the same pattern I used here and throw in your own custom logic. Then you're done.

Have fun!

Photo by ArtHouse Studio from Pexels