Controller Details: Argument Binding, Return Values and Conversion
The most common controller mistake is not syntax — it is not knowing where data can come from. A single @GetMapping method can accept a bewildering variety of parameters: query strings, path placeholders, request bodies, headers, cookies, sessions, raw streams. Each one is assembled by a different HandlerMethodArgumentResolver. Map the space of possibilities first with one table, then drill into the details — far more effective than memorizing APIs piecemeal.
| Annotation / type | Data source | Typical methods | Example |
|---|---|---|---|
@RequestParam | URL query string ?page=1, or form body | Any | @RequestParam("page") int page |
@PathVariable | A placeholder inside the URL path | Any | @PathVariable Long id |
@RequestBody | The request body (JSON / XML) | POST / PUT / PATCH | @RequestBody UserDTO dto |
@RequestHeader | An HTTP header | Any | @RequestHeader("Authorization") String token |
@CookieValue | One key inside a cookie | Any | @CookieValue("SESSION") String sid |
@RequestAttribute | A value a filter wrote via request.setAttribute | Any | @RequestAttribute("userId") Long uid |
@ModelAttribute | Query string / form → bound to an object | GET / POST | @ModelAttribute UserQuery q |
@SessionAttribute | A property in HttpSession | Any | @SessionAttribute("user") User u |
HttpServletRequest / HttpServletResponse | The raw Servlet objects | Any | HttpServletRequest req |
InputStream / OutputStream | The raw request / response byte streams | Any | InputStream in |
Principal | The authenticated principal | Any | Principal principal |
@Valid | Takes no value; triggers validation | Any | @Valid @RequestBody UserDTO dto |

only the first eight rows actually read data. Native objects like HttpServletRequest are escape hatches — avoid them when you can, because using one means giving up the conversion and validation the framework does for you. @Valid is even more of an outlier: it produces no value at all, it merely attaches a validation switch to an already-bound parameter.
How much of that table you can actually use is decided before any annotation, in pom.xml. Tick it yourself and it becomes obvious: with only web checked, @RequestBody works immediately while @Valid does nothing at all — validation was never part of MVC. And whether a @ModelAttribute object can bind at all depends on it having getters and setters, which is exactly what the lombok line buys you:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.4</version> <!-- 版本由 BOM 统管,子依赖不写 version -->
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>demo-service</artifactId>
<version>0.0.1-SNAPSHOT</version>
<properties>
<java.version>17</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>@RestController@RequestMapping("/api/products")public class ProductController { // 1) name / value are aliases: @RequestParam("page") == @RequestParam(name = "page") // 2) required defaults to true; missing it throws MissingServletRequestParameterException -> 400 // 3) the moment a defaultValue exists, required silently becomes false @GetMapping public List<Product> list( @RequestParam(name = "page", defaultValue = "1") int page, @RequestParam(defaultValue = "20") int size, @RequestParam(required = false) String keyword) { return productService.search(keyword, page, size); }}name/value: the parameter name. Omit it and Spring reads it from the bytecode via "parameter name retention" (-parameters); get it wrong and you get a 400, not a compile errorrequired = true(the default): a missing parameter throws immediately — do not expect to reach the method body and null-check itdefaultValue = "...": it implicitly turns off required, the most easily missed side effect
@GetMapping("/users/{userId}/orders/{orderId:\\d+}")public Order detail(@PathVariable Long userId, @PathVariable("orderId") Long orderId) { return orderService.findByUserAndOrder(userId, orderId);}// Or capture every path variable at once with a Map@GetMapping("/files/{folder}/{name}")public String read(@PathVariable Map<String, String> vars) { return files.read(vars.get("folder"), vars.get("name"));}Writing {orderId:\\d+} attaches a regex constraint to that placeholder: only pure digits match. /orders/abc fails \d+, never reaches this mapping, and returns 404 — much cleaner than letting parsing fail inside the method.
@PostMapping("/users")public UserVO create(@Valid @RequestBody UserCreateDTO dto) { // Jackson reads the body, treats it as a JSON tree and deserializes it into dto // Content-Type must be application/json, otherwise 415 return userService.create(dto);}public record UserCreateDTO( @NotBlank String username, @Email String email, Integer age) {}@RequestBody relies on HttpMessageConverter (in JSON scenarios, MappingJackson2HttpMessageConverter). It inspects Content-Type first: not application/json means 415; JSON but structurally wrong (bad field type, missing required member) throws HttpMessageNotReadableException, which a global handler usually maps to 400.
@GetMapping("/users")public PageResult<UserVO> page(@ModelAttribute UserQuery query) { // username, status, page, size from the query string are injected into query return userService.page(query);}public class UserQuery { private String username; private UserStatus status = UserStatus.ACTIVE; private int page = 1; private int size = 20; // getters / setters omitted}@ModelAttribute writes each query-string (or form) entry into the object by property name, leaving absent fields at their defaults. It makes "a pile of filter parameters" look like one ordinary method parameter — the first choice for conditional-filter endpoints.
public class ReportQuery { // Declare the text format, or "2024-05-01" cannot become a LocalDate @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) private LocalDate from; @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") private LocalDateTime createdAt; // Enums match by name: ?status=ACTIVE private UserStatus status;}Dates are the classic "type mismatch scene": without a format hint, Spring cannot parse 2024-05-01 and returns 400. Enums are easier — they match names via Enum.valueOf by default, and a name that does not exist fails.
Every "String → target type" step above is governed by one component: ConversionService. It is Spring's general-purpose conversion facade, and both Converter and Formatter register with it.

These are parameter-level conversion declarations, scoped to the annotated field, with no global registration needed:
public class InvoiceQuery { @DateTimeFormat(pattern = "yyyyMMdd") private LocalDate billDate; // 20240501 -> LocalDate @NumberFormat(pattern = "#,###.##") private BigDecimal amount; // "12,345.67" -> BigDecimal}When a parameter is not a primitive but a value object of your own (say an OrderNo), you need a custom converter:
@Componentpublic class OrderNoConverter implements Converter<String, OrderNo> { @Override public OrderNo convert(String source) { // Shaped like "ORD-20240501-0001": validate prefix and segment count if (source == null || !source.startsWith("ORD-")) { throw new IllegalArgumentException("invalid order no: " + source); } return OrderNo.parse(source); }}// Once registered, the controller can take the value object directly@GetMapping("/orders/{no}")public OrderVO detail(@PathVariable OrderNo no) { return orderService.findByNo(no);}Tip: Converter<String, T> only handles one direction. If you need both, implement a Converter<T, S> alongside Converter<S, T>, or use Formatter<T> (which supports both parse and print); the latter is also Locale-aware, which suits internationalization better.
@Componentpublic class PercentFormatter implements Formatter<BigDecimal> { @Override public BigDecimal parse(String text, Locale locale) { return new BigDecimal(text.replace("%", "")).movePointLeft(2); } @Override public String print(BigDecimal value, Locale locale) { return value.movePointRight(2).toPlainString() + "%"; }}Both Converter and Formatter register via WebMvcConfigurer#addFormatters (annotating with @Component also gets them collected automatically):
@Configurationpublic class WebConvertConfig implements WebMvcConfigurer { @Override public void addFormatters(FormatterRegistry registry) { registry.addConverter(new OrderNoConverter()); registry.addFormatter(new PercentFormatter()); }}With inputs covered, look at the other end. What a handler returns decides how Spring treats it:
| Return value | Handler | Behavior |
|---|---|---|
Plain object + @ResponseBody (or @RestController) | RequestResponseBodyMethodProcessor | Serialized to JSON via HttpMessageConverter and written back |
ResponseEntity<T> | HttpEntityMethodProcessor | Full control over status, headers and body |
void | ServletInvocableHandlerMethod | Assumes you wrote the response yourself; with @ResponseBody it may also return an empty body |
String | Depends on view resolution | No view resolver = plain text; template engine = view name |
ModelAndView | ViewNameMethodReturnValueHandler | Carries view name and model for server-side rendering |
StreamingResponseBody | StreamingResponseBodyReturnValueHandler | Writes in chunks on a separate thread; good for large downloads |
the meaning of String is ambiguous. Inside a @RestController (equivalently, with @ResponseBody on the class), returning "success" is the text success. But in a plain @Controller, the same "success" is treated as a view name, and Spring renders a template called success — a 404 if none exists. Not understanding this is the number-one cause of "why does my endpoint return an HTML page".
The right way to read that table is not to memorize "which type pairs with which handler" but to understand the voting order: the handler table asks exactly one question, and that question is not about the return type. Tap through it:
That fork has a real scene you can click. The same return statement, only the class-level annotation swapped, and the exit changes entirely:
Finally, the six exits as one animation — frames ③ and ④ are the same method body living two different lives:

When you need precise control over status and headers, use ResponseEntity:
@RestController@RequestMapping("/api/files")public class FileController { // 1) A custom status: 201 + Location header on creation @PostMapping public ResponseEntity<UserVO> create(@RequestBody UserCreateDTO dto) { UserVO vo = userService.create(dto); return ResponseEntity .created(URI.create("/api/files/" + vo.id())) // 201 .body(vo); } // 2) No content: 204 on successful delete @DeleteMapping("/{id}") public ResponseEntity<Void> delete(@PathVariable Long id) { fileService.delete(id); return ResponseEntity.noContent().build(); // 204 } // 3) File download: a custom Content-Type and Content-Disposition @GetMapping("/{id}/download") public ResponseEntity<Resource> download(@PathVariable Long id) throws IOException { Resource resource = fileService.loadAsResource(id); String filename = URLEncoder.encode(resource.getFilename(), StandardCharsets.UTF_8); return ResponseEntity.ok() .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_OCTET_STREAM_VALUE) .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''" + filename) .contentLength(resource.contentLength()) .body(resource); }}The fluent API reads like a sentence: .created(uri) = 201, .noContent() = 204, .ok() = 200. Whenever the status is not 200, or extra headers are needed, reach for ResponseEntity — it is clearer than calling setStatus by hand.
Every backend meets these two, yet their root causes differ completely:
| Symptom | Status | Trigger | First reaction |
|---|---|---|---|
HttpMediaTypeNotSupportedException | 415 | Content-Type is not application/json (or consumes does not match) | Check the client's request header |
HttpMediaTypeNotAcceptableException | 406 | The client's Accept matches none of the producible types | Check produces |
HttpMessageNotReadableException | 400 | Illegal body: missing field, wrong type, bad JSON | Read the raw body |
MissingServletRequestParameterException | 400 | A required @RequestParam is absent | Align parameter names |
MethodArgumentTypeMismatchException | 400 | The string cannot convert to the target type | Check the value's format |
Attention: if a test script sends `{"name":"a"}` but omits `Content-Type: application/json`, the server always answers 415 — it never even gets to parse that JSON. This is not an endpoint bug; the caller simply lost a header.
@GetMapping("/users")public PageResult<UserVO> page( @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int size, @RequestParam(required = false) String sort, @RequestParam(required = false) String keyword) { return userService.page(keyword, page, size, sort);}When there are few parameters, each independent, each needing its own default, individual @RequestParam bindings are the most readable.
@GetMapping("/users/search")public PageResult<UserVO> search(@ModelAttribute UserQuery query) { // query can hold a dozen filter fields and the signature stays clean return userService.search(query);}Once filter fields exceed five or six, individual bindings explode the signature and repeat required = false everywhere. Bundle them into an object and bind with @ModelAttribute — both the signature and validation become easier to maintain.
- Confusing @RequestParam with @PathVariable of the same name:
/users/{id}with@RequestParam Long idwill never get a value, becauseidlives in the path, not the query string. On a 400, first confirm which route this parameter actually takes - Sending an object over GET with @RequestBody: GET typically has no body, so
@RequestBodygives a 415 or reads an empty body. Use@ModelAttributefor compound GET filters - @RequestBody can be read only once: underneath sits
HttpServletRequest.getInputStream(), and a stream is consumed once. If an interceptor reads the body, a later@RequestBodythrowsHttpMessageNotReadableException(Stream closed / read end). To read it more than once, wrap it in a repeatableContentCachingRequestWrapper
All three traps above live in your code. A fourth one does not — it lives in a server property: the size ceiling for application/x-www-form-urlencoded POST bodies. Cross it and Tomcat simply stops parsing form parameters, so your @RequestParam behaves as though the client sent nothing and reports a 400. Drag the number and watch the four shapes this failure takes:
- Login, filter and checkout forms almost all stay under it
- The default is not timid — it assumes a form is not a container for payloads
- If nobody is stuffing large text into forms, leave this alone
The first eight sections covered what you can write. This one steps back to why it is shaped this way — because a picture beats a list of rules.
think of a handler method as the glass window of a post office counter. Four pickup slips are taped to it, each stating where to fetch things: @RequestParam is the annotation box in the top-right corner of the envelope (content the sender fills in, arriving as ?page=1); @PathVariable is the house number (a segment welded into the address, arriving from /users/42); @RequestBody is the parcel itself (everything inside the box, arriving from the body); @RequestHeader is the shipping terms printed on the back of the label (metadata unrelated to contents but mandatory, arriving from headers). Behind the window sits the sorter — the argument resolver chain. It never reads your business logic; it only looks at which place the slip names, then rummages there.
On the other side of the glass there is a second rule: the shape of what you hand out decides how it gets packed. Return an object = packed as a JSON parcel and shipped; return a String = read as "which room to collect your item from", i.e. a view name. That fork is the entire origin of the trap in Section 4.

type conversion (ConversionService) is like the water filter under your sink. Tap water (everything in the HTTP world) is always one substance — a string — while each faucet (method parameter) wants something different: the kitchen wants drinking water (Long), the bathroom wants warm water (LocalDate), the washing machine wants softened water (your own value object OrderNo). ConversionService is the filter unit: it picks a cartridge (Converter / Formatter) by faucet model (target type). You may use a factory cartridge (built-in converters) or screw in your own (register Converter<String, OrderNo>); @DateTimeFormat is a sticky note on one faucet saying "45°C here" — it affects that faucet only. What is scary is not a wrong cartridge failing loudly but passing the water straight through: your LocalDate from silently arrives as null.
After this article you should answer:
- "Do the
1in?page=1and the42in/users/42travel the same road?" (No: query string versus path placeholder, two different resolvers) - "Why does the same string sometimes become a
Longand sometimes a 400?" (Depends whether a format hint and a converter exist) - "Why should a GET endpoint not use
@RequestBody?" (Semantically a query should carry no body; practically you throw away the ability to open the URL in a browser)
Section 2 said "resolvers are tried in order" — worth verifying with your own hands. The chain has this shape:

Note the last box: when no resolver claims the parameter you get IllegalStateException (a 500), not a 400. "I do not know where to fetch your value" and "I fetched it but it is wrong" are two entirely different failures — the first means your signature is broken, the second means the caller's payload is. The demo below turns all five cases into switchable scenes:
Once you can see the shape, you also need to know what it looks like in code. The seven lines on the left are the skeleton of getMethodArgumentValues; variables and the call stack refresh on the right. Press next and watch two things: args[i] and the UNRESOLVED sentinel:
MethodParameter p = parameters.getParameter(i); // 1 take parameter iif (p.getParameterAnnotation(PathVariable.class) != null) { ... } // 2 the annotation is only a signpostfor (HandlerMethodArgumentResolver r : resolvers) { // 3 ask one by one if (r.supportsParameter(p)) { // 4 the first yes wins args[i] = r.resolveArgument(p, mav, webRequest, null); // 5 read the value, convert it break; // 6 nobody after this is asked }}if (args[i] == UNRESOLVED) throw new IllegalStateException(...); // 7 nobody claimed it| i | 0 |
| name | id |
| declared type | Long |
| annotations | [@PathVariable] |
InvocableHandlerMethod.getMethodArgumentValuesPull the lens back one notch: once the chain finishes, how did the request even reach this method? That is doDispatch from #22. The same method seen at two scales tells one story; switch to /nope and you will see "it never even got to argument resolution":
Finally the road after the value is fetched — conversion. "2024-05-01" becoming a LocalDate is not magic; there is a fixed lookup order: built-in converters first, then format annotations on the field, then the Converter you registered yourself.

When this chain fails, the exception resolver takes over and translates it into a status code. Switch to the "conversion failure" scene and you will see the Section 3 ConversionService error landing on a 400 — which explains why "the same bad input prints different words":
The reverse leg of the same road exists too: how the object you return turns back into text. That is owned by content negotiation plus message converters, and its four scenes cover exactly the String ambiguity of Section 4 — in the string scene you watch one and the same "success" split into two fates depending on whether @ResponseBody is present, and the fail scene is the live version of the No converter for ... with preset Content-Type row in Section 11's table:
When the clicking is done, switch to a command line. This console is wired to the same kernel in your browser and every reply is computed — run boot, then type the four parameter sources one by one. The last two are the failure demos:
run lab argres none and lab err convert back to back. The first is "nobody claimed the parameter" (500); the second is "somebody claimed it but could not convert it" (400). Seeing them one after the other is the last time you will ever confuse the two.
| Error snippet | Real cause | Thirty-second fix | Dig deeper in |
|---|---|---|---|
Failed to convert value of type 'java.lang.String' to required type 'java.lang.Long' (wrapped as MethodArgumentTypeMismatchException) | The value is a string that cannot reach the target type: /users/abc against Long id, or a date with no format | Decide whether the caller is wrong or your signature is too wide; add {id:\\d+} so it fails as a 404 instead | Sections 2 and 3 |
Required request parameter 'keyword' for method parameter type String is not present (MissingServletRequestParameterException) | @RequestParam defaults to required = true and the key genuinely is absent | Add defaultValue (it also flips required off) or state required = false | Section 2.1 |
Could not resolve parameter [0] in public ... No suitable resolver found (IllegalStateException) | The parameter has neither a supported annotation nor a built-in type — usually a mistyped annotation (@PathParam belongs to JAX-RS) | Check character by character that the annotation comes from org.springframework.web.bind.annotation.* | Section 10 |
Required request body is missing (HttpMessageNotReadableException) | You declared @RequestBody but no body arrived; most often a GET forced into JSON semantics | Use @ModelAttribute for GET; in Postman pick raw + JSON | Section 6 |
Content-type 'application/x-www-form-urlencoded' not supported (HttpMediaTypeNotSupportedException, 415) | @RequestBody only accepts media types a converter claims; form encoding is not Jackson's | Have the client send -H "Content-Type: application/json"; if you truly want forms, drop @RequestBody | Section 6 |
JSON parse error: Unexpected character ('}' ...) | The body is not valid JSON: trailing comma, single quotes, truncated | Paste the raw body into any JSON validator | #24 Jackson details |
Unrecognized field "user_name" (class UserCreateDTO), not marked as ignorable | Names do not line up and Jackson rejects unknown properties by default | Align the naming or use @JsonProperty; global relaxation is the next row | #24 |
Sending user_name yields null with no error | Two naming regimes clash: Spring MVC binding uses setter names, Jackson uses fields / PropertyNamingStrategy | Do not mix them; plain camelCase everywhere is the cheapest fix | Section 2.4 |
No converter for [class java.util.LinkedHashMap] with preset Content-Type 'text/html' | The return type and the Content-Type you set by hand have no matching converter | Drop the manual header and let negotiation choose, or return a ResponseEntity explicitly | #24 |
Added spring-boot-starter-validation yet @Valid does nothing | The DTO carries @Valid but the fields carry no @NotNull-style constraints | Constraints live on fields; @Valid is only the switch | #25 |
BindingException, or a form body that reads as empty | The @ModelAttribute object lacks getters/setters, so property names miss | Add accessors, or switch to a record / Lombok @Data | Section 2.4 |
| The endpoint occasionally answers with an HTML login page instead of JSON | An interceptor took over static resources or the redirect, and the return value went down the view path | Exclude /error and static paths from the interceptor; give API prefixes their own rule | #26 |
One row in that table is special: Could not resolve parameter [0] is the only 500 caused by your own signature. Here is that stack for real — do not read the analysis first; click the line you believe is guilty:
A brand-new PUT endpoint fails on the first call. The DTO has getters and setters, the client really did send JSON — the only thing nobody checked is which package that parameter annotation was imported from.
to make Jackson lenient about unknown fields the property is spring.jackson.deserialization.fail-on-unknown-properties=false (Spring Boot already defaults to false, though many legacy projects switched it on). Leniency has a price: when the frontend mistypes a field name the endpoint stops complaining and quietly hands you a null — the hardest class of bug to trace in production. Keep strict contracts for public APIs; relax only for internal systems.
The same signature behaves completely differently depending on how @RequestParam's three attributes are combined. Pick a pair and the output is the endpoint's real behaviour.
200 OKpage = 1#?page=1 hits it, and required=true is satisfied
Goal: one endpoint that consumes all four input sources at once, and verify where each one actually comes from.
package com.example.lab.controller;import jakarta.validation.constraints.NotBlank;import org.springframework.http.ResponseEntity;import org.springframework.web.bind.annotation.*;import java.time.LocalDate;import java.util.Map;@RestController@RequestMapping("/api/orders")public class OrderLabController { record OrderVO(Long id, String username, LocalDate from, String token, String keyword) {} @PostMapping("/{id}/search") public ResponseEntity<OrderVO> handle( @PathVariable Long id, // the house number @RequestParam(required = false, defaultValue = "1") int page, // the annotation box @RequestHeader("X-Auth-Token") String token, // the shipping terms @RequestBody SearchForm form) { // the parcel itself return ResponseEntity.ok(new OrderVO(id, form.keyword(), form.from(), token, form.keyword())); } record SearchForm(@NotBlank String keyword, LocalDate from) {} // from must be yyyy-MM-dd // form-style submission (no JSON body): the @ModelAttribute route @GetMapping public ResponseEntity<Map<String, Object>> list(@ModelAttribute PageQuery q) { return ResponseEntity.ok(Map.of("page", q.page, "size", q.size, "from", String.valueOf(q.from))); } static class PageQuery { private int page = 1; private int size = 20; @org.springframework.format.annotation.DateTimeFormat(iso = org.springframework.format.annotation.DateTimeFormat.ISO.DATE) private LocalDate from; public int getPage() { return page; } public void setPage(int page) { this.page = page; } public int getSize() { return size; } public void setSize(int size) { this.size = size; } public LocalDate getFrom() { return from; } public void setFrom(LocalDate from) { this.from = from; } }}Five curls covering four sources and two failures:
# 1) all four sources present -> 200curl -i -X POST http://localhost:8080/api/orders/7/search \ -H "Content-Type: application/json" \ -H "X-Auth-Token: tk-abc" \ -d '{"keyword":"phone","from":"2024-05-01"}'# 2) custom header missing -> 400 MissingRequestHeaderExceptioncurl -i -X POST http://localhost:8080/api/orders/7/search \ -H "Content-Type: application/json" -d '{"keyword":"phone"}'# 3) Content-Type missing -> 415 (the server never gets to read your JSON)curl -i -X POST http://localhost:8080/api/orders/7/search -d '{"keyword":"phone"}'# 4) wrong date format -> 400 Failed to convert value of type 'java.lang.String'curl -i -X POST http://localhost:8080/api/orders/7/search \ -H "Content-Type: application/json" -H "X-Auth-Token: tk-abc" \ -d '{"keyword":"phone","from":"01/05/2024"}'# 5) query string bound into an object -> 200, absent fields keep their defaultscurl -i "http://localhost:8080/api/orders?page=3&from=2024-05-01"Expected response for command 1:
{ "id": 7, "username": "phone", "from": "2024-05-01", "token": "tk-abc", "keyword": "phone"}Expected response for command 5 (size kept its field default of 20):
{ "page": 3, "size": 20, "from": "2024-05-01"}Each edit is one or two lines; the point is to watch the error sentence swap:
- Change
@PathVariable Long idto@RequestParam Long idand rerun command 1 → you observe a 400 whose exception is nowRequired request parameter 'id' ... is not present: the trap from Section 12's first question, made real - Replace
@RequestBody SearchForm formwith@ModelAttribute SearchForm formand sendcurl -X POST .../search?keyword=phone&from=2024-05-01→ you observe a 200: the same data may arrive via the query string, provided you swapped the pickup slip - Add
@Validto the parameter (@Valid @RequestBody SearchForm form) whileSearchFormkeeps@NotBlank String keyword, then send{"keyword":""}→ you observe a 400 withMethodArgumentNotValidExceptionnaming the fieldkeyword. Without@Validthe exact same request sails through with a 200 — that is the division of labour between "switch" and "constraint" - Change the
fromfield type fromLocalDatetoStringand rerun command 4 → you observe a 200: the error did not disappear, you merely postponed it into business code. Section 11's table calls this principle "let the framework fail early"
Build a "filter receiver": a list endpoint with a dozen filter fields that stays clean, readable, validatable and extensible.
Requirements:
- An
OrderQueryobject holdingstatus(enum),from/to(LocalDatewith@DateTimeFormat),minAmount(BigDecimalwith@NumberFormat(pattern = "#,###.##")),keyword,page,size - A custom
Converter<String, OrderStatus>so clients may send lowercaseactiveand still bind toACTIVE - A
@RestControllerAdvicemappingMethodArgumentTypeMismatchExceptionandMissingServletRequestParameterExceptioneach to a unified 400 body carrying acode - Two variants of the same filter: one
@ModelAttribute(GET) and one@RequestBody(POST search), proving both slips deliver identical values
Acceptance checklist:
- [ ]
?status=active&page=2&minAmount=12,345.67binds completely (print the object and verify) - [ ]
?from=2024-13-01returns 400 and the body reveals which field broke — not a bare Whitelabel page - [ ]
?size=(empty) does not explode, and you can explain why it is not "absent" — compare theempty|plaincell of Section 13's sandbox - [ ] The GET variant works without a body; swapping
@ModelAttributefor@RequestBodymakes the same GET fail instantly, and you can name both the status code and the fully-qualified exception - [ ] Adding one more filter changes only
OrderQuery— not a single line of the method signature
given a new requirement "read both values from PUT /coupons/{code}?force=true", can you instantly name the two annotations and their resolver classes? (@PathVariable → PathVariableMethodArgumentResolver, @RequestParam → RequestParamMethodArgumentResolver)
besides supplying a default, what else does defaultValue quietly change? (It flips required to false — the most overlooked side effect)
an unclaimed parameter versus a wrongly-typed value produce which status codes, and why do they differ? (500 IllegalStateException: Could not resolve parameter means your signature is broken; 400 means the caller's value is broken — they fail at different stops of the chain)
why does @RequestParam so often come up empty right after a @RequestBody? (getInputStream() is a one-shot byte stream, drained once read; re-reading needs a ContentCachingRequestWrapper)
the annotation says WHERE to fetch, the type says HOW to convert, @Valid only says "check it once fetched"; read names off the URL, read structure out of the message.
every part of a handler's signature states an intention to the framework — which annotation decides where data enters, which type decides how it is converted, and what you return decides how the response is written. Memorize the input and output tables, and step through the three traps of 415 / 400 / single-read bodies, and there is no @RequestMapping method you cannot write.