Content negotiation & message converters (Jackson)

java dev.to

🧠 The big idea in one line

  • Your controller returns a plain Java object, and Spring quietly turns it into JSON (or reads JSON back into an object) — content negotiation is how Spring decides which format, and a message converter is the thing that actually does the translation.

Why this topic exists:

  • A browser, a mobile app, and a curl script can all call the same endpoint but want the body in different shapes: JSON, XML, plain text, a PDF.
  • You do not want to write if (wantsJson) ... else if (wantsXml) ... in every method.
  • Spring pushes that decision to the edge of the request, so your method stays about business logic and returns one object.

When you meet it:

  • The moment you write @RestController and return an object instead of a view name.
  • The first time a client gets a 406 or 415 error and you have no idea why.
  • The first time a date serializes as 1699999999000 instead of 2026-09-16 and you go hunting.

🎯 The starting point: a method that returns an object

  • In classic Spring MVC, a controller method returned a view name — a string like "userProfile" — and Spring rendered an HTML template.
  • For an API you do not want HTML. You want the object itself, as data, in the response body.

Here is the shape almost every API method has:

@RestController
@RequestMapping("/users")
class UserController {

    @GetMapping("/{id}")
    User getUser(@PathVariable Long id) {
        return userService.find(id); // just a Java object
    }
}
Enter fullscreen mode Exit fullscreen mode
  • The method returns a User. No JSON anywhere in sight.
  • Something between your method and the network socket has to turn that User into bytes on the wire.
  • That "something" is a message converter, and picking the right one is content negotiation. Let's build both up.

🔌 What a message converter is

  • A message converter is an object that knows how to translate between a Java type and one HTTP body format.
  • It works in both directions:
    • Write: Java object → bytes in the response body (serialization).
    • Read: bytes in the request body → Java object (deserialization).

Spring models this with one interface, HttpMessageConverter. The important part is these four questions it can answer:

boolean canRead(Class<?> type, MediaType mediaType);   // can I turn this body into that type?
boolean canWrite(Class<?> type, MediaType mediaType);  // can I turn that type into this body?
Object  read(Class<?> type, HttpInputMessage in);      // do the reading
void    write(Object value, MediaType type, HttpOutputMessage out); // do the writing
Enter fullscreen mode Exit fullscreen mode
  • A MediaType is just a content type like application/json or text/plain — the label that says what a body is.
  • Each converter is tied to one or a few media types. For example:
    • MappingJackson2HttpMessageConverter handles application/json using the Jackson library.
    • StringHttpMessageConverter handles text/plain.
    • ByteArrayHttpMessageConverter handles raw application/octet-stream bytes.

So Spring holds a list of converters, and for any request it asks each one, "can you handle this type and this media type?" The first that says yes wins.


🧭 Content negotiation: which format does the client want?

  • On the response side, Spring has your User object and a list of converters. It still needs to know which format the client wants back.
  • Deciding that format is content negotiation.

The client states its preference with the Accept header:

GET /users/42
Accept: application/json
Enter fullscreen mode Exit fullscreen mode
  • Accept: application/json means "send me JSON."
  • Accept: application/xml means "send me XML."
  • Accept: */* (what curl sends by default) means "I'll take anything."

The flow on the way out looks like this:

controller returns User
        │
        ▼
read Accept header  ──►  application/json
        │
        ▼
find a converter where canWrite(User, application/json) == true
        │
        ▼
MappingJackson2HttpMessageConverter.write(user, ...)
        │
        ▼
{"id":42,"name":"Ada"}  ──►  response body
Enter fullscreen mode Exit fullscreen mode
  • Spring intersects two lists: the media types the client will accept, and the media types the converters can produce.
  • It picks the best match and hands the object to that converter.

The other strategies (and why Accept is the default)

  • The Accept header is the default and preferred way to negotiate. But Spring supports a few other strategies, each managed by a ContentNegotiationStrategy:
Strategy How the client asks Status today
Header Accept: application/json ✅ Default, recommended
Parameter GET /users/42?format=json Off by default; opt in
Path extension GET /users/42.json ❌ Deprecated (security risks), off by default
  • ⚠️ Easy to confuse — Accept vs Content-Type:
    • Accept is on the request and describes the body the client wants back. It drives the response converter.
    • Content-Type describes the body that is actually attached right now. On a request it labels what the client sent; on a response it labels what the server sent.
    • Same idea (a media type), opposite direction. Mixing them up is the classic content-negotiation bug.

You can turn the parameter strategy on with a small config bean:

@Configuration
class WebConfig implements WebMvcConfigurer {
    @Override
    public void configureContentNegotiation(ContentNegotiationConfigurer c) {
        c.favorParameter(true)       // enable ?format=...
         .parameterName("format")
         .defaultContentType(MediaType.APPLICATION_JSON); // fallback when Accept is silent
    }
}
Enter fullscreen mode Exit fullscreen mode
  • defaultContentType matters: if a client sends no Accept header at all, this is what they get.

📥 The request side: reading a body in

  • Everything so far was the response. Reading a request body works with the same converters, but the trigger and the header are different.
  • You mark a parameter with @RequestBody to say "fill this from the request body."
@PostMapping("/users")
User create(@RequestBody User newUser) {
    return userService.save(newUser);
}
Enter fullscreen mode Exit fullscreen mode
  • Here the client sends a JSON body, and Spring must turn it into a User.
  • Now the header that matters is Content-Type, because it describes the body the client actually sent:
POST /users
Content-Type: application/json

{"name":"Ada"}
Enter fullscreen mode Exit fullscreen mode
  • Spring asks each converter canRead(User, application/json). Jackson's converter says yes and deserializes the body into a User.

So the two sides mirror each other:

Direction Trigger Header read Converter method
Response out @ResponseBody / @RestController Accept canWritewrite
Request in @RequestBody Content-Type canReadread
  • Note: @RestController is just @Controller + @ResponseBody on every method, which is why returning an object "just works" without writing @ResponseBody each time.

🚦 When negotiation fails: 406 and 415

  • Two HTTP errors come straight out of this machinery, and knowing which is which saves real debugging time.
  • 406 Not Acceptable → a response problem.
    • The client's Accept header asks for a format no converter can produce.
    • Example: Accept: application/xml but you only have Jackson (JSON) on the classpath.
  • 415 Unsupported Media Type → a request problem.
    • The client's Content-Type labels a body no converter can read.
    • Example: client posts Content-Type: text/yaml and nothing can parse YAML.
  • ⚠️ Easy to confuse — 406 vs 415:
    • 406 = "I can't give you what you'll accept" (about Accept, the way out).
    • 415 = "I can't read what you sent me" (about Content-Type, the way in).

You can also constrain endpoints explicitly with produces and consumes:

@PostMapping(
    path = "/users",
    consumes = MediaType.APPLICATION_JSON_VALUE,  // only accept JSON bodies
    produces = MediaType.APPLICATION_JSON_VALUE)  // only ever return JSON
User create(@RequestBody User u) { ... }
Enter fullscreen mode Exit fullscreen mode
  • consumes narrows what the endpoint will read → a mismatch gives 415.
  • produces narrows what the endpoint will return → a mismatch with Accept gives 406.
  • These also help Spring route: two methods on the same path can differ only by produces, one for JSON and one for XML.

🧩 Jackson: the converter that does the JSON work

  • For JSON, the converter is MappingJackson2HttpMessageConverter, and inside it sits a single ObjectMapper — Jackson's core engine that maps between objects and JSON.
  • Spring Boot auto-configures this ObjectMapper for you, so you rarely create one by hand. But you do need to steer it.

The date trap

  • By default, plain Jackson serializes a java.time date as a number (epoch or an array), which almost nobody wants:
❌  "createdAt": 1726444800.000
✅  "createdAt": "2026-09-16T10:00:00"
Enter fullscreen mode Exit fullscreen mode
  • The fix has two parts, and Boot handles both automatically:
    • Register the JavaTimeModule so Jackson understands LocalDate, Instant, and friends.
    • Turn off WRITE_DATES_AS_TIMESTAMPS so they render as ISO-8601 strings.
  • If you build an ObjectMapper yourself, you must add these — forgetting the module is the single most common Jackson bug.

Steering serialization with annotations

  • You control the JSON shape field by field, right on the class:
class User {
    @JsonProperty("user_name")   // rename the JSON key
    String name;

    @JsonIgnore                  // never serialize this
    String passwordHash;

    @JsonInclude(JsonInclude.Include.NON_NULL) // drop nulls from output
    String nickname;
}
Enter fullscreen mode Exit fullscreen mode
  • @JsonProperty bridges a Java name and a different JSON name.
  • @JsonIgnore keeps secrets out of the body.
  • @JsonInclude(NON_NULL) trims empty fields so responses stay small.

The unknown-property trap

  • By default Jackson fails when the incoming JSON has a field your class does not declare.
  • That is strict and often surprising when a client sends an extra field.
❌  Strict (default): unknown field  →  deserialization throws  →  400
✅  Lenient: ignore unknown fields, bind the rest
Enter fullscreen mode Exit fullscreen mode
  • Boot flips this to lenient for you by default (FAIL_ON_UNKNOWN_PROPERTIES=false), but know the switch exists — it decides whether an extra field is a hard error or quietly ignored.

⚙️ Customizing converters the right way

  • You will eventually need to change global behavior. There is a clean order of preference, from least to most invasive.
  • 1. Properties first. Most tuning needs no code:
spring.jackson.serialization.write-dates-as-timestamps=false
spring.jackson.default-property-inclusion=non_null
spring.jackson.deserialization.fail-on-unknown-properties=false
Enter fullscreen mode Exit fullscreen mode
  • 2. A builder-customizer bean when properties are not enough. This adjusts Boot's ObjectMapper without replacing it:
@Bean
Jackson2ObjectMapperBuilderCustomizer json() {
    return builder -> builder.simpleDateFormat("yyyy-MM-dd")
                             .modulesToInstall(new MyModule());
}
Enter fullscreen mode Exit fullscreen mode
  • 3. configureMessageConverters / extendMessageConverters when you need to touch the converter list itself:
  • extendMessageConverters — adjust the list Boot already built (the safe choice).
  • configureMessageConverters — replace the whole list (you now own every converter, including the defaults you just dropped).
  • ❌ Don't create a bare new ObjectMapper() bean just to tweak one setting — you throw away all of Boot's sensible defaults (the JavaTimeModule, the lenient reading) and reintroduce the date trap.
  • ✅ Do start at properties, then a customizer, and only reach for the converter list when you are adding a genuinely new format.

📊 Quick summary

Concept What it does Header it reads
Message converter Translates Java object ↔ body bytes
Content negotiation Picks the response format Accept
@RequestBody Reads request body into an object Content-Type
@ResponseBody Writes returned object to the body Accept
produces Restricts formats the endpoint returns Accept (mismatch → 406)
consumes Restricts formats the endpoint reads Content-Type (mismatch → 415)
Jackson ObjectMapper The engine doing the JSON mapping

🎯 Decision rule

  • Getting a 406? The client's Accept asks for a format you can't produce. Add the converter (e.g. XML) or fix the client's Accept.
  • Getting a 415? The client's Content-Type names a body you can't read. Fix the header or add a converter.
  • Dates look like numbers? You're missing the JavaTimeModule or write-dates-as-timestamps is still on — you likely built your own ObjectMapper.
  • Need to change JSON globally? Try spring.jackson.* → then a Jackson2ObjectMapperBuilderCustomizer → then the converter list, in that order.
  • Need per-field control? Use @JsonProperty, @JsonIgnore, @JsonInclude on the class.

💡 Remember this

  • Your controller returns an object; a message converter turns it into bytes, and content negotiation picks the format.
  • Accept drives the response (out); Content-Type drives the request body (in). Keep the two straight and 406 vs 415 becomes obvious.
  • Jackson's ObjectMapper is the JSON engine — let Boot configure it, and tune it through properties or a customizer, never by replacing it wholesale.
  • @JsonProperty, @JsonIgnore, and @JsonInclude shape the JSON per field; the JavaTimeModule keeps your dates readable.

Source: dev.to

arrow_back Back to Tutorials