How to customize JSON (de-)serialization with high-level, global converters.

Photo by Brendan Church, https://unsplash.com/photos/pKeF6Tt3c08

A common problem when mapping between raw JSON and Java objects is that your framework’s default handling of certain Java types does not behave as you want it to. For instance, you might want to tweak the output of the mapping, or you might even have to deal with a mapping that fails altogether.

Many proposed solutions on the web have one of the following drawbacks:

  • You have to re-invent the wheel by writing low-level mapping code (which is already provided by your mapping framework).
  • You have to add annotations wherever you use the problematic Java types, which
    • is repetitive and redundant
    • doesn’t work for third-party code that you can’t change

This article describes a simple solution to this problem for both Jakarta JSON Binding (JSON-B) and Jackson.

Example Types

Let’s take a look at the Java types that we want to convert to and from JSON:

public class Pirate {

  private String name;
  private NastyParrot parrot;
  
  public Pirate() {
  }
  
  public Pirate(String name, NastyParrot parrot) {
    this.name = name;
    this.parrot = parrot;
  }

  // getters and setters

}

Mapping this pirate would work perfectly fine, but his nasty parrot causes problems:

public class NastyParrot {

  private String name;

  public NastyParrot(String name) {
    this.name = name;
  }

  public String getName() {
    return name;
  }

}

This parrot doesn’t have a default constructor, which leads to a deserialization exception in both frameworks. Let’s look at this in action:

// pirate to be (de-)serialized
Pirate blackbeard = new Pirate(
    "Blackbeard", new NastyParrot("Polly"));

The following code uses JSON-B…

Jsonb jsonb = JsonbBuilder.create();

// serialization works
String blackbeardJson = jsonb.toJson(blackbeard);
    
// deserialization fails
Pirate blackbeard2 = jsonb.fromJson(
    blackbeardJson, Pirate.class);

…and results in the following exception:

jakarta.json.bind.JsonbException: Unable to deserialize property ‘parrot’ because of: Cannot create instance of a class: NastyParrot, No default constructor found.

Now let’s try Jackson…

ObjectMapper mapper = new ObjectMapper();
    
// serialization works
String blackbeardJson = mapper.writeValueAsString(blackbeard);
    
// deserialization fails
Pirate blackbeard2 = mapper.readValue(
    blackbeardJson, Pirate.class);

…with the following exception:

com.fasterxml.jackson.databind.exc.MismatchedInputException: Cannot construct instance of ‘NastyParrot’ (although at least one Creator exists): cannot deserialize from Object value (no delegate- or property-based Creator)

Here is how a good parrot would look like - it is identical to the nasty one, but provides an additional, default constructor:

public class GoodParrot {

  private String name;

  public GoodParrot() {
  }

  public GoodParrot(String name) {
    this.name = name;
  }

  public String getName() {
    return name;
  }

}

Assuming that changing the NastyParrot directly is not possible (because it is contained in a third-party library), we want to provide a piece of code that simply maps between NastyParrot and GoodParrot, and lets the framework do the rest. In other words, we want to tell the framework: “If you encounter a NastyParrot somewhere, treat it as if it was a GoodParrot, which won’t cause you any troubles. I will take care of converting between these two.”

JSON-B Adapter

In JSON-B, the conversion between good and nasty (unmappable) types is performed by a JsonbAdapter. For our parrots, this looks as follows:

public class ParrotAdapter 
implements JsonbAdapter<NastyParrot, GoodParrot> {

  @Override
  public GoodParrot adaptToJson(NastyParrot obj) throws Exception {
    return new GoodParrot(obj.getName());
  }

  @Override
  public NastyParrot adaptFromJson(GoodParrot obj) throws Exception {
    return new NastyParrot(obj.getName());
  }

}

The JsonbAdapter interface expects you to explicitly take care of both directions (serialization and deserialization), even though in our example, serialization works fine by default.

To register this adapter globally, we have to tweak the initialization from before by explicitly passing a JsonbConfig to the JsonbBuilder:

Jsonb jsonb = JsonbBuilder.create(
  new JsonbConfig().withAdapters(
    new ParrotAdapter()));

The result is that the following JSON representation (which popped out of the serialization even before introducing the adapter) can now also be deserialized successfully:

{
  "name": "Blackbeard",
  "parrot": {
    "name":"Polly"
  }
}

If your parrot needs additional customizations, you can apply them to the GoodParrot in the usual way. For instance, in order to serialize the “name” property as “call-me”, you can do so with a simple annotation:

  @JsonbProperty("call-me")
  private String name;

And if you need even more control over the mapping process, you can replace the GoodParrot inside the adapter with JsonObject, which is created using the Json factory class.

The full source code for this JSON-B example is available on GitHub.

Jackson Converter

With Jackson, we map between our parrot types using a Converter, or the more convenient utility class StdConverter:

public class ParrotConverter 
extends StdConverter<GoodParrot, NastyParrot> {

  @Override
  public NastyParrot convert(GoodParrot value) {
    return new NastyParrot(value.getName());
  }

}

In contrast to a JSON-B adapter, a single converter only deals with one direction (deserialization in our example). If we wanted to customize serialization as well, we would have to add a second converter with reversed order of generic arguments.

Even though it is possible to specify the converter as an attribute to the annotation JsonDeserialize, this annotation would have to be applied directly to the “parrot” attribute of our Pirate, which has the disadvantages mentioned at the beginning. To register the converter globally, we have to options. The first one is to put the converter into a deserializer, which is in turn embedded inside a module, which can then be registered to the object mapper:

ObjectMapper mapper = new ObjectMapper();
SimpleModule module = new SimpleModule();
module.addDeserializer(
  NastyParrot.class, 
  new StdDelegatingDeserializer<>(new ParrotConverter()));
mapper.registerModule(module);

The following, more cumbersome alternative works by providing the mapper with an annotation introspector, which returns our converter for the right type:

ObjectMapper mapper = new ObjectMapper();
mapper.setAnnotationIntrospector(new JacksonAnnotationIntrospector() {
  public Object findDeserializationConverter(Annotated a) {
    if (NastyParrot.class == a.getRawType()) {
      return new ParrotConverter();
    } else {
      return super.findDeserializationConverter(a);
    }
  }
});

And again, further customizations can be performed by applying the appropriate annotations to the GoodParrot class.

The full source code for this Jackson example is also available on GitHub.