Spring BootJSON Handling
Spring Boot

JSON Handling

Spring Boot uses Jackson as its default JSON library, auto-configured through spring-boot-starter-web. This entry covers Jackson configuration, custom serializers/deserializers, date/time handling, ignoring unknown fields, renaming fields, polymorphic types, and common pitfalls when shaping JSON for REST APIs.

Jackson Auto-Configuration

Spring Boot auto-configures a single ObjectMapper bean via JacksonAutoConfiguration. Declare a Jackson2ObjectMapperBuilderCustomizer bean to tune it globally — or replace it entirely by registering your own ObjectMapper bean. Never create a second ObjectMapper manually; always customize the one Spring manages.
Java
// ── Option 1: Customise via builder (preferred) ───────────────────────
@Configuration
public class JacksonConfig {

    @Bean
    public Jackson2ObjectMapperBuilderCustomizer jacksonCustomizer() {
        return builder -> builder
            .featuresToDisable(
                SerializationFeature.WRITE_DATES_AS_TIMESTAMPS,   // ISO-8601 dates
                DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES // tolerate extra fields
            )
            .featuresToEnable(
                MapperFeature.DEFAULT_VIEW_INCLUSION
            )
            .modules(new JavaTimeModule())   // java.time support
            .timeZone(TimeZone.getTimeZone("UTC"));
    }
}

// ── Option 2: application.yml (no code needed for common settings) ─────
// spring:
//   jackson:
//     serialization:
//       write-dates-as-timestamps: false
//     deserialization:
//       fail-on-unknown-properties: false
//     default-property-inclusion: non_null
//     time-zone: UTC

// ── Option 3: Replace the ObjectMapper entirely ────────────────────────
@Bean
@Primary
public ObjectMapper objectMapper() {
    return JsonMapper.builder()
        .addModule(new JavaTimeModule())
        .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
        .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
        .serializationInclusion(JsonInclude.Include.NON_NULL)
        .build();
}

Shaping JSON with Jackson Annotations

Jackson annotations let you control exactly how Java fields map to JSON keys — without changing your domain model. The most commonly used ones are @JsonProperty, @JsonIgnore, @JsonInclude, @JsonAlias, and @JsonNaming.
Java
// ── Rename a field ────────────────────────────────────────────────────
public record UserResponse(
    @JsonProperty("user_id")   Long id,        // serializes as "user_id"
    @JsonProperty("full_name") String name,
    String email
) {}

// ── Ignore a field ────────────────────────────────────────────────────
public class UserEntity {
    private Long id;
    private String name;

    @JsonIgnore                          // never appears in JSON output or input
    private String passwordHash;
}

// ── Exclude null / empty values ───────────────────────────────────────
@JsonInclude(JsonInclude.Include.NON_NULL)   // omit null fields for this class
public record ProductResponse(
    Long id,
    String name,
    String description,    // omitted from JSON when null
    BigDecimal discount     // omitted from JSON when null
) {}

// ── Accept multiple input names (aliases) ─────────────────────────────
public record CreateUserRequest(
    @JsonAlias({"full_name", "fullName", "name"}) String name,
    String email
) {}

// ── Snake_case for the whole class ────────────────────────────────────
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public record OrderResponse(
    Long orderId,           // → "order_id"
    LocalDateTime createdAt // → "created_at"
) {}

// ── Flatten a nested object ───────────────────────────────────────────
public class AddressResponse {
    @JsonUnwrapped              // street/city/zip appear at the top level
    private Address address;
    private String phone;
}

Date and Time Handling

The JavaTimeModule (from jackson-datatype-jsr310, included in spring-boot-starter-web) handles java.time types. Disable WRITE_DATES_AS_TIMESTAMPS to get ISO-8601 strings instead of epoch arrays. Use @JsonFormat on individual fields for per-field patterns.
Java
// ── Global config (application.yml) ──────────────────────────────────
// spring:
//   jackson:
//     serialization:
//       write-dates-as-timestamps: false   # "2024-03-15T10:30:00Z" not [2024,3,15,...]
//     time-zone: UTC

// ── DTO with java.time fields ─────────────────────────────────────────
public record EventResponse(
    Long id,
    String title,

    @JsonFormat(shape = JsonFormat.Shape.STRING,
                pattern = "yyyy-MM-dd",
                timezone = "UTC")
    LocalDate eventDate,           // → "2024-03-15"

    @JsonFormat(shape = JsonFormat.Shape.STRING,
                pattern = "yyyy-MM-dd'T'HH:mm:ss'Z'",
                timezone = "UTC")
    LocalDateTime createdAt,       // → "2024-03-15T10:30:00Z"

    Instant updatedAt              // → "2024-03-15T10:30:00Z" (Instant is ISO by default)
) {}

// ── Deserializing dates from a request body ───────────────────────────
public record CreateEventRequest(
    String title,

    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd")
    LocalDate eventDate,           // accepts "2024-03-15" in request JSON

    @JsonDeserialize(using = LocalDateTimeDeserializer.class)
    LocalDateTime startsAt
) {}

Custom Serializers and Deserializers

When annotations are not enough — for example, to format a BigDecimal as a currency string, or to parse a non-standard date format — write a custom JsonSerializer or JsonDeserializer and register it on the field or globally via a Jackson Module.
Java
// ── Custom serializer: BigDecimal → "$1,234.56" ───────────────────────
public class MoneySerializer extends JsonSerializer<BigDecimal> {

    private static final NumberFormat FMT =
        NumberFormat.getCurrencyInstance(Locale.US);

    @Override
    public void serialize(BigDecimal value, JsonGenerator gen,
                          SerializerProvider provider) throws IOException {
        gen.writeString(FMT.format(value));
    }
}

// ── Custom deserializer: "$1,234.56" → BigDecimal ─────────────────────
public class MoneyDeserializer extends JsonDeserializer<BigDecimal> {

    @Override
    public BigDecimal deserialize(JsonParser p,
                                  DeserializationContext ctx) throws IOException {
        String raw = p.getText().replaceAll("[$,]", "");
        return new BigDecimal(raw);
    }
}

// ── Apply on a field ──────────────────────────────────────────────────
public record PriceResponse(
    String product,
    @JsonSerialize(using = MoneySerializer.class)
    @JsonDeserialize(using = MoneyDeserializer.class)
    BigDecimal price
) {}

// ── Register globally via a Module ───────────────────────────────────
@Configuration
public class JacksonConfig {

    @Bean
    public Jackson2ObjectMapperBuilderCustomizer customizers() {
        return builder -> builder.deserializerByType(BigDecimal.class, new MoneyDeserializer())
                                 .serializerByType(BigDecimal.class, new MoneySerializer());
    }
}

Polymorphic JSON with @JsonTypeInfo

When a field can hold one of several subtypes, Jackson needs a type discriminator in the JSON. @JsonTypeInfo and @JsonSubTypes configure this. Use PROPERTY (an explicit field) or EXISTING_PROPERTY (a field already in the payload) as the inclusion strategy.
Java
// ── Base class with type discriminator ────────────────────────────────
@JsonTypeInfo(
    use = JsonTypeInfo.Id.NAME,
    include = JsonTypeInfo.As.PROPERTY,
    property = "type"               // {"type":"card", ...} or {"type":"paypal", ...}
)
@JsonSubTypes({
    @JsonSubTypes.Type(value = CardPayment.class,   name = "card"),
    @JsonSubTypes.Type(value = PayPalPayment.class, name = "paypal"),
    @JsonSubTypes.Type(value = BankPayment.class,   name = "bank")
})
public abstract class Payment {
    public abstract BigDecimal amount();
}

// ── Subtypes ──────────────────────────────────────────────────────────
public record CardPayment(
    BigDecimal amount,
    String cardNumber,
    String expiry
) extends Payment {}

public record PayPalPayment(
    BigDecimal amount,
    String email
) extends Payment {}

// ── Controller accepts polymorphic request body ───────────────────────
@PostMapping("/payments")
public ResponseEntity<Void> pay(@RequestBody @Valid Payment payment) {
    // payment is a CardPayment or PayPalPayment at runtime
    paymentService.process(payment);
    return ResponseEntity.accepted().build();
}

// ── Example request bodies:
// { "type": "card",   "amount": 99.99, "cardNumber": "4111...", "expiry": "12/26" }
// { "type": "paypal", "amount": 49.00, "email": "user@example.com" }

JSON Views for Partial Responses

@JsonView lets a single DTO serialize different subsets of fields depending on the context — for example, returning fewer fields in a list endpoint than in a detail endpoint. Define marker interfaces as view names and annotate fields with the views they belong to.
Java
// ── Define view marker interfaces ─────────────────────────────────────
public class UserViews {
    public interface Summary {}                       // minimal: id + name
    public interface Detail extends Summary {}        // everything
    public interface Admin extends Detail {}          // includes sensitive fields
}

// ── Annotate DTO fields ───────────────────────────────────────────────
public class UserResponse {

    @JsonView(UserViews.Summary.class)
    public Long id;

    @JsonView(UserViews.Summary.class)
    public String name;

    @JsonView(UserViews.Detail.class)
    public String email;

    @JsonView(UserViews.Detail.class)
    public LocalDateTime createdAt;

    @JsonView(UserViews.Admin.class)
    public String internalNotes;   // only visible in Admin view
}

// ── Use @JsonView in the controller ──────────────────────────────────
@GetMapping
@JsonView(UserViews.Summary.class)     // list returns id + name only
public List<UserResponse> findAll() {
    return userService.findAll();
}

@GetMapping("/{id}")
@JsonView(UserViews.Detail.class)      // detail returns everything except admin fields
public UserResponse findById(@PathVariable Long id) {
    return userService.findById(id);
}