Controller Details: Argument Binding, Return Values and Conversion

bee2026-10-0850 min read0 views
How many parameter styles can one handler method take? Where does data come from, how is it converted and validated? The full playground of controller inputs and outputs.
1 / 129
Section
1. The parameter landscape: a signature is an input manifest
2 / 129

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.

3 / 129
Table
Annotation / typeData sourceTypical methodsExample
@RequestParamURL query string ?page=1, or form bodyAny@RequestParam("page") int page
@PathVariableA placeholder inside the URL pathAny@PathVariable Long id
@RequestBodyThe request body (JSON / XML)POST / PUT / PATCH@RequestBody UserDTO dto
@RequestHeaderAn HTTP headerAny@RequestHeader("Authorization") String token
@CookieValueOne key inside a cookieAny@CookieValue("SESSION") String sid
@RequestAttributeA value a filter wrote via request.setAttributeAny@RequestAttribute("userId") Long uid
@ModelAttributeQuery string / form → bound to an objectGET / POST@ModelAttribute UserQuery q
@SessionAttributeA property in HttpSessionAny@SessionAttribute("user") User u
HttpServletRequest / HttpServletResponseThe raw Servlet objectsAnyHttpServletRequest req
InputStream / OutputStreamThe raw request / response byte streamsAnyInputStream in
PrincipalThe authenticated principalAnyPrincipal principal
@ValidTakes no value; triggers validationAny@Valid @RequestBody UserDTO dto
4 / 129
Diagram
Figure 1 · Controller inputs and outputs
Figure 1 · Controller inputs and outputs
5 / 129
Note

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.

6 / 129

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:

7 / 129
Generator
GeneratorThe switches behind this chapter: four dependencies decide what you can usepom.xml2 / 4
Generate once with only web ticked, then read Section 2 against it: @RequestBody works, @Valid is inert. Add validation and the clean query-object signature from Section 7 can finally carry @NotNull. Add lombok or test last, and the BindingException described in 2.4 explains itself.
Output
<?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>
Why each choice matters
parentInheriting 3.3.4 starter-parent means no spring-boot-starter-* needs a version; the moment someone adds an explicit version to one starter, that one wins — the most common source of dependency drift.
WebAnything that serves HTTP needs it: DispatcherServlet, embedded Tomcat and JSON mapping come inside this starter.
ValidationWithout it @Valid silently does nothing — @NotNull on its own checks no one.
8 / 129
Section
2. Binding details, one by one
9 / 129
Section
2.1 The three attributes of @RequestParam
10 / 129
Code
Codejava
@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);    }}
Notes
  • 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 error
  • required = true (the default): a missing parameter throws immediately — do not expect to reach the method body and null-check it
  • defaultValue = "...": it implicitly turns off required, the most easily missed side effect
11 / 129
Section
2.2 Multiple @PathVariable values and regex constraints
12 / 129
java
@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"));}
13 / 129

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.

14 / 129
Section
2.3 @RequestBody and JSON deserialization
15 / 129
java
@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) {}
16 / 129

@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.

17 / 129
Section
2.4 @ModelAttribute form binding
18 / 129
java
@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}
19 / 129

@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.

20 / 129
Section
2.5 Dates and enums
21 / 129
java
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;}
22 / 129

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.

23 / 129
Section
3. Conversion under the hood: ConversionService
24 / 129

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.

25 / 129
Animation
Animation · How one argument is built
Animation · How one argument is built
26 / 129
Section
3.1 @DateTimeFormat / @NumberFormat
27 / 129

These are parameter-level conversion declarations, scoped to the annotated field, with no global registration needed:

28 / 129
java
public class InvoiceQuery {    @DateTimeFormat(pattern = "yyyyMMdd")    private LocalDate billDate;           // 20240501 -> LocalDate    @NumberFormat(pattern = "#,###.##")    private BigDecimal amount;            // "12,345.67" -> BigDecimal}
29 / 129
Section
3.2 A custom Converter: String → value object
30 / 129

When a parameter is not a primitive but a value object of your own (say an OrderNo), you need a custom converter:

31 / 129
Code
Codejava
@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);}
Notes

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.

32 / 129
Section
3.3 Formatter: a locale-aware conversion
33 / 129
java
@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() + "%";    }}
34 / 129

Both Converter and Formatter register via WebMvcConfigurer#addFormatters (annotating with @Component also gets them collected automatically):

35 / 129
java
@Configurationpublic class WebConvertConfig implements WebMvcConfigurer {    @Override    public void addFormatters(FormatterRegistry registry) {        registry.addConverter(new OrderNoConverter());        registry.addFormatter(new PercentFormatter());    }}
36 / 129
Section
4. The return-value landscape
37 / 129

With inputs covered, look at the other end. What a handler returns decides how Spring treats it:

38 / 129
Table
Return valueHandlerBehavior
Plain object + @ResponseBody (or @RestController)RequestResponseBodyMethodProcessorSerialized to JSON via HttpMessageConverter and written back
ResponseEntity<T>HttpEntityMethodProcessorFull control over status, headers and body
voidServletInvocableHandlerMethodAssumes you wrote the response yourself; with @ResponseBody it may also return an empty body
StringDepends on view resolutionNo view resolver = plain text; template engine = view name
ModelAndViewViewNameMethodReturnValueHandlerCarries view name and model for server-side rendering
StreamingResponseBodyStreamingResponseBodyReturnValueHandlerWrites in chunks on a separate thread; good for large downloads
39 / 129
Trap

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".

40 / 129

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:

41 / 129
Diagram
FlowTwo exits for one return value, node by node1 / 5
Tap ① to ⑤; node ③ is the crux — the test is an annotation, not a type. Whenever you cannot explain why one return became JSON and another became a page, the answer is in this node
→
→
→
→
① The method returns; the type is known
At this moment the result is just an Object reference plus its declared type. The type matters a lot — but it matters for step ⑤, how bytes get written, not for which exit the value takes. Hold onto that sentence and the table above stops being something you memorize.
All clearThe annotation decides the meaning, the type only decides how it is written — this one line explains half the traps in this section.
42 / 129

That fork has a real scene you can click. The same return statement, only the class-level annotation swapped, and the exit changes entirely:

43 / 129
Kernel lab
TeaVM@Controller or @RestController: four endings for one return statementidle
Start with view to watch a String turn into a template lookup, then compare with json; missing is the whitelabel 404 scene, and string dissects the trap quoted just above
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
44 / 129

Finally, the six exits as one animation — frames ③ and ④ are the same method body living two different lives:

45 / 129
Animation
Animation · Two exits for one return value
Animation · Two exits for one return value
46 / 129
Section
5. ResponseEntity in practice
47 / 129

When you need precise control over status and headers, use ResponseEntity:

48 / 129
java
@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);    }}
49 / 129

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.

50 / 129
Section
6. Content-Type and bodies in REST: where 415 and 400 come from
51 / 129

Every backend meets these two, yet their root causes differ completely:

52 / 129
Table
SymptomStatusTriggerFirst reaction
HttpMediaTypeNotSupportedException415Content-Type is not application/json (or consumes does not match)Check the client's request header
HttpMediaTypeNotAcceptableException406The client's Accept matches none of the producible typesCheck produces
HttpMessageNotReadableException400Illegal body: missing field, wrong type, bad JSONRead the raw body
MissingServletRequestParameterException400A required @RequestParam is absentAlign parameter names
MethodArgumentTypeMismatchException400The string cannot convert to the target typeCheck the value's format
53 / 129

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.

54 / 129
Section
7. Two common composition templates
55 / 129
Section
7.1 Pagination: individual @RequestParam values
56 / 129
java
@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);}
57 / 129

When there are few parameters, each independent, each needing its own default, individual @RequestParam bindings are the most readable.

58 / 129
Section
7.2 Conditional filters: a @ModelAttribute object
59 / 129
java
@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);}
60 / 129

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.

61 / 129
Section
8. Three high-frequency traps
62 / 129
  • Confusing @RequestParam with @PathVariable of the same name: /users/{id} with @RequestParam Long id will never get a value, because id lives 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 @RequestBody gives a 415 or reads an empty body. Use @ModelAttribute for 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 @RequestBody throws HttpMessageNotReadableException (Stream closed / read end). To read it more than once, wrap it in a repeatable ContentCachingRequestWrapper
63 / 129
Kernel lab
TeaVMSee how the argument resolvers workidle
Use /users/42 to watch @PathVariable convert the string into a Long
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
64 / 129

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:

65 / 129
Tuner
TunerThe form-POST size ceiling: cross it and parameters vanish instead of failing
Slide from 0 to 20MB and pay attention to the 1MB band — its error text is what sends people to blame the frontend
server.tomcat.max-http-form-post-size
2097152bytesNow 0 – 20971520
2MB: the Boot default
  • 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
Parameters lost5%
Heap pressure10%
Form parameters are key-value pairs, not shipping containers: bigger payload goes to multipart, bulk content goes to object storage — never to a raised ceiling.
66 / 129
Decision
Decisionyou are writing a new list endpoint with a dozen filter fields. Bind parameters individually with `@RequestParam`, or into an object with `@ModelAttribute`?
67 / 129
Section
9. Thirty seconds: a controller method is a post office window
68 / 129

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.

69 / 129
类比

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.

70 / 129

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.

71 / 129
Diagram
Figure 2 · Choosing among the four input annotations
Figure 2 · Choosing among the four input annotations
72 / 129
类比

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.

73 / 129

After this article you should answer:

74 / 129
  • "Do the 1 in ?page=1 and the 42 in /users/42 travel the same road?" (No: query string versus path placeholder, two different resolvers)
  • "Why does the same string sometimes become a Long and 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)
75 / 129
Section
10. Hands-on: five steps of the chain and three ways it dies
76 / 129

Section 2 said "resolvers are tried in order" — worth verifying with your own hands. The chain has this shape:

77 / 129
Diagram
Figure 3 · How the resolver chain picks one
Figure 3 · How the resolver chain picks one
78 / 129

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:

79 / 129
Kernel lab
TeaVMInside the argument resolver chainidle
Step through pathvar / reqparam / body / special, then press none to see the 500 when nobody claims it
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
80 / 129

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:

81 / 129
Stepper
StepperStep-through bench: how one argument gets built into args1 / 7
Walk all seven steps. The break at step ⑥ and the sentinel at step ⑦ are the watershed between every failure in this article
Code under debug
1MethodParameter p = parameters.getParameter(i); // 1 take parameter i
2if (p.getParameterAnnotation(PathVariable.class) != null) { ... } // 2 the annotation is only a signpost
3for (HandlerMethodArgumentResolver r : resolvers) { // 3 ask one by one
4 if (r.supportsParameter(p)) { // 4 the first yes wins
5 args[i] = r.resolveArgument(p, mav, webRequest, null); // 5 read the value, convert it
6 break; // 6 nobody after this is asked
7 }
8}
9if (args[i] == UNRESOLVED) throw new IllegalStateException(...); // 7 nobody claimed it
Variables now
i0
nameid
declared typeLong
annotations[@PathVariable]
Call stack
1InvocableHandlerMethod.getMethodArgumentValues
1A MethodParameter is a slot in the signature, not a value. Right now it carries two facts only: the type Long and the annotation list. Assembly begins here.
82 / 129

Pull 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":

83 / 129
Kernel lab
TeaVMFrom URL to method: the whole dispatch pathidle
Compare /users/42 with /users, then try /nope to see which stop cuts it off
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
84 / 129

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.

85 / 129
Animation
Animation · How one string becomes an object
Animation · How one string becomes an object
86 / 129

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":

87 / 129
Kernel lab
TeaVMException resolver chain: how a conversion failure becomes a 400idle
Try convert / valid / handler / status / none and see who catches each kind of exception
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
88 / 129

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:

89 / 129
Kernel lab
TeaVMContent negotiation and converters: how a return value becomes textidle
Walk json / accept / string / fail and focus on the view-name versus body fork in the string scene
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
90 / 129

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:

91 / 129
Console
92 / 129
Note

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.

93 / 129
Section
11. Common errors at a glance
94 / 129
Table
Error snippetReal causeThirty-second fixDig 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 formatDecide whether the caller is wrong or your signature is too wide; add {id:\\d+} so it fails as a 404 insteadSections 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 absentAdd defaultValue (it also flips required off) or state required = falseSection 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 semanticsUse @ModelAttribute for GET; in Postman pick raw + JSONSection 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'sHave the client send -H "Content-Type: application/json"; if you truly want forms, drop @RequestBodySection 6
JSON parse error: Unexpected character ('}' ...)The body is not valid JSON: trailing comma, single quotes, truncatedPaste the raw body into any JSON validator#24 Jackson details
Unrecognized field "user_name" (class UserCreateDTO), not marked as ignorableNames do not line up and Jackson rejects unknown properties by defaultAlign the naming or use @JsonProperty; global relaxation is the next row#24
Sending user_name yields null with no errorTwo naming regimes clash: Spring MVC binding uses setter names, Jackson uses fields / PropertyNamingStrategyDo not mix them; plain camelCase everywhere is the cheapest fixSection 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 converterDrop the manual header and let negotiation choose, or return a ResponseEntity explicitly#24
Added spring-boot-starter-validation yet @Valid does nothingThe DTO carries @Valid but the fields carry no @NotNull-style constraintsConstraints live on fields; @Valid is only the switch#25
BindingException, or a form body that reads as emptyThe @ModelAttribute object lacks getters/setters, so property names missAdd accessors, or switch to a record / Lombok @DataSection 2.4
The endpoint occasionally answers with an HTML login page instead of JSONAn interceptor took over static resources or the redirect, and the return value went down the view pathExclude /error and static paths from the interceptor; give API prefixes their own rule#26
95 / 129

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:

96 / 129
Triage
Error triageIllegalStateException: Could not resolve parameter [0] in public org.springframework.http.ResponseEntity com.example.user.UserController.update(UserUpdateDTO)
Half a package name in one import, and the endpoint returns 500

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.

java.lang.IllegalStateException: Could not resolve parameter [0] in public org.springframework.http.ResponseEntity com.example.user.UserController.update(com.example.user.UserUpdateDTO): No suitable resolver
at org.springframework.web.method.support.InvocableHandlerMethod.getMethodArgumentValues(InvocableHandlerMethod.java:175)
at org.springframework.web.method.support.InvocableHandlerMethod.invokeForRequest(InvocableHandlerMethod.java:143)
at org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:118)
at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.invokeHandlerMethod(RequestMappingHandlerAdapter.java:926)
at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1046)
at com.example.user.UserController.update(UserController.java:31)
Click the frame you blame — guessing is allowed
No pressure: guess the exception first, then which line actually made the call.
97 / 129
坑

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.

98 / 129
Section
12. Quick checks
99 / 129
Quiz
Check yourselfA method is declared `@GetMapping("/orders/{no}")` with the parameter written as `@RequestParam String no`. What happens for `GET /orders/A12`?
Pick one — you get feedback right away
100 / 129
Quiz
Check yourselfA handler declares `@RequestBody Map<String, Object> body`, and the client sends the very same JSON using Postman's `form-data`. What comes back?
Pick one — you get feedback right away
101 / 129
Section
13. Sandbox: tweak one annotation attribute and watch the response change
102 / 129

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.

103 / 129
Sandbox
Sandbox@RequestParam attribute combinations
Result
200 OK
page = 1
#?page=1 hits it, and required=true is satisfied
The ideal alignment: the client did send it and the server did want it
104 / 129
Section
14. Hands-on exercises
105 / 129
Section
Tier one · Follow along
106 / 129

Goal: one endpoint that consumes all four input sources at once, and verify where each one actually comes from.

107 / 129
java
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; }    }}
108 / 129

Five curls covering four sources and two failures:

109 / 129
bash
# 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"
110 / 129

Expected response for command 1:

111 / 129
json
{  "id": 7,  "username": "phone",  "from": "2024-05-01",  "token": "tk-abc",  "keyword": "phone"}
112 / 129

Expected response for command 5 (size kept its field default of 20):

113 / 129
json
{  "page": 3,  "size": 20,  "from": "2024-05-01"}
114 / 129
Section
Tier two · Variants
115 / 129

Each edit is one or two lines; the point is to watch the error sentence swap:

116 / 129
  1. Change @PathVariable Long id to @RequestParam Long id and rerun command 1 → you observe a 400 whose exception is now Required request parameter 'id' ... is not present: the trap from Section 12's first question, made real
  2. Replace @RequestBody SearchForm form with @ModelAttribute SearchForm form and send curl -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
  3. Add @Valid to the parameter (@Valid @RequestBody SearchForm form) while SearchForm keeps @NotBlank String keyword, then send {"keyword":""} → you observe a 400 with MethodArgumentNotValidException naming the field keyword. Without @Valid the exact same request sails through with a 200 — that is the division of labour between "switch" and "constraint"
  4. Change the from field type from LocalDate to String and 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"
117 / 129
Section
Tier three · Build one
118 / 129

Build a "filter receiver": a list endpoint with a dozen filter fields that stays clean, readable, validatable and extensible.

119 / 129

Requirements:

120 / 129
  • An OrderQuery object holding status (enum), from/to (LocalDate with @DateTimeFormat), minAmount (BigDecimal with @NumberFormat(pattern = "#,###.##")), keyword, page, size
  • A custom Converter<String, OrderStatus> so clients may send lowercase active and still bind to ACTIVE
  • A @RestControllerAdvice mapping MethodArgumentTypeMismatchException and MissingServletRequestParameterException each to a unified 400 body carrying a code
  • Two variants of the same filter: one @ModelAttribute (GET) and one @RequestBody (POST search), proving both slips deliver identical values
121 / 129

Acceptance checklist:

122 / 129
  • [ ] ?status=active&page=2&minAmount=12,345.67 binds completely (print the object and verify)
  • [ ] ?from=2024-13-01 returns 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 the empty|plain cell of Section 13's sandbox
  • [ ] The GET variant works without a body; swapping @ModelAttribute for @RequestBody makes 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
123 / 129
Section
15. Self-check
124 / 129
自检

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)

125 / 129
自检

besides supplying a default, what else does defaultValue quietly change? (It flips required to false — the most overlooked side effect)

126 / 129
自检

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)

127 / 129
自检

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)

128 / 129
口诀

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.

129 / 129
Summary

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.