
Я заменил реакцию <любое> на герметичные интерфейсы - мои тесты, наконец, имеют смысл
18 июня 2025 г.Введение
Котлинsealed class
/interface
Ограничивает его подклассы: каждый подтип известен во время компиляции и объявлен в одном и том же модуле. Это приносит:
Способность | Выгода |
---|---|
Компилятор знает каждый подтип |
|
Полиморфная сериализация |
|
Четкий контракт | Swagger/OpenAPI может генерировать точную схему для каждой ветви |
Почему я должен заботиться?
Выбрасывая «черный ящик»ANY
Вы получаете самостоятельный, безопасный контракт, который сразу же понимают как клиенты, так и разработчики.
Проблема сResponseEntity<Any>
Контроллер, такой как
@GetMapping("/users/{id}")
fun getUser(@PathVariable id: UUID): ResponseEntity<Any>
создает несколько головных болей:
- Непонятный тип телосложения- IDE не может намекнуть на то, что находится внутри; вы в конечном итоге
is
проверяет или слегка. - Риски сериализации- Джексон может потерять информацию о типе и разорвать вложенные даты, большие взорватели и т. Д.
- Плохая документация- Swagger Shows
object
, оставляя потребителей догадываться, что прибывает. - Более жесткие тесты- Вы должны проанализировать
String
или карта аLinkedHashMap
Чтобы проверить поля.
Запечатанный интерфейс как контракт с безопасным типом
Фактор | ✅ Плюсы | ❌ минусы |
---|---|---|
Ясность контракта | Код читается как перечисление всех филиалов | - |
IDE поддержка | Автозаполнение на подтипах, Refactor-Safe | - |
Swagger/Openapi | Каждая ветвь становится своей собственной схемой | Нужен дискриминатор, настроенный |
Рефакторинг | Компилятор заставляет обращаться с новым подтипом | Пожилым клиентам нужна миграция |
Структура проекта | Одиночный Mapper «Тип → HTTP» | Несколько дополнительных классов |
Упражняться
Объявление иерархии
@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")
}
Один карт для всех ответов
@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!
}
Пример 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…"
}
Единый тест на сериализацию
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)
}
}
Заключение
Запечатанный подход превращает хаотичныйAny
в строгий, самодокументирующий контракт:
- Типы описывают каждый сценарий - компилер, чванство и тесты подтверждают это.
- Клиенты получают предсказуемый JSON.
- Разработчики экономят время, потраченное на актеры и отладку.
Попробуйте перенести только одну конечную точку изResponseEntity<Any>
кApiResponse
Вы быстро почувствуете разницу в ясности и надежности.
Оригинал