🔍 Kotlin Types vs. HTTP Reality Link to heading

Kotlin encourages strong, expressive types. So when we write a Spring controller, it’s tempting to define something like:

@GetMapping("/search")
fun search(@RequestParam size: Int): SearchResponse

But HTTP isn’t strongly typed. All inputs are strings—often optional, often messy. If someone calls /search?size=foo, this code fails before your logic or validation runs. Spring throws a 400 Bad Request trying to bind "foo" to Int.

Even worse:

@GetMapping("/search")
fun search(@RequestParam query: String): SearchResponse

This will fail if query is missing entirely, even though you might’ve been fine handling a default.


🚫 What Can Go Wrong Link to heading

Let’s say your endpoint expects two parameters: a query string and a size integer:

  • ?size=20 is valid.
  • ?size=twenty triggers a type-cast failure before validation.
  • Missing query causes a failure if it’s defined as a non-null String.

These aren’t validation failures. These are binding or type conversion failures—and they happen too early. Worse, depending on how Spring is configured and how your controller code is written, these failures can lead to unhandled exceptions and result in a 500 Internal Server Error, not just a 400 Bad Request. Kotlin’s strict type system and null safety make it easy to accidentally trigger these early errors. These are binding failures, and they happen too early., and they happen too early.


✅ A Cleaner Strategy: Defaults and Declarative Validation Link to heading

The solution is to stop fighting HTTP. Accept everything as a string with a sane default, validate structure with annotations, and handle domain logic later.

data class SearchRequestMessage(
    @field:NotBlank(message = "Query must not be blank")
    val query: String = "",

    @field:Pattern(regexp = "\d+", message = "Size must be a number")
    val size: String = "20"
)

This gives you:

  • No nulls
  • Clear validation rules
  • Defaults for missing inputs
  • No errors during binding

One important detail when working with validation annotations in Kotlin: you must prefix them with @field: (or @get:) for them to apply to the actual field. Without the prefix, the annotation targets the constructor parameter and silently does nothing—validation won’t be triggered.


🔄 Converting to a Domain Object Link to heading

Instead of putting logic in your DTO, convert it to a real domain type once it’s been validated:

data class SearchRequest(
    val query: String,
    val size: Int
)

fun convertToDomain(msg: SearchRequestMessage): SearchRequest {
    val parsedSize = msg.size.toIntOrNull()
        ?.let { minOf(it, 100) }
        ?: throw IllegalArgumentException("Unable to parse size")

    return SearchRequest(msg.query, parsedSize)
}

We cap size at 100. We assume validation has already run—but the parsing step adds a safety net in case validation was skipped or bypassed.


🧰 Controller Design Link to heading

In theory, you can validate individual GET parameters using annotations like @NotBlank or @Pattern on @RequestParam arguments. But in practice—especially in Kotlin—this rarely works reliably.

Validation on individual parameters often fails to trigger, and Spring will return a 400 without any structured error response. That leads many developers (myself included) to add manual validation in the controller just to generate a proper response body.

Instead, binding query parameters to a message object (like SearchRequestMessage) and annotating it with @Valid ensures that:

  • Validation is triggered reliably
  • You get structured error messages
  • You can reuse the same logic for both GET and POST endpoints

With Spring, this pattern works for both GET and POST methods. The response here is represented by a placeholder SearchResponse object—what it contains isn’t important for this topic. It could be a list of results, a page wrapper, or even just a status message.

@RestController
class SearchController {

    @GetMapping("/search")
    fun searchGet(@Valid search: SearchRequestMessage) = search(search)

    @PostMapping("/search")
    fun searchPost(@Valid @RequestBody body: SearchRequestMessage) = search(body)

    private fun search(msg: SearchRequestMessage): SearchResponse {
        val request = convertToDomain(msg)
        // actual search logic here
    }
}
  • @Valid triggers bean validation
  • SearchRequestMessage can be reused across HTTP methods
  • The domain logic only deals with strongly typed, trusted values

🧨 What Happens If Validation Fails? Link to heading

If a required field is missing or a constraint is violated, Spring will return a 400 Bad Request with the appropriate validation error. You can customize the response shape globally using @ControllerAdvice, but this basic behavior requires no extra effort.

The IllegalArgumentException in the converter should be impossible under normal use. It exists as a sanity check—just in case validation is skipped, the controller is misused, or defaults change without review.


✅ Final Thoughts Link to heading

Kotlin gives you expressive types and null safety—but HTTP is messy and untyped. When you try to map directly from one to the other, things break.

Instead:

  • Accept strings
  • Validate structure
  • Apply defaults
  • Convert to strong types only after validation

You get the safety of Kotlin with the flexibility HTTP requires. Clean code, predictable behavior, and no unexpected errors when someone types size=banana.


When it comes to parameter handling in Kotlin, structure and discipline go a long way. This pattern has worked well for me—and I hope it saves you from a few cryptic 400s too.