Christopher Orr

Handle missing values in Spring controllers using Kotlin

This article explains how optional and default values work with Spring controllers written in Kotlin, and provides a reference for keeping them concise, while avoiding potential pitfalls.

Spring provides a variety of ways to handle HTTP requests — the simplest being annotated controllers with flexible method arguments. These make it easy to access HTTP headers, URL query parameters, the request body, and more, in a type-safe way.

We’ll use this function as an example, in a class annotated with @RestController, which handles incoming HTTP requests to a POST /user/search endpoint:

@PostMapping("/user/search")
fun listUsers(
  @RequestHeader(value = "user-agent", required = false) browser: String,
  @RequestParam(required = false, defaultValue = "100") maxResults: Int,
  payload: UserSearchRequest,
): List<User>
  = TODO("Implement user search")

In this case, Spring Web handles an incoming HTTP request and attempts to map its values to the arguments of this annotated function. The User-Agent HTTP header would be assigned to the browser: String argument thanks to the @RequestHeader annotation.

If the URL path was /user/search?maxResults=123, Spring would see the name and type of the maxResults: Int argument, and coerce the value "123" to an integer. Nice!

Example… This incoming HTTP request:
POST /users/search?maxResults=123

content-type: application/json
accept-encoding: gzip, br
user-agent: curl/8.8.0

{"name": "chris"}

Would cause Spring to make a method call like this:

controller.listUsers(
  browser = "curl/8.8.0",
  maxResults = 123,
  payload = UserSearchRequest(name = "chris"),
)

Dealing with missing values

The @RequestHeader and @RequestParam annotations default to requiring a value, meaning that an HTTP 400 error would be thrown if an incoming request doesn’t include the values corresponding to those annotations.

But as shown, we can allow for missing values by configuring the annotations with required = false, so that we either get null, or we receive a defaultValue, if the incoming request doesn’t include a value.

Old vs new

The Spring Framework is written in Java, and existed long (long) before Kotlin came into existence… so how does it interact with some of the language features that Kotlin introduced?

Knowing that we want to handle incoming HTTP requests with optional values, a Kotlin developer with some Spring experience (like you!) might intuitively write the above function to be a wee bit shorter:

@PostMapping("/user/search")
fun listUsers(
  @RequestHeader("user-agent") browser: String?,
  @RequestParam maxResults: Int? = 100,
  payload: UserSearchRequest,
): List<User>

This takes advantage of Kotlin features like nullable types, and default values, along with Spring’s ever-improving Kotlin integration:

  • Annotate each argument to be populated from a request header or URL parameter
  • Make each argument’s type nullable, since we want all the values to be optional, so:
    • If the header is not provided, browser should be set to null
    • If maxResults is not provided, it should use the default value of 100

But since these Spring annotations default to required = true, will this function be called as expected, if these values are missing from an incoming HTTP request?

Config vs convention

The good news is that Spring will mostly do what you expect with missing values when you ignore the annotation settings and just take advantage of Kotlin language features:

  • ✅ Declaring an argument as nullable will make the request value optional
  • ✅ Providing a default argument value will be used if a request value is missing

Note that this has not always been the case — prior to Spring 6.1 (Spring Boot 3.2), default argument values were ignored — which could lead to unexpected behaviour.

Precedence vs reality

This seems great, and lets us write concise code. But now we have two ways of configuring request arguments: via the annotations, or via Kotlin language features…

While you might expect that providing Spring annotation values would overrule configuration via Kotlin language features, in practice it’s a bit of a mixture:

  • ✅ Annotations with defaultValue take precedence over default argument values
  • 🤔 Annotations with required = true are overriden by nullable type definitions
@PostMapping("/user/search")
fun listUsers(
  // Despite being required, the value will be `null` if no header is provided
  @RequestHeader("user-agent", required = true) browser: String?,
  // If no parameter is provided, the value will be `100` rather than `50`
  @RequestParam(required = false, defaultValue = "100") maxResults: Int? = 50,
  payload: UserSearchRequest,
): List<User>

Oh, the possibilities

This suggests that there are four potential ways of configuring @RequestHeader- or @RequestParam-annotated arguments:

  • Spring annotation: required
  • Spring annotation: defaultValue
  • Kotlin argument: nullable
  • Kotlin argument: default value

Therefore I write some Kotlin… which generated more Kotlin. Specifically, a REST controller with 16 different endpoint functions, to cover all the different ways we can configure a function that has a @RequestHeader (or @RequestParam) annotation.

For example, this case requires a header value, yet has a nullable argument. Which can fall back to an annotation default, or to an argument default value.

@GetMapping("/header/16")
fun test16(
  @RequestHeader(required = true, defaultValue = "annotation-default")
  value: String? = "arg-default",
): String = """`$value`"""

I then called each endpoint, without ever providing the desired HTTP header, and recorded what argument value Spring actually sets when the header is missing:

Annot’n: is required Annot’n: has defaultValue Argument: nullable Argument: default value HTTP status Value when header missing
1 🔥 500
2 🟢 200 arg-default
3 🟢 200 null
4 🟢 200 arg-default
5 🟢 200 annotation-default
6 🟢 200 annotation-default
7 🟢 200 annotation-default
8 🟢 200 annotation-default
9 🟡 400
10 🟢 200 arg-default
11 🟢 200 null
12 🟢 200 arg-default
13 🟢 200 annotation-default
14 🟢 200 annotation-default
15 🟢 200 annotation-default
16 🟢 200 annotation-default

Notes

The results are identical for both @RequestHeader and @RequestParam cases.

In the first 8 cases above, no header value is required; we explicitly set required = false on the annotation:

  • #1 throws a NullPointerException because the value is optional, with no defaults, so Spring attempts to set the non-nullable Kotlin argument type to null.
  • #3 shows how it should be done: we provide a nullable type, and get a null value.
  • #6 and #8 demonstrate the annotation taking precedence: despite providing a default argument value in Kotlin, the annotation’s defaultValue is used.

More interesting are cases #9–16, where we can write less code, and leave the annotation configured with its default required = true value.

  • #9 requires a header value, but none is provided, and there are no defaults, so an HTTP 400 error is raised by the framework. As expected.
  • #11 is the unexpected case: the annotation requires a value, but we provided no defaults. But instead of raising an HTTP 400 error, the nullable argument takes precedence, and receives a null value!

(Also note that cases #13–16 behave the same as cases #5–8, because setting a defaultValue on the annotation will implicitly set required = false on the annotation!)

Behaviour prior to Spring 6.1

If you’re stuck on older Spring versions: Kotlin argument default values were never used when mapping HTTP requests to function arguments before 6.1. This means that:

  • #2 would throw an HTTP 500 error, despite a default value being provided.
  • #4 would set the value to null, despite a default value being provided.

To handle those cases, you should use the annotation to provide a defaultValue.

Summary

When using annotations like @RequestHeader and @RequestParam to define HTTP request handlers, it’s safe to forego most of Spring’s annotation configuration, by taking advantage of Kotlin language features.

Here are the patterns that you should use, and avoid.

✅ Parameter is optional; may be null (#3)

The nullable argument type makes the parameter optional, and the argument value will be null if the city query parameter is not provided in the URL:

@GetMapping("/weather")
fun currentWeather(
  @RequestParam city: String?,
)

🤷 Parameter is optional, but has a fallback (#4)

The nullable argument type makes the parameter optional, but the argument value will be "Berlin" if the city query parameter is not provided in the URL.

Semantically this doesn’t make sense: the argument value is guaranteed to be set, therefore it could be declared as non-nullable. Instead use case #10 below.

@GetMapping("/weather")
fun currentWeather(
  // Doesn't make sense to be nullable
  @RequestParam city: String? = "Berlin", 
)

✅ Parameter is required (#9)

The argument is non-nullable, meaning that the city parameter remains mandatory (due to the default set by the annotation).

If the city query parameter is not provided in the URL, the request will return an HTTP 400 response, and this function won’t be called:

@GetMapping("/weather")
fun currentWeather(
  @RequestParam city: String,
)

✅ Parameter is required, but has a fallback (#10)

The argument is non-nullable. If the city query parameter is not provided in the URL, the function will be called with the fallback value of "Berlin":

@GetMapping("/weather")
fun currentWeather(
  @RequestParam city: String = "Berlin",
)

❌ Parameter is required, but has nullable type (#11)

This scenario may cause confusion, and should be avoided.
If you want the parameter to be mandatory, use case #9 instead.

The annotation has required = true, but gets overridden by the nullable argument type.

If the city query parameter is not provided in the URL, Spring will call the function, passing null as the value — despite us wanting the parameter to be mandatory:

@GetMapping("/weather")
fun currentWeather(
  @RequestParam(required = true) city: String?,
)

Final thought

Would it be useful to have a Detekt ruleset which can warn about incompatible or redundant use of annotation configuration vs argument definitions?

Let me know what you think, and if this article was useful!