Skip to main content
spring internals

Argument Resolution and Return Value Handling

7 min read Chapter 45 of 78

Your controller method has five parameters. Each is annotated differently. Some come from the URL path, some from the request body, one from a custom source. The framework resolves all of them before invoking your method. This section covers the machinery that makes that work, and how to extend it for the SaaS backend’s tenant context.

The Argument Resolver Chain

RequestMappingHandlerAdapter holds a HandlerMethodArgumentResolverComposite. This composite wraps a List<HandlerMethodArgumentResolver> containing roughly 30 resolvers in a default Spring Boot application. When the adapter needs to invoke your controller method, it iterates every parameter of that method. For each parameter, it walks the resolver list and calls supportsParameter(MethodParameter). The first resolver that returns true wins.

The result is cached. Once a resolver is matched to a parameter, subsequent requests skip the scan and go directly to the cached resolver. This means the first request to a handler method pays the lookup cost; all subsequent requests are fast.

The resolver ordering matters. Built-in resolvers are registered in a specific sequence:

  1. Annotation-based resolvers: @RequestBody, @RequestParam, @PathVariable, @RequestHeader, @CookieValue, @ModelAttribute
  2. Type-based resolvers: HttpServletRequest, HttpServletResponse, HttpSession, Principal, Locale
  3. Custom resolvers (registered via WebMvcConfigurer.addArgumentResolvers())
  4. Catch-all resolvers: unannotated simple types fall to RequestParamMethodArgumentResolver, unannotated complex types fall to ModelAttributeMethodProcessor

Custom resolvers are added after built-in ones. This means a custom resolver cannot override @RequestBody handling. If you need to intercept body deserialization, use RequestBodyAdvice, not a custom argument resolver.

@RequestBody: HttpMessageConverter Selection

When a parameter is annotated with @RequestBody, RequestResponseBodyMethodProcessor handles it. The resolution path:

  1. Read the Content-Type header from the request.
  2. Iterate the registered HttpMessageConverter list.
  3. For each converter, call canRead(targetType, mediaType).
  4. The first converter that returns true reads the body.

In a standard Spring Boot app, the converter list includes:

  • ByteArrayHttpMessageConverter (for byte[])
  • StringHttpMessageConverter (for String)
  • MappingJackson2HttpMessageConverter (for POJOs with application/json)
  • MappingJackson2XmlHttpMessageConverter (if jackson-dataformat-xml is on the classpath)

For JSON requests, MappingJackson2HttpMessageConverter.canRead() checks that the media type is application/json (or a subtype). It then uses ObjectMapper.readValue() to deserialize the body into your target type.

@PostMapping("/orders")
public ResponseEntity<OrderDto> createOrder(
        @PathVariable String tenantId,
        @RequestBody CreateOrderRequest request) {
    // MappingJackson2HttpMessageConverter reads the JSON body
    // Jackson deserializes into CreateOrderRequest
    // If the JSON is malformed, HttpMessageNotReadableException is thrown
    OrderDto created = orderService.create(tenantId, request);
    return ResponseEntity.status(HttpStatus.CREATED).body(created);
}

The ObjectMapper instance used is the one auto-configured by Spring Boot. Customizations via application.properties (spring.jackson.*) or a Jackson2ObjectMapperBuilderCustomizer bean apply here.

@PathVariable and @RequestParam: Type Conversion

PathVariableMethodArgumentResolver extracts the raw String value from the URI template variables (populated during path matching). RequestParamMethodArgumentResolver extracts from query parameters.

Both delegate type conversion to the ConversionService. Spring Boot auto-configures a DefaultFormattingConversionService with converters for:

  • Primitives and wrappers: String to Long, Integer, Boolean, etc.
  • Dates and times: String to LocalDate, LocalDateTime, Instant (configurable format)
  • Enums: String to any enum type (by name)
  • Custom types: register a Converter<String, YourType> bean
@GetMapping("/orders")
public List<OrderDto> listOrders(
        @PathVariable String tenantId,
        @RequestParam(required = false) OrderStatus status,
        @RequestParam(defaultValue = "0") int page,
        @RequestParam(defaultValue = "20") int size) {
    // "status" query param converted from String to OrderStatus enum
    // "page" and "size" converted from String to int
    return orderService.findByTenant(tenantId, status, page, size);
}

When conversion fails, a TypeMismatchException wraps the root cause. A request to /orders?page=abc produces a 400 response because “abc” cannot convert to int.

Return Value Handler Chain

After your method returns, the adapter iterates HandlerMethodReturnValueHandlerComposite to find a handler for the return type. The chain works identically to argument resolvers: iterate, call supportsReturnType(), delegate to the first match.

Key return value handlers:

Return TypeHandlerBehavior
ResponseEntity<T>HttpEntityMethodProcessorSets status, headers, delegates body to HttpMessageConverter
@ResponseBody ObjectRequestResponseBodyMethodProcessorSelects converter by Accept header, serializes body
String (view name)ViewNameMethodReturnValueHandlerResolves view, renders template
voidVoidMethodReturnValueHandlerNo body written (status from annotation or 200)

ResponseEntity and @ResponseBody both serialize through HttpMessageConverter, but ResponseEntity gives you control over the HTTP status code and response headers. In the SaaS backend, ResponseEntity is preferred for write operations where you need 201 Created or custom headers.

// ResponseEntity: explicit status and headers
@PostMapping("/orders")
public ResponseEntity<OrderDto> createOrder(
        @PathVariable String tenantId,
        @RequestBody CreateOrderRequest request) {
    OrderDto created = orderService.create(tenantId, request);
    URI location = URI.create("/api/v1/tenants/" + tenantId + "/orders/" + created.id());
    return ResponseEntity.created(location).body(created);
    // Status: 201, Location header set, body serialized via Jackson
}

// @ResponseBody (implicit via @RestController): default 200
@GetMapping("/orders/{orderId}")
public OrderDto getOrder(@PathVariable String tenantId,
                          @PathVariable Long orderId) {
    return orderService.findByTenantAndId(tenantId, orderId);
    // Status: 200, body serialized via Jackson
}

Custom ArgumentResolver: Tenant Context from JWT

The SaaS backend needs the tenant ID on every request. Extracting it from a path variable works, but it is the wrong source of truth. The path variable is client-controlled. The tenant identity should come from the authenticated JWT, where the server has already validated it.

// BROKEN: relying on a client-controlled header for tenant identification
@GetMapping("/orders")
public List<OrderDto> listOrders(
        @RequestHeader("X-Tenant-Id") String tenantId) {
    // Any client can send any X-Tenant-Id header.
    // A user authenticated as tenant "acme" can send X-Tenant-Id: "globex"
    // and access another tenant's orders.
    return orderService.findByTenant(tenantId);
}

This is a horizontal privilege escalation vulnerability. The header value is never validated against the authenticated identity. An attacker changes the header value and reads another tenant’s data.

The correct approach: extract the tenant from the JWT that Spring Security has already validated, and inject it as a method parameter through a custom HandlerMethodArgumentResolver.

First, define the parameter annotation:

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface AuthenticatedTenant {
}

Implement the resolver:

// CORRECT: extract tenant from the validated JWT, not from client-controlled input
public class AuthenticatedTenantArgumentResolver implements HandlerMethodArgumentResolver {

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return parameter.hasParameterAnnotation(AuthenticatedTenant.class)
                && parameter.getParameterType().equals(String.class);
    }

    @Override
    public Object resolveArgument(MethodParameter parameter,
                                   ModelAndViewContainer mavContainer,
                                   NativeWebRequest webRequest,
                                   WebDataBinderFactory binderFactory) {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        if (authentication == null) {
            throw new AccessDeniedException("No authentication present");
        }

        if (authentication.getPrincipal() instanceof Jwt jwt) {
            String tenantId = jwt.getClaimAsString("tenant_id");
            if (tenantId == null || tenantId.isBlank()) {
                throw new AccessDeniedException("JWT does not contain tenant_id claim");
            }
            return tenantId;
        }

        throw new AccessDeniedException("Unexpected principal type: "
                + authentication.getPrincipal().getClass().getName());
    }
}

Register it:

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {

    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(new AuthenticatedTenantArgumentResolver());
    }
}

Use it in controllers:

@RestController
@RequestMapping("/api/v1/orders")
public class OrderController {

    @GetMapping
    public List<OrderDto> listOrders(@AuthenticatedTenant String tenantId) {
        // tenantId comes from the JWT, validated by Spring Security
        // No path variable, no header, no client-controlled input
        return orderService.findByTenant(tenantId);
    }

    @PostMapping
    public ResponseEntity<OrderDto> createOrder(
            @AuthenticatedTenant String tenantId,
            @RequestBody CreateOrderRequest request) {
        OrderDto created = orderService.create(tenantId, request);
        return ResponseEntity.status(HttpStatus.CREATED).body(created);
    }
}

The URL no longer contains {tenantId}. The tenant identity is derived from the authentication token, which the server controls. A user cannot impersonate another tenant by modifying a header or path segment.

The resolver plugs into the existing chain. It runs alongside the built-in resolvers for @RequestBody, @PathVariable, and @RequestParam. Each parameter is resolved independently by whichever resolver claims it first.

Verifying Resolver Registration

To confirm your custom resolver is registered and in the expected position:

@Bean
public CommandLineRunner inspectResolvers(RequestMappingHandlerAdapter adapter) {
    return args -> {
        List<HandlerMethodArgumentResolver> resolvers =
                adapter.getArgumentResolvers();
        for (int i = 0; i < resolvers.size(); i++) {
            log.info("Resolver [{}]: {}", i, resolvers.get(i).getClass().getSimpleName());
        }
    };
}

Your custom resolver appears after the built-in ones. This is correct. It means @RequestBody and @PathVariable take priority on parameters annotated with those annotations. @AuthenticatedTenant only activates on parameters that no built-in resolver claims.

If you need your resolver to take priority over a built-in one (rare, and usually a sign of a design problem), you must replace RequestMappingHandlerAdapter’s resolver list entirely. This is fragile and not recommended. Design your parameter annotations to be unambiguous instead.