ThelluMost 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
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.
There’s no universal strictness. Define your contract.
A reasonable default for backend APIs:
"" → null, "10" → 10, 1.0 → 1, etc.)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);
}
}
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 immediatelyThis shifts bugs from “production data weirdness” to “client gets a 400”.
Even with coercions disabled, some numeric edge cases still need explicit handling:
+10, 01 (maybe you want to reject)1e3
int/long
For fields where it matters, use a custom 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();
}
}
Usage:
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
public record CreateOrderRequest(
@JsonDeserialize(using = StrictIntDeserializer.class)
Integer count
) {}
This guarantees count is a real JSON integer (not a string or float).
Money is where “lenient” becomes dangerous.
You have two sane options:
"amount": "12.34"
BigDecimal with your own rules (scale, rounding, max value)"amountCents": 1234
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);
}
}
Usage:
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import java.math.BigDecimal;
public record CreatePaymentRequest(
@JsonDeserialize(using = StrictBigDecimalStringDeserializer.class)
BigDecimal amount
) {}
Now "amount": 12.34 (number) fails, and "amount": "12.3400" fails if you require max 2 decimals.
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" }
]
}
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;
}
}
(If you already have a global error envelope, plug this into it.)
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"));
}
}
A strict JSON parser is a contract enforcer.
Once you tighten your deserialization rules: