Jackson - Controlling What You Don't Own
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 ignoreid
andfirstName
.
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 thecustomer
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.
Subscribe to my newsletter
Read articles from Chris Bath directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Chris Bath
Chris Bath
I'm Chris Bath, a technical lead specialising in full-stack development, solving complex technical problems, nurturing high-performing teams, and consulting companies to overcome their scaling needs.