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

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:

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:

Try migrating just one endpoint from ResponseEntity<Any> to ApiResponse you’ll quickly feel the difference in clarity and reliability.