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 |
|
Polymorphic serialization |
|
Clear contract |
Swagger/OpenAPI can generate an accurate schema for each branch |
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
is
checks 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
String
or map aLinkedHashMap
to 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.
Try migrating just one endpoint from ResponseEntity<Any>
to ApiResponse
you’ll quickly feel the difference in clarity and reliability.