Я заменил реакцию <любое> на герметичные интерфейсы - мои тесты, наконец, имеют смысл

Я заменил реакцию <любое> на герметичные интерфейсы - мои тесты, наконец, имеют смысл

18 июня 2025 г.

Введение

Котлинsealed class/interfaceОграничивает его подклассы: каждый подтип известен во время компиляции и объявлен в одном и том же модуле. Это приносит:

Способность

Выгода

Компилятор знает каждый подтип

whenбезelse→ Добавление нового варианта выделяет каждое место для обновления

Полиморфная сериализация

kotlinx.serializationили Джексон автоматически вводит дискриминатор ("type") в JSON

Четкий контракт

Swagger/OpenAPI может генерировать точную схему для каждой ветви

Почему я должен заботиться?

Выбрасывая «черный ящик»ANYВы получаете самостоятельный, безопасный контракт, который сразу же понимают как клиенты, так и разработчики.

Проблема сResponseEntity<Any>

Контроллер, такой как

@GetMapping("/users/{id}")
fun getUser(@PathVariable id: UUID): ResponseEntity<Any>

создает несколько головных болей:

  • Непонятный тип телосложения- IDE не может намекнуть на то, что находится внутри; вы в конечном итогеisпроверяет или слегка.
  • Риски сериализации- Джексон может потерять информацию о типе и разорвать вложенные даты, большие взорватели и т. Д.
  • Плохая документация- Swagger Showsobject, оставляя потребителей догадываться, что прибывает.
  • Более жесткие тесты- Вы должны проанализировать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Вы быстро почувствуете разницу в ясности и надежности.


Оригинал
PREVIOUS ARTICLE
NEXT ARTICLE