Introduction
A Kotlin sealed class/interface restricts its subclasses: every subtype is known at compile time and declared in the same module. This brings:
| Capability | Benefit |
|—-|—-|
| Compiler knows every subtype | when without else → adding a new variant highlights every spot to update |
| Polymorphic serialization | kotlinx.serialization or Jackson automatically injects a discriminator ("type") into JSON |
| Clear contract | Swagger/OpenAPI can generate an accurate schema for each branch |
:::info
Why should I care?
By throwing away the “black box” ANY, you gain a self-documenting, safe contract that both clients and developers understand instantly.
:::
The problem with ResponseEntity<Any>
A controller such as
@GetMapping("/users/{id}")
fun getUser(@PathVariable id: UUID): ResponseEntity<Any>
creates several headaches:
- Unclear body type – the IDE can’t hint at what’s inside; you end up with
ischecks or casts. - Serialization risks – Jackson may lose type info and break nested dates, BigDecimals, etc.
- Poor documentation – Swagger shows
object, leaving consumers guessing what arrives. - Harder tests – you must parse a
Stringor map aLinkedHashMapto verify fields.
Sealed Interface as a type-safe contract
| Factor | ✅ Pros | ❌ Cons |
|—-|—-|—-|
| Contract clarity | The code reads like an enum of all branches | — |
| IDE support | Autocomplete on subtypes, refactor-safe | — |
| Swagger/OpenAPI | Every branch becomes its own schema | Needs a discriminator configured |
| Refactoring | The compiler forces handling of new subtype | Older clients need migration |
| Project structure | Single “type → HTTP” mapper | A few extra classes |
Practice
Declaring the hierarchy
@Serializable
sealed interface ApiResponse<out T>
@Serializable
data class Success<out T>(val payload: T) : ApiResponse<T>
@Serializable
data class ValidationError(val errors: List<String>) : ApiResponse<Nothing>
@Serializable
data class NotFound(val resource: String) : ApiResponse<Nothing>
Spring WebFlux controllers
@RestController
@RequestMapping("/api/v1/users")
class UserController(private val service: UserService) {
@GetMapping("/{id}")
suspend fun getById(@PathVariable id: UUID): ApiResponse<UserDto> =
service.find(id)?.let(::Success) ?: NotFound("User $id")
@PostMapping
suspend fun create(@RequestBody body: CreateUserDto): ApiResponse<UserDto> =
body.validate()?.let { ValidationError(it) } ?: Success(service.create(body))
@DeleteMapping("/{id}")
suspend fun delete(@PathVariable id: UUID): ApiResponse<Unit> =
if (service.remove(id)) Success(Unit) else NotFound("User $id")
}
One mapper for all responses
@Component
class ApiResponseMapper {
fun <T> toHttp(response: ApiResponse<T>): ResponseEntity<Any> = when (response) {
is Success -> ResponseEntity.ok(response.payload)
is ValidationError -> ResponseEntity.badRequest().body(response.errors)
is NotFound -> ResponseEntity.status(HttpStatus.NOT_FOUND).body(response.resource)
} // no else needed!
}
Example JSON
// 200 OK
{
"type": "Success",
"payload": {
"id": "6a1f…",
"name": "Temirlan"
}
}
// 400 Bad Request
{
"type": "ValidationError",
"errors": ["email is invalid"]
}
// 404 Not Found
{
"type": "NotFound",
"resource": "User 6a1f…"
}
Unit test for serialization
class ApiResponseSerializationTest {
private val json = Json { classDiscriminator = "type" }
@Test
fun `success encodes correctly`() {
val dto = Success(payload = 42)
val encoded = json.encodeToString(
ApiResponse.serializer(Int.serializer()), dto
)
assertEquals("""{"type":"Success","payload":42}""", encoded)
}
}
Conclusion
A sealed approach turns the chaotic Any into a strict, self-documenting contract:
- Types describe every scenario—compiler, Swagger, and tests confirm that.
- Clients receive predictable JSON.
- Developers save time otherwise spent on casts and debugging.
:::tip
Try migrating just one endpoint from ResponseEntity<Any> to ApiResponse you’ll quickly feel the difference in clarity and reliability.
:::