Search before asking
- [x] I searched in the issues and found nothing similar.
Describe the bug
Java Records fail to include @class type information when using DefaultTyping.NON_FINAL, causing Redis caching deserialization failures in Spring Boot applications.
Version Information
2.17.3
Reproduction
@Configuration
public class RedisConfig {
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
RedisCacheConfiguration cacheConfiguration = RedisCacheConfiguration.defaultCacheConfig()
.serializeValuesWith(RedisSerializationContext.SerializationPair
.fromSerializer(new GenericJackson2JsonRedisSerializer(createRedisObjectMapper())));
return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(cacheConfiguration)
.build();
}
private static ObjectMapper createRedisObjectMapper() {
final ObjectMapper mapper = new ObjectMapper();
mapper.activateDefaultTyping(
mapper.getPolymorphicTypeValidator(),
DefaultTyping.NON_FINAL, // Records are excluded here
JsonTypeInfo.As.PROPERTY
);
return mapper;
}
}
public record UserRecord(Long id, String name) {}
@Service
public class UserService {
@Cacheable("users")
public UserRecord getUser(Long id) {
return new UserRecord(id, "User-" + id);
}
}
@Test
void testRecordCaching() {
UserRecord user1 = userService.getUser(1L); // ✅ Works
UserRecord user2 = userService.getUser(1L); // ❌ Fails
}
exception message: com.fasterxml.jackson.databind.exc.InvalidTypeIdException: Could not resolve subtype of [simple type, class java.lang.Object]: missing type id property '@class'
Expected behavior
No response
Additional context
Related to Previous Issue #3512:
This issue was previously reported in #3512 and closed with the following recommendations: - Use DefaultTyping.EVERYTHING instead of NON_FINAL for Records - Consider using regular classes instead of Records
However, these solutions have limitations:
DefaultTyping.EVERYTHING concerns: - Planned for deprecation and removal in Jackson 3.0 (#4160) - Includes unnecessary type metadata for all fields, raising security concerns - No clear migration path for Jackson 3.0
"Avoid Records" approach issues: - High risk of human error (developers forgetting which pattern to use)
Thank you for your consideration.
Comment From: JooHyukKim
First, it would help alot if you can provide a reproduction with only-Jackson? Something like this from our existing test suite would suffice.
Second, DefaultTyping.NON_FINAL
does really mean non-final types, as per documentation...
... so Java Record not being included in DefaultTyping.NON_FINAL
configuration is a natural behavior.
Also, there is another solution suggested -- check out the thread and you will find most answers @cndqjacndqja .
Comment From: cndqjacndqja
@JooHyukKim First of all, thank you for the quick response
I believe NON_FINAL excludes Records because they are final classes that don't support polymorphism, assuming that @class type information wouldn't be needed during deserialization. Reference documentation
The issue occurs in the following case:
public class JacksonTest {
@Test
public void testRecordDeserialization() throws JsonProcessingException {
ObjectMapper mapper = new ObjectMapper();
mapper.activateDefaultTyping(
mapper.getPolymorphicTypeValidator(),
DefaultTyping.NON_FINAL,
JsonTypeInfo.As.PROPERTY
);
User user = new User(1L, "test-user");
String json = mapper.writeValueAsString(user);
// This fails: missing @class property
Object result = mapper.readValue(json, Object.class);
}
}
record User(Long id, String name) {
}
When running this test code, the following error occurs:
com.fasterxml.jackson.databind.exc.InvalidTypeIdException: Could not resolve subtype of [simple type, class java.lang.Object]: missing type id property '@class'
at [Source: REDACTED (`StreamReadFeature.INCLUDE_SOURCE_IN_LOCATION` disabled); line: 1, column: 27]
This appears to be the same issue that occurs when GenericJackson2JsonRedisSerializer uses ObjectMapper. GenericJackson2JsonRedisSerializer deserializes using ObjectMapper as follows:
@Override
public @Nullable Object deserialize(byte @Nullable[] source) throws SerializationException {
return deserialize(source, Object.class);
}
GenericJackson2JsonRedisSerializer
Since it deserializes to Object.class, Records fail due to missing @class type information. Therefore, as shown in the original issue example, when implementing Redis caching using GenericJackson2JsonRedisSerializer, caching Records results in errors.
My question is: Should this be considered a problem with how GenericJackson2JsonRedisSerializer uses ObjectMapper? Was ObjectMapper originally designed with the expectation that classes without polymorphism (like Records or final classes) should NOT be deserialized to Object.class?
Thank you for your time and consideration.
Comment From: cowtowncoder
I would highly recommend ALWAYS using a Wrapper class when serializing "always needing type" values: for this there is no need to even use Default Typing:
public class Wrapper {
@JsonTypeInfo(use = Id.CLASS, include = As.WRAPPER_ARRAY)
public Object cached;
}
and there's guarantee Type Id will be included, even for Records. This because this forces "base type" for polymorphic handling to be java.lang.Object
, which is non-Final.
As to this:
return deserialize(source, Object.class);
this probably expects Object written had base type of java.lang.Object
. This does NOT happen if you write with simple:
mapper.writeValueAsString(valueOb); // base type with be `valueOb.getClass()`
but would work with
mapper.writerFor(Object.class).writeValueAsString(valueOb);
(but may have other problems).
Comment From: cndqjacndqja
Thank you for the quick response. Based on what you've explained, I understand that @class type was designed for leveraging polymorphism when using ObjectMapper. Example:
Animal animal = new Dog("Buddy");
String json = mapper.writeValueAsString(animal); // JSON: ["com.example.Dog", {"name":"Buddy"}]
Animal result = mapper.readValue(json, Animal.class);
From this perspective, records and final classes cannot utilize polymorphism, so @classtype
would be unnecessary for them. Even if we wanted to include @class type for records and final classes, I understand that you suggested using the Wrapper class or writerFor(Object.class) methods that you mentioned.
In other words, the way GenericJackson2JsonRedisSerializer uses ObjectMapper, which I raised in the original issue, seems to be using it for a different purpose (general object storage) than Jackson's original design intent (polymorphism handling).
This appears to be a mismatch between GenericJackson2JsonRedisSerializer's usage pattern and Jackson's design philosophy, rather than a problem with Jackson's design itself. If there are further issues with this, it seems like something that should be considered for resolution on the GenericJackson2JsonRedisSerializer side.
Thanks to your explanation, I was able to understand this better. Thank you for your response.
Comment From: cowtowncoder
@cndqjacndqja While @JsonTypeInfo
(and Default Typing) were originally meant for "simple" polymorphic types, like Animal
, they can definitely be used for a wider set. Problem with GenericJackson2JsonRedisSerializer
is (as far as I know) that it does not force Base Type of java.lang.Object
when the goal is for general polymorphism support; causing issues with the directly referenced root value (all further references are fine). This falls under general Commonly Recurring Problem; (de)serializing Polymorphic root value -- something that "wrapper" Object solves neatly.
Anyway, yes, I think problem is with GenericJackson2JsonRedisSerializer
.
Comment From: cndqjacndqja
@cowtowncoder Thanks for the helpful explanation above.
Using a wrapper object or manually adding @JsonTypeInfo annotations — as you suggested — make a lot of sense and are solid approaches.
That said, I was wondering if it might also be helpful to handle this in a slightly more automatic way. For example, in larger projects, it’s easy to overlook wrapping or annotating a record
, which could lead to issues during deserialization. To help reduce that possibility, I tried configuring a custom TypeResolverBuilder that automatically includes type info for records, like this:
public class RecordSupportingTypeResolver extends DefaultTypeResolverBuilder {
public RecordSupportingTypeResolver(DefaultTyping t, PolymorphicTypeValidator ptv) {
super(t, ptv);
}
@Override
public boolean useForType(JavaType t) {
boolean isRecord = t.getRawClass().isRecord();
return isRecord || super.useForType(t);
}
}
RecordSupportingTypeResolver typeResolver = new RecordSupportingTypeResolver(
DefaultTyping.NON_FINAL,
mapper.getPolymorphicTypeValidator()
);
StdTypeResolverBuilder initializedResolver = typeResolver.init(JsonTypeInfo.Id.CLASS, null);
initializedResolver = initializedResolver.inclusion(JsonTypeInfo.As.PROPERTY);
mapper.setDefaultTyping(initializedResolver);
This seems to work well with GenericJackson2JsonRedisSerializer, even when records are used directly.
Do you think this is a reasonable approach? Just wanted to check in case there are any caveats I might be missing.
Thanks!
Comment From: cowtowncoder
@cndqjacndqja That looks exactly like the way to do it (unless I am missing something)! I think you got it: good job. :)