# Stop Letting Jackson Accept Garbage: Strict JSON Parsing in Spring Boot

# webdev# programming# beginners# java
# Stop Letting Jackson Accept Garbage: Strict JSON Parsing in Spring BootThellu

Most Spring Boot APIs think they are strict — until a client sends something slightly “off”, and your...

Most Spring Boot APIs think they are strict — until a client sends something slightly “off”, and your app quietly accepts it.

Examples I’ve seen in real systems:

  • "count": "10" (string) accepted where you expected an integer
  • "amount": 1.2 accepted where you expected an integer cents value
  • "age": "" becoming 0 or null depending on coercion rules
  • unknown fields being ignored (and bugs staying hidden for months)

If you care about contracts, lenient deserialization is technical debt. The earlier you fail, the cheaper it is.

This post shows a practical way to make JSON parsing predictable and strict in Spring Boot, without turning your codebase into a pile of custom validators.


What “strict” means (pick your rules)

There’s no universal strictness. Define your contract.

A reasonable default for backend APIs:

  1. Unknown fields fail (clients can’t silently send typos)
  2. Wrong types fail (string vs number, boolean vs string, etc.)
  3. No “magical” coercions (""null, "10" → 10, 1.0 → 1, etc.)
  4. Numbers are validated (no NaN/Infinity; integer overflow fails)
  5. Error responses are consistent (clients can handle them)

Step 1) Turn off “accept anything” defaults

Create a single ObjectMapper configuration. In Spring Boot, the cleanest way is to customize Jackson2ObjectMapperBuilder.

import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.MapperFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.cfg.CoercionAction;
import com.fasterxml.jackson.databind.cfg.CoercionInputShape;
import com.fasterxml.jackson.databind.type.LogicalType;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilderCustomizer;

@Configuration
public class StrictJacksonConfig {

  @Bean
  public Jackson2ObjectMapperBuilderCustomizer strictJacksonCustomizer() {
    return builder -> builder.postConfigurer(this::configureStrictness);
  }

  private void configureStrictness(ObjectMapper mapper) {
    // 1) Unknown fields should fail
    mapper.enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);

    // 2) Don’t accept null for primitives (helps catch missing fields)
    mapper.enable(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES);

    // 3) Be explicit about coercions (Jackson 2.12+)
    mapper.coercionConfigFor(LogicalType.Integer)
        .setCoercion(CoercionInputShape.String, CoercionAction.Fail)
        .setCoercion(CoercionInputShape.Float, CoercionAction.Fail)
        .setCoercion(CoercionInputShape.EmptyString, CoercionAction.Fail);

    mapper.coercionConfigFor(LogicalType.Float)
        .setCoercion(CoercionInputShape.String, CoercionAction.Fail)
        .setCoercion(CoercionInputShape.EmptyString, CoercionAction.Fail);

    mapper.coercionConfigFor(LogicalType.Boolean)
        .setCoercion(CoercionInputShape.String, CoercionAction.Fail)
        .setCoercion(CoercionInputShape.EmptyString, CoercionAction.Fail);

    // 4) Optional: enforce exact property names (rarely needed, but useful for hard contracts)
    mapper.disable(MapperFeature.ACCEPT_CASE_INSENSITIVE_PROPERTIES);
  }
}
Enter fullscreen mode Exit fullscreen mode

Why this matters

With these rules:

  • "count": "10" fails instead of being accepted
  • "count": 10.0 fails instead of being coerced to 10
  • "count": "" fails instead of becoming 0/null
  • "unknowField": 123 fails immediately

This shifts bugs from “production data weirdness” to “client gets a 400”.


Step 2) Define “strict numbers” (the part Jackson won’t solve alone)

Even with coercions disabled, some numeric edge cases still need explicit handling:

  • leading/trailing spaces
  • +10, 01 (maybe you want to reject)
  • scientific notation for integers: 1e3
  • extremely large numbers that overflow int/long
  • decimal formatting rules (for money you may want to accept only strings or only decimals)

For fields where it matters, use a custom deserializer.

Example: strict integer deserializer

import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.JsonMappingException;
import com.fasterxml.jackson.databind.JsonNode;

import java.io.IOException;

public class StrictIntDeserializer extends JsonDeserializer<Integer> {

  @Override
  public Integer deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
    JsonNode node = p.getCodec().readTree(p);

    if (!node.isInt()) {
      // Reject anything that isn't a JSON integer token.
      // That means: no strings, no floats, no booleans, no null.
      throw JsonMappingException.from(p, "Expected an integer number");
    }

    return node.intValue();
  }
}
Enter fullscreen mode Exit fullscreen mode

Usage:

import com.fasterxml.jackson.databind.annotation.JsonDeserialize;

public record CreateOrderRequest(
    @JsonDeserialize(using = StrictIntDeserializer.class)
    Integer count
) {}
Enter fullscreen mode Exit fullscreen mode

This guarantees count is a real JSON integer (not a string or float).


Step 3) Money fields: choose one representation and enforce it

Money is where “lenient” becomes dangerous.

You have two sane options:

Option A: represent money as string (recommended for many APIs)

  • client sends "amount": "12.34"
  • you parse to BigDecimal with your own rules (scale, rounding, max value)

Option B: represent money as integer minor units

  • client sends "amountCents": 1234
  • you store and compute in integers

Here’s a strict BigDecimal deserializer for Option A:

import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.JsonMappingException;
import com.fasterxml.jackson.databind.JsonNode;

import java.io.IOException;
import java.math.BigDecimal;

public class StrictBigDecimalStringDeserializer extends JsonDeserializer<BigDecimal> {

  @Override
  public BigDecimal deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
    JsonNode node = p.getCodec().readTree(p);

    if (!node.isTextual()) {
      throw JsonMappingException.from(p, "Expected a decimal string like \"12.34\"");
    }

    String raw = node.textValue();
    if (raw == null || raw.isBlank()) {
      throw JsonMappingException.from(p, "Amount must not be blank");
    }

    // Example rule: only digits + optional '.' + up to 2 decimals
    if (!raw.matches("^\\d+(\\.\\d{1,2})?$")) {
      throw JsonMappingException.from(p, "Amount format must be like \"12\" or \"12.34\"");
    }

    return new BigDecimal(raw);
  }
}
Enter fullscreen mode Exit fullscreen mode

Usage:

import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import java.math.BigDecimal;

public record CreatePaymentRequest(
    @JsonDeserialize(using = StrictBigDecimalStringDeserializer.class)
    BigDecimal amount
) {}
Enter fullscreen mode Exit fullscreen mode

Now "amount": 12.34 (number) fails, and "amount": "12.3400" fails if you require max 2 decimals.


Step 4) Make error responses consistent (so clients can handle them)

Strict parsing is only pleasant if clients get a predictable response.

A minimal error envelope:

{
  "status": 400,
  "error": "invalid.json",
  "message": "Malformed JSON or invalid field type",
  "details": [
    { "field": null, "reason": "Expected an integer number" }
  ]
}
Enter fullscreen mode Exit fullscreen mode

You can implement this via @RestControllerAdvice handling HttpMessageNotReadableException (the exception Spring throws for JSON parse/type issues).

import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.util.List;

@RestControllerAdvice
public class JsonErrorHandler {

  @ExceptionHandler(HttpMessageNotReadableException.class)
  public ResponseEntity<ApiErrorResponse> handleNotReadable(
      HttpMessageNotReadableException ex,
      HttpServletRequest req
  ) {
    ApiErrorResponse body = ApiErrorResponse.of(
        400,
        "invalid.json",
        "Malformed JSON or invalid field type",
        req.getRequestURI(),
        null,
        List.of(ApiErrorDetail.of(null, rootCauseMessage(ex)))
    );
    return ResponseEntity.badRequest().body(body);
  }

  private static String rootCauseMessage(Throwable t) {
    Throwable cur = t;
    while (cur.getCause() != null) cur = cur.getCause();
    String msg = cur.getMessage();
    return (msg == null || msg.isBlank()) ? cur.getClass().getSimpleName() : msg;
  }
}
Enter fullscreen mode Exit fullscreen mode

(If you already have a global error envelope, plug this into it.)


Step 5) Lock it with tests

This is important: strictness tends to “drift” over time when someone changes ObjectMapper settings.

Here’s a small @WebMvcTest that ensures wrong types fail:

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@WebMvcTest(PaymentController.class)
@Import({StrictJacksonConfig.class, JsonErrorHandler.class})
class StrictJsonTest {

  @Autowired MockMvc mvc;

  @Test
  void stringInsteadOfInt_shouldFail() throws Exception {
    String json = "{\"count\": \"10\"}";

    mvc.perform(post("/api/payments")
            .contentType(MediaType.APPLICATION_JSON)
            .content(json))
        .andExpect(status().isBadRequest())
        .andExpect(jsonPath("$.error").value("invalid.json"));
  }
}
Enter fullscreen mode Exit fullscreen mode

Practical tips (what I wish I knew earlier)

  • Don’t return JPA entities directly from controllers — it mixes serialization concerns with persistence proxies and can trigger lazy loading.
  • Decide your money strategy early: decimal strings vs minor units.
  • Be explicit about unknown fields: failing fast saves a lot of production debugging.
  • If your consumers are internal services, strict JSON is even more valuable because you can enforce contracts as code.

Wrap-up

A strict JSON parser is a contract enforcer.

Once you tighten your deserialization rules:

  • you catch client mistakes instantly
  • you reduce data corruption risks
  • you simplify debugging (“the error tells you exactly what’s wrong”)