Introduction A Kotlin sealed class/interface restricts its subclasses: every subtype is known at compile time and declared in the same module. This brings: sealed class interface 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 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 Capability Benefit Capability Capability Benefit Benefit Compiler knows every subtype when without else → adding a new variant highlights every spot to update Compiler knows every subtype Compiler knows every subtype when without else → adding a new variant highlights every spot to update when without else → adding a new variant highlights every spot to update when else Polymorphic serialization kotlinx.serialization or Jackson automatically injects a discriminator ("type") into JSON Polymorphic serialization Polymorphic serialization kotlinx.serialization or Jackson automatically injects a discriminator ("type") into JSON kotlinx.serialization or Jackson automatically injects a discriminator ("type") into JSON kotlinx.serialization "type" Clear contract Swagger/OpenAPI can generate an accurate schema for each branch Clear contract Clear contract Swagger/OpenAPI can generate an accurate schema for each branch 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. Why should I care? 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. ANY The problem with ResponseEntity<Any> ResponseEntity<Any> A controller such as @GetMapping("/users/{id}") fun getUser(@PathVariable id: UUID): ResponseEntity<Any> @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 a LinkedHashMap to verify fields. Unclear body type – the IDE can’t hint at what’s inside; you end up with is checks or casts. Unclear body type is Serialization risks – Jackson may lose type info and break nested dates, BigDecimals, etc. Serialization risks Poor documentation – Swagger shows object, leaving consumers guessing what arrives. Poor documentation object Harder tests – you must parse a String or map a LinkedHashMap to verify fields. Harder tests String LinkedHashMap 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 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 Factor ✅ Pros ❌ Cons Factor Factor ✅ Pros ✅ Pros ❌ Cons ❌ Cons Contract clarity The code reads like an enum of all branches — Contract clarity Contract clarity The code reads like an enum of all branches The code reads like an enum of all branches — — IDE support Autocomplete on subtypes, refactor-safe — IDE support IDE support Autocomplete on subtypes, refactor-safe Autocomplete on subtypes, refactor-safe — — Swagger/OpenAPI Every branch becomes its own schema Needs a discriminator configured Swagger/OpenAPI Swagger/OpenAPI Every branch becomes its own schema Every branch becomes its own schema Needs a discriminator configured Needs a discriminator configured Refactoring The compiler forces handling of new subtype Older clients need migration Refactoring Refactoring The compiler forces handling of new subtype The compiler forces handling of new subtype Older clients need migration Older clients need migration Project structure Single “type → HTTP” mapper A few extra classes Project structure Project structure Single “type → HTTP” mapper Single “type → HTTP” mapper A few extra classes 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> @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") } @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! } @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…" } // 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) } } 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: Any Types describe every scenario—compiler, Swagger, and tests confirm that. Clients receive predictable JSON. Developers save time otherwise spent on casts and debugging. 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. Try migrating just one endpoint from ResponseEntity<Any> to ApiResponse you’ll quickly feel the difference in clarity and reliability. ResponseEntity<Any> ApiResponse