Jackson - Controlling What You Don't Own

Jackson - Controlling What You Don't Own

ยท

6 min read

This post explores how to customise JSON output using several Jackson features and Spring Boot . Even when you can't modify class definitions, learn valuable techniques like mixin and delegates .

Use case

We have the following class definition:

public record Customer(int id, String firstName, Address address) {}
public record Address(String streetName, String postcode) {}

With the following instance:

var customer = new Customer(1, "Maeve",
        new Address("A Street Name", "This is a postcode"));

We want to generate the following JSON:

{
  "name": "Maeve",
  "address": {
    "streetName": "A Street Name",
    "postcode": "This is a postcode"
  }
}

When we pass the customer into the ObjectMapper:

@Autowired ObjectMapper mapper;
String json = mapper.writeValueAsString(customer);

We would end up with the following output:

{
  "id": 1,
  "firstName": "Maeve",
  "address": {
    "streetName": "A Street Name",
    "postcode": "This is a postcode"
  }
}

We have produced a JSON output. However, there is an additional field id and firstName should be name.

We can resolve this using @JsonIgnore on the id field and @JsonProperty("name") on the firstName.

public record Customer(@JsonIgnore int id,
                       @JsonProperty("name") String firstName, Address address) {}

We now produce the target JSON:

{
  "name": "Maeve",
  "address": {
    "streetName": "A Street Name",
    "postcode": "This is a postcode"
  }
}

The above will solve most of our day-to-day use cases, PR raised, code merged, and deployed into production ๐Ÿš€๐Ÿš€๐Ÿš€.

However, what if Customer is in a library that we can't modify? Let's take a look at four options to overcome this challenge.

Options

Let's explore some options on how we can overcome this problem.

1. Delegate

One approach is to use a delegate to apply our desired customisation.

public record CustomerWrapper(@JsonUnwrapped @JsonIgnoreProperties({"id", "firstName"}) @Getter Customer customer) {
    @JsonProperty
    public String name() {
        return customer.firstName();
    }
}

We have used the following Jackson features.

  • @JsonIgnoreProperties to ignore id and firstName.

The output changes from:

{
  "customer": {
    "id": 1,
    "firstName": "Maeve",
    "address": {
      "streetName": "A Street Name",
      "postcode": "This is a postcode"
    }
  }
}

to

{
  "customer": {
    "address": {
      "streetName": "A Street Name",
      "postcode": "This is a postcode"
    }
  }
}
  • @JsonUnwrapped brings the customer properties up one level in the JSON tree and removes the wrapping key.

The output changes from:

{
  "customer": {
    "address": {
      "streetName": "A Street Name",
      "postcode": "This is a postcode"
    }
  }
}

to

{
  "address": {
    "streetName": "A Street Name",
    "postcode": "This is a postcode"
  }
}

Now, when we pass the CustomerWrapper instance to the ObjectMapper and get the desired JSON.

{
  "name": "Maeve",
  "address": {
    "streetName": "A Street Name",
    "postcode": "This is a postcode"
  }
}

A delegate can be helpful in modifying behaviour. However, there are a few things to keep in mind.

  • Increase in memory as you must create a 1-1 mapping for every Customer instance.
  • Nested field customisation can be tricky, e.g. changing address fields.
  • It is only sometimes possible to use a delegate if customisation is at the root of the object tree.

2. Mixins

Another approach is to use the Jackson mixin feature. Doing so lets you define an interface or abstract class to achieve the desired outcome.

First, define and register the mixin.

@JsonMixin(Customer.class)
public abstract class CustomerMixin {
    @JsonProperty("name")
    String firstName;
    @JsonIgnore
    abstract int id();
}

Then, pass the Customer instance to the ObjectMapper, and we get the desired JSON.

{
  "name": "Maeve",
  "address": {
    "streetName": "A Street Name",
    "postcode": "This is a postcode"
  }
}

What is going on?

The best way to visualise the mixin is that it has merged the annotations with the desired type, so @JsonProperty("name") String firstName; on the CustomerMixin would act as if we had put this annotation on the Customer directly.

With this in mind, we can use any Jackson annotation on the CustomerMixin, which gives us all the same control as if Customer was entirely in our possession.

Spring Boot

You may have noticed the @JsonMixin(Customer.class) annotation. Spring Boot allows us to auto-register our mixin with the ObjectMapper.

Without this auto-register, we would need to register the mixin manually:

ObjectMapper mapper = new ObjectMapper();
mapper.addMixIn(Customer.class, CustomerMixin.class);

Hiding properties

One thing to remember is with the default ObjectMapper settings, if we were to add new fields to Customer, these are shown in the JSON output without any changes to the CustomerMixin.

To prevent this behaviour, we could configure the @JsonAutoDetect for the CustomerMixin to only render fields for which we have included a configuration.

@JsonAutoDetect(
        fieldVisibility = Visibility.NONE,
        setterVisibility = Visibility.NONE,
        getterVisibility = Visibility.NONE,
        isGetterVisibility = Visibility.NONE,
        creatorVisibility = Visibility.NONE
)
@JsonMixin(Customer.class)
public abstract class CustomerMixin {
    @JsonProperty("name")
    String firstName;
}
{
  "name": "Maeve"
}

3. JSON Serializer

Rather than using the annotation-driven approach, we could implement a JsonSerializer that programmatically allows us to build up the JSON output.

First, we need to define and register the JsonSerializer.

@JsonComponent
public class CustomerJsonSerializer extends JsonSerializer<Customer> {
    @Override
    public void serialize(final Customer value, final JsonGenerator gen, final SerializerProvider serializers) throws IOException {
        gen.writeStartObject();
        gen.writeStringField("name", value.firstName());
        gen.writePOJOField("address", value.address());
        gen.writeEndObject();
    }
}

With the above, we pass the Customer instance to the ObjectMapper and get the desired JSON.

{
  "name": "Maeve",
  "address": {
    "streetName": "A Street Name",
    "postcode": "This is a postcode"
  }
}

This approach gives you the most control over defining the target JSON format in an imperative style.

One downside of this approach is requiring a separate JsonDeserializer to convert the JSON to an Object.

Spring Boot

You may have noticed the @JsonComponent annotation. Spring Boot allows us to auto-register our serialiser with the ObjectMapper.

Without this auto-register, we would need to register the serialiser manually:

ObjectMapper mapper = new ObjectMapper();
SimpleModule module = new SimpleModule();
module.addSerializer(Customer.class, new CustomerJsonSerializer());
mapper.registerModule(module);

4. Data Transfer Object (DTO)

The final approach we will cover today is separating the concerns of the domain model from the transfer model.

With this approach, first, we need two new class definitions, CustomerDto and AddressDto. These are responsible for the target JSON format.

public record CustomerDto(String name, AddressDto address) {}
public record AddressDto(String streetName, String postcode) {}

Then use mapping code to translate the Customer and Address into CustomerDto and AddressDto.

public CustomerDto convert(final Customer customer) {
    return new CustomerDto(customer.firstName(), 
        new AddressDto(customer.address().streetName(), customer.address().postcode()))
}

With the above, we pass the CustomerDto instance to the ObjectMapper and get the desired JSON.

{
  "name": "Maeve",
  "address": {
    "streetName": "A Street Name",
    "postcode": "This is a postcode"
  }
}

This approach is an excellent way to separate the different concerns. However, you can end up with a lot of similar code and boilerplate, such as the mappings (though tools like MapStructs can help).

Closing Thoughts

We have covered a few options for converting objects into JSON when we don't control the class definitions.

Each option has benefits and trade-offs to consider when solving a problem.

I prefer the mixin approach for most cases.

A few reasons are:

  • I can continue to use a declarative approach with support of the existing annotations provided by Jackson.
  • I don't need to create new object instances to delegate/convert to, which reduces some of the garbage generated.
  • I can use the mixin class definition as the contract and documentation of the generated JSON.

What are your thoughts? What approaches do you use? Do let us know!

Thank you for reading.

ย