🧠 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
@RestControllerand 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
1699999999000instead of2026-09-16and 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
}
}
- The method returns a
User. No JSON anywhere in sight. - Something between your method and the network socket has to turn that
Userinto 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
- A
MediaTypeis just a content type likeapplication/jsonortext/plain— the label that says what a body is. - Each converter is tied to one or a few media types. For example:
-
MappingJackson2HttpMessageConverterhandlesapplication/jsonusing the Jackson library. -
StringHttpMessageConverterhandlestext/plain. -
ByteArrayHttpMessageConverterhandles rawapplication/octet-streambytes.
-
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
Userobject 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
-
Accept: application/jsonmeans "send me JSON." -
Accept: application/xmlmeans "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
- 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
Acceptheader is the default and preferred way to negotiate. But Spring supports a few other strategies, each managed by aContentNegotiationStrategy:
| 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 —
AcceptvsContent-Type:-
Acceptis on the request and describes the body the client wants back. It drives the response converter. -
Content-Typedescribes 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
}
}
-
defaultContentTypematters: if a client sends noAcceptheader 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
@RequestBodyto say "fill this from the request body."
@PostMapping("/users")
User create(@RequestBody User newUser) {
return userService.save(newUser);
}
- 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"}
- Spring asks each converter
canRead(User, application/json). Jackson's converter says yes and deserializes the body into aUser.
So the two sides mirror each other:
| Direction | Trigger | Header read | Converter method |
|---|---|---|---|
| Response out |
@ResponseBody / @RestController
|
Accept |
canWrite → write
|
| Request in | @RequestBody |
Content-Type |
canRead → read
|
-
Note:
@RestControlleris just@Controller+@ResponseBodyon every method, which is why returning an object "just works" without writing@ResponseBodyeach 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
Acceptheader asks for a format no converter can produce. - Example:
Accept: application/xmlbut you only have Jackson (JSON) on the classpath.
- The client's
-
415 Unsupported Media Type → a request problem.
- The client's
Content-Typelabels a body no converter can read. - Example: client posts
Content-Type: text/yamland nothing can parse YAML.
- The client's
-
⚠️ 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).
-
406 = "I can't give you what you'll accept" (about
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) { ... }
-
consumesnarrows what the endpoint will read → a mismatch gives 415. -
producesnarrows what the endpoint will return → a mismatch withAcceptgives 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 singleObjectMapper— Jackson's core engine that maps between objects and JSON. - Spring Boot auto-configures this
ObjectMapperfor 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.timedate as a number (epoch or an array), which almost nobody wants:
❌ "createdAt": 1726444800.000
✅ "createdAt": "2026-09-16T10:00:00"
- 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_TIMESTAMPSso they render as ISO-8601 strings.
- Register the JavaTimeModule so Jackson understands
- If you build an
ObjectMapperyourself, 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;
}
-
@JsonPropertybridges a Java name and a different JSON name. -
@JsonIgnorekeeps 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
- 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
-
2. A builder-customizer bean when properties are not enough. This adjusts Boot's
ObjectMapperwithout replacing it:
@Bean
Jackson2ObjectMapperBuilderCustomizer json() {
return builder -> builder.simpleDateFormat("yyyy-MM-dd")
.modulesToInstall(new MyModule());
}
-
3.
configureMessageConverters/extendMessageConverterswhen 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
Acceptasks for a format you can't produce. Add the converter (e.g. XML) or fix the client'sAccept. -
Getting a 415? The client's
Content-Typenames 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-timestampsis still on — you likely built your ownObjectMapper. -
Need to change JSON globally? Try
spring.jackson.*→ then aJackson2ObjectMapperBuilderCustomizer→ then the converter list, in that order. -
Need per-field control? Use
@JsonProperty,@JsonIgnore,@JsonIncludeon the class.
💡 Remember this
- Your controller returns an object; a message converter turns it into bytes, and content negotiation picks the format.
-
Acceptdrives the response (out);Content-Typedrives the request body (in). Keep the two straight and 406 vs 415 becomes obvious. - Jackson's
ObjectMapperis the JSON engine — let Boot configure it, and tune it through properties or a customizer, never by replacing it wholesale. -
@JsonProperty,@JsonIgnore, and@JsonIncludeshape the JSON per field; the JavaTimeModule keeps your dates readable.