Introduction

Jackson is the de facto standard for JSON processing in Java and Spring Boot integrates it seamlessly. From customizing serialization to ignoring fields or using views, Jackson gives us a powerful toolbox for shaping JSON exactly the way we need.

Standard everyday use

When you create a REST API, Jackson handles the conversion between Java objects and JSON behind the scenes. Here is a simple example:

import java.util.Map;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class Simple {
  @GetMapping("/hello")
  public Map<String, String> helloWorld() {
    return Map.of("message", "Hello, world!");
  }
}

Spring will automatically convert this response to JSON using Jackson.

Customize Field Names During Serialization and Deserialization

Annotation: com.fasterxml.jackson.annotation.JsonProperty

What Does It Do?

The @JsonProperty annotation in Jackson allows you to define the exact name that a field should be mapped to in JSON during both serialization and deserialization. This overrides the default behavior, where Jackson uses the Java field name.

Common Use Cases

This simple functionality provides powerful value to real-world applications:

  1. Field naming conventions: Java code often uses camelCase, but many APIs or data consumers expect snake_case.
  2. Translation and external compatibility: Sometimes your JSON field names come from a third-party system or need to be localized.
  3. Backward compatibility: If a DTO field changes in Java but the external API contract must remain the same.
  4. Aliases: A field name can be descriptive in code while remaining compact in JSON.

Implementation:

import com.fasterxml.jackson.annotation.JsonProperty;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

record JsonPropertyExample(@JsonProperty("first_name") String firstName) {}

@RestController
class JsonPropertyExampleController {
  @GetMapping("/json-property")
  public JsonPropertyExample getJsonPropertyExample() {
    return new JsonPropertyExample("John Doe");
  }
}

Example:

@Test
void shouldSucceed() throws Exception {
  mockMvc
      .perform(get("/json-property"))
      .andExpect(status().isOk())
      .andExpect(content().contentType(MediaType.APPLICATION_JSON))
      .andExpect(jsonPath("$.first_name").value("John Doe"));
}

Exclude Specific Fields from JSON Serialization and Deserialization

Annotation: com.fasterxml.jackson.annotation.JsonIgnore

What Does It Do?

The @JsonIgnore annotation in Jackson allows you to exclude specific fields from being serialized or deserialized. This is useful when you need to stop sending a field in an API response or avoid populating it from incoming JSON without removing the field from the class.

Use Case: Updating an API Contract

Let’s say you’re updating an API and need to stop sending a certain field in the response JSON, but removing the field from the class itself would require extensive refactoring because it is still used elsewhere in the code. In this case, @JsonIgnore is a clean solution.

Implementation:

import com.fasterxml.jackson.annotation.JsonIgnore;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

record JsonIgnoreExample(String visibleField, @JsonIgnore String ignoredField) {}

@RestController
class JsonIgnoreExampleController {
  @GetMapping("/json-ignore")
  public JsonIgnoreExample getJsonIgnoreExample() {
    return new JsonIgnoreExample("visible", "ignored");
  }
}

Example:

@Test
void shouldSucceed() throws Exception {
  mockMvc
      .perform(get("/json-ignore"))
      .andExpect(status().isOk())
      .andExpect(jsonPath("$.visibleField").value("visible"))
      .andExpect(jsonPath("$.ignoredField").doesNotExist());
}

Control Which Fields Are Included in JSON Output Based on Their Values

Annotation: com.fasterxml.jackson.annotation.JsonInclude

What Does It Do?

The @JsonInclude annotation in Jackson allows you to control which fields should be included in JSON output during serialization. A common use case is excluding fields that have null values, preventing empty fields from being included in the response.

Use Case

Imagine a DTO class with many fields, some of which are not populated due to user role restrictions. Rather than sending null values for unauthorized fields, you can use @JsonInclude to exclude those fields entirely.

Implementation:

import com.fasterxml.jackson.annotation.JsonInclude;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@JsonInclude(JsonInclude.Include.NON_NULL)
record JsonIncludeExample(String nullField, String notNullField) {}

@RestController
class JsonIncludeExampleController {
  @GetMapping("/json-include")
  public JsonIncludeExample getJsonIncludeExample() {
    return new JsonIncludeExample(null, "Not Null Field");
  }
}

Example:

@Test
void shouldSucceed() throws Exception {
  mvc.perform(MockMvcRequestBuilders.get("/json-include"))
      .andExpect(status().isOk())
      .andExpect(jsonPath("$.notNullField").value("Not Null Field"))
      .andExpect(jsonPath("$.nullField").doesNotExist());
}

Define Custom Formatting for Dates, Times and Other Values in JSON

Annotation: com.fasterxml.jackson.annotation.JsonFormat

What Does It Do?

The @JsonFormat annotation in Jackson allows you to customize how certain values are serialized into and deserialized from JSON. It is especially useful for dates, times and values that need a custom representation.

Use Case

Let’s say you want to handle a collection of clocks, each showing the date and time in a different time zone. Instead of manually converting each time zone, you can use Jackson’s @JsonFormat to handle the conversion for you.

Implementation:

record JsonFormatExample(
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Kolkata") Date kolkataDateTime,
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Europe/London") Date londonDateTime,
    @JsonFormat(shape = JsonFormat.Shape.NUMBER) Date anotherDateTime) {
  JsonFormatExample() {
    this(new Date(), new Date(), new Date());
  }
}

@RestController
class JsonFormatExampleController {
  @GetMapping("/json-format")
  public JsonFormatExample getJsonFormat() {
    return new JsonFormatExample();
  }
}

Example:

@Test
void testJsonInclude() throws Exception {
  MvcResult result =
      mvc.perform(MockMvcRequestBuilders.get("/json-format"))
          .andExpect(status().isOk())
          .andExpect(jsonPath("$.kolkataDateTime").exists())
          .andExpect(jsonPath("$.londonDateTime").exists())
          .andExpect(jsonPath("$.anotherDateTime").exists())
          .andExpect(jsonPath("$.anotherDateTime").isNumber())
          .andReturn();

  String responseBody = result.getResponse().getContentAsString();

  LocalDateTime kolkataTime =
      LocalDateTime.parse(JsonPath.read(responseBody, "$.kolkataDateTime"), formatter);
  LocalDateTime londonTime =
      LocalDateTime.parse(JsonPath.read(responseBody, "$.londonDateTime"), formatter);

  assertThat(kolkataTime)
      .as("Kolkata time should be 4 hours and 30 minutes ahead of London")
      .isAfter(londonTime)
      .isCloseTo(londonTime.plusHours(4).plusMinutes(30), within(1, ChronoUnit.HOURS));
}

Flatten Nested Objects into Parent JSON Structure During Serialization

Annotation: com.fasterxml.jackson.annotation.JsonUnwrapped

What Does It Do?

In JSON serialization, objects are often nested within one another. Jackson provides the @JsonUnwrapped annotation to flatten these nested objects, merging their properties directly into the parent object during serialization.

Use Case

For example, let’s say you have a Person object that contains an Address object, and you want to flatten the address fields into the person object during serialization.

Implementation:

record JsonUnwrappedExample(String name, @JsonUnwrapped Address address) {}

record Address(String street, String city) {}

@RestController
class JsonUnwrappedExampleController {
  @GetMapping("/json-unwrapped")
  public JsonUnwrappedExample getJsonUnwrapped() {
    return new JsonUnwrappedExample("A good name", new Address("A nice street", "A peaceful city"));
  }
}

Example:

@Test
void getJsonUnwrapped() throws Exception {
  mockMvc
      .perform(get("/json-unwrapped"))
      .andExpect(status().isOk())
      .andExpect(jsonPath("$.name").value("A good name"))
      .andExpect(jsonPath("$.street").value("A nice street"))
      .andExpect(jsonPath("$.city").value("A peaceful city"));
}

Handling Dynamic Key-Value Pairs in JSON Serialization and Deserialization

Annotations:

  • com.fasterxml.jackson.annotation.JsonAnyGetter
  • com.fasterxml.jackson.annotation.JsonAnySetter

What Do They Do?

In many cases, you might need to work with dynamic or unknown properties in a JSON object. This happens when you don’t know all the keys in advance but still need to serialize or deserialize them. Jackson provides two annotations to handle these dynamic key-value pairs.

@JsonAnyGetter tells Jackson how to serialize dynamic properties of an object into JSON, usually from a method that returns a map.

@JsonAnySetter tells Jackson how to handle dynamic key-value pairs during deserialization by associating a method with arbitrary incoming properties.

Implementation:

record JsonAnyGetterSetterExample(String name, Map<String, Object> properties) {
  @JsonAnyGetter
  public Map<String, Object> getProperties() {
    return properties;
  }

  @JsonAnySetter
  public void setProperty(String name, Object value) {
    properties.put(name, value);
  }
}

@RestController
class JsonAnyGetterSetterController {
  @PostMapping("/json-any-getter-setter")
  public JsonAnyGetterSetterExample anyGetterSetter(@RequestBody JsonAnyGetterSetterExample data) {
    return data;
  }
}

Example:

@Test
void testAnyGetterSetter() throws Exception {
  JsonAnyGetterSetterExample input =
      new JsonAnyGetterSetterExample(
          "John Doe",
          objectMapper.readValue(
              """
              {
                "age": 30,
                "city": "New York",
                "address": {
                  "street": "123 Main St",
                  "zip": "10001"
                }
              }
              """,
              objectMapper
                  .getTypeFactory()
                  .constructMapType(Map.class, String.class, Object.class)));

  mockMvc
      .perform(
          post("/json-any-getter-setter")
              .contentType(MediaType.APPLICATION_JSON)
              .content(objectMapper.writeValueAsString(input)))
      .andExpect(status().isOk())
      .andExpect(jsonPath("$.name").value("John Doe"))
      .andExpect(jsonPath("$.age").value(30))
      .andExpect(jsonPath("$.city").value("New York"))
      .andExpect(jsonPath("$.address.street").value("123 Main St"))
      .andExpect(jsonPath("$.address.zip").value("10001"));
}

Enable Polymorphic Type Handling in JSON Serialization and Deserialization

Annotations:

  • com.fasterxml.jackson.annotation.JsonSubTypes
  • com.fasterxml.jackson.annotation.JsonTypeInfo

What Do They Do?

When dealing with inheritance in Java, it is common to have a base class with multiple subclasses. In JSON serialization and deserialization, Jackson provides annotations to handle polymorphic types correctly. This allows Jackson to work with a base class reference while still serializing and deserializing the actual subclass type.

@JsonTypeInfo includes metadata in the JSON that tells Jackson how to differentiate between different types in a polymorphic hierarchy.

@JsonSubTypes works with it to specify the possible subclasses available during deserialization.

Implementation:

interface Device {}

record Laptop(String battery) implements Device {}

record Desktop(String monitor) implements Device {}

record Computer<T extends Device>(
    @JsonTypeInfo(
            use = JsonTypeInfo.Id.NAME,
            property = "type",
            include = JsonTypeInfo.As.EXTERNAL_PROPERTY)
        @JsonSubTypes({
          @JsonSubTypes.Type(value = Laptop.class, name = "Laptop"),
          @JsonSubTypes.Type(value = Desktop.class, name = "Desktop")
        })
        T device) {}

@RestController
class JsonTypeSubTypeExampleController {
  @PostMapping("/json-type")
  public String jsonType(@RequestBody Computer<? extends Device> computer) {
    return switch (computer.device()) {
      case Laptop laptop -> "It is a Laptop!";
      case Desktop desktop -> "It is a Desktop!";
      default -> "Unknown device!";
    };
  }
}

Example:

private static Stream<Arguments> deviceProvider() {
  return Stream.of(
      Arguments.of("Laptop", new Laptop("60Wh"), "It is a Laptop!"),
      Arguments.of("Desktop", new Desktop("32 inch"), "It is a Desktop!"));
}

@ParameterizedTest
@MethodSource("deviceProvider")
void shouldSucceed(String type, Device device, String expectedResponse) throws Exception {
  String requestBody =
      """
      {
        "type": "%s",
        "device": %s
      }
      """
          .formatted(type, objectMapper.writeValueAsString(device));

  mockMvc
      .perform(post("/json-type").contentType(MediaType.APPLICATION_JSON).content(requestBody))
      .andExpect(status().isOk())
      .andExpect(content().string(expectedResponse));
}

Customizing the Serialization Process for Objects in Jackson

Annotation: com.fasterxml.jackson.databind.annotation.JsonSerialize

What Does It Do?

By default, Jackson handles the serialization and deserialization of objects based on standard conventions. However, some cases require more control over the output format. Jackson provides the @JsonSerialize annotation so you can specify a custom serializer.

Implementation:

class GreetingSerializer extends JsonSerializer<JsonSerializeExample> {
  @Override
  public void serialize(
      JsonSerializeExample value, JsonGenerator gen, SerializerProvider serializers)
      throws IOException {
    gen.writeStartObject();
    gen.writeFieldName("greetings");
    gen.writeString("Hello, " + value.name());
    gen.writeEndObject();
  }
}

@JsonSerialize(using = GreetingSerializer.class)
record JsonSerializeExample(String name) {}

@RestController
class CustomSerializerExampleController {
  @PostMapping("/json-serialize")
  public JsonSerializeExample getJsonSerialize(
      @RequestBody JsonSerializeExample jsonSerializeExample) {
    return jsonSerializeExample;
  }
}

Example:

@Test
void shouldSucceed() throws Exception {
  String requestBody =
      """
      {
        "name": "John"
      }
      """;

  mockMvc
      .perform(
          post("/json-serialize").contentType(MediaType.APPLICATION_JSON).content(requestBody))
      .andExpect(status().isOk())
      .andExpect(jsonPath("$.greetings").value("Hello, John"));
}

Conclusion

Jackson annotations are incredibly useful because they give you fine-grained control over how your Java objects are converted to and from JSON. Instead of relying solely on Jackson’s default behavior, you can precisely shape the JSON structure and content to match the needs of your application and the systems it interacts with.

Further Reading