spring-projects / spring-projects/spring-data-redis
GenericJacksonJsonRedisSerializer default typing fails for DTO properties declared as Kotlin value classes
Nobody has claimed this yet.
- Dominant language
- Java
- Stars
- 1.9k
- Forks
- 1.3k
- PR merge metrics
- No merged PRs in 30d
Description
Description
GenericJacksonJsonRedisSerializer fails to deserialize a DTO containing a Kotlin value-class property when the Jackson Kotlin module and Spring Data Redis default typing are enabled.
Serialization succeeds, but deserialization fails because Spring Data Redis's custom TypeResolverBuilder expects a polymorphic type ID for the declared value class, while the Jackson Kotlin module serializes it as its underlying scalar value.
The issue can be reproduced without running a Redis server.
Versions
- Spring Data Redis: 4.1.0
- Spring Framework: 7.0.8
- Jackson Databind: 3.1.4
- Jackson Kotlin Module: 3.1.4
- Kotlin: 2.3.21
- Java: 25.0.2
- Spring Boot: 4.1.0, used for dependency management
The same resolver logic is also present on the current main branch.
Reproduction
package example
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import org.springframework.data.redis.serializer.GenericJacksonJsonRedisSerializer
import tools.jackson.databind.jsontype.BasicPolymorphicTypeValidator
import tools.jackson.module.kotlin.kotlinModule
@JvmInline
value class SampleId(val value: Long)
data class SampleResult(
val sampleId: SampleId,
)
class GenericJacksonJsonRedisSerializerValueClassTest {
@Test
fun `round trips a Kotlin value class property`() {
val validator = BasicPolymorphicTypeValidator.builder()
.allowIfSubType("example.")
.build()
val serializer = GenericJacksonJsonRedisSerializer.builder()
.enableDefaultTyping(validator)
.customize { it.addModule(kotlinModule()) }
.build()
val original = SampleResult(SampleId(123L))
val bytes = serializer.serialize(original)
assertEquals(
"""{"@class":"example.SampleResult","sampleId":123}""",
bytes.toString(Charsets.UTF_8),
)
assertEquals(original, serializer.deserialize(bytes))
}
}
The second assertion fails.
Actual behavior
Serialization produces the following JSON:
{
"@class": "example.SampleResult",
"sampleId": 123
}
Deserialization fails with:
org.springframework.data.redis.serializer.SerializationException:
Could not read JSON:
Could not resolve subtype of [simple type, class example.sampleId]:
missing type id property '@class' (for POJO property 'sampleId')
The direct cause is a tools.jackson.databind.exc.InvalidTypeIdException.
Expected behavior
serializer.deserialize(bytes) should return:
SampleResult(SampleId(123L))
A DTO property whose declared type is a Kotlin value class should round-trip when the Jackson Kotlin module is registered.
Analysis
GenericJacksonJsonRedisSerializer.TypeResolverBuilder.useForType() currently contains the following logic:
if (javaType.isFinal() && !KotlinDetector.isKotlinType(javaType.getRawClass())
&& javaType.getRawClass().getPackageName().startsWith("java")) {
return false;
}
This causes useForType() to return true for all non-primitive Kotlin types, including @JvmInline value classes.
The Jackson Kotlin module unboxes SampleId to its underlying long value during serialization. The scalar serializer does not emit polymorphic type information, so the sampleId property does not contain an @class field.
During deserialization, the Spring Data Redis type resolver applies a type deserializer to the declared SampleId property and requires the missing type ID.
This issue is specific to GenericJacksonJsonRedisSerializer with default typing. It is not a general limitation of Spring Data's Kotlin value-class support. A typed JacksonJsonRedisSerializer<SampleResult> with a Kotlin mapper and without default typing works correctly.
Suggested fix
Exclude Kotlin inline classes from default typing after resolving array and reference types:
javaType = resolveArrayOrWrapper(javaType);
if (KotlinDetector.isInlineClass(javaType.getRawClass())) {
return false;
}
if (javaType.isEnumType() || ClassUtils.isPrimitiveOrWrapper(javaType.getRawClass())) {
return false;
}
KotlinDetector.isInlineClass(Class<?>) has been available since Spring Framework 6.1.5.
This change retains type information for ordinary final Kotlin DTOs while allowing the Jackson Kotlin module to reconstruct value-class properties from their declared property types.
It also keeps the serialized JSON format unchanged.
Scope
This report concerns value classes used as concrete DTO properties.
A value class used directly as the root value of an untyped generic serializer remains a separate limitation because scalar JSON does not retain the root value-class name.
The Jackson 2 GenericJackson2JsonRedisSerializer contains analogous resolver logic and may require the same exclusion.
I would be happy to submit a pull request with a regression test if this approach is acceptable.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with GenericJacksonJsonRedisSerializer.TypeResolverBuilder.useForType() and compare its handling of Kotlin inline classes with the reproduction test in the issue. Add a regression test for a Kotlin value-class DTO property, then verify serialization keeps the shown JSON format and deserialization returns the original SampleResult.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- java, kotlin, redis, spring
- Domain
- backend, database
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 78/100