Sealed class as Entity

1.1k views Asked by At

I have an issue with sealed classes. If I run my application from docker it works perfectly fine, but if I do the same in IntelliJ I run into the following exception:

java.lang.IncompatibleClassChangeError: class com.nemethlegtechnika.products.model.Attribute$HibernateProxy$D0WxdNVz cannot inherit from sealed class com.nemethlegtechnika.products.model.Attribute

If I use an abstract class instead of a sealed one, I get no errors in IntelliJ as well as in Docker. Can you guys help me find the root of the problem?

Thanks in advance and have a wonderfull day! :)

The Classes:
package com.nemethlegtechnika.products.model

import jakarta.persistence.*
import org.hibernate.annotations.DiscriminatorFormula

@Entity
@Table(name = "attribute")
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorFormula("case when stringValues is not null then 'string' else 'boolean' end")
sealed class Attribute : BaseEntity() {

    @ManyToOne(fetch = FetchType.EAGER, optional = false)
    @JoinColumn(name = "product_id")
    val product: Product? = null

    @ManyToOne(fetch = FetchType.EAGER, optional = false)
    @JoinColumn(name = "group_id")
    val group: Group? = null

    abstract val value: Any
}

@Entity
@DiscriminatorValue("boolean")
class BooleanAttribute : Attribute() {

    @Column(name = "boolean_value", nullable = true)
    val booleanValue: Boolean = false

    override val value: Boolean
        get() = booleanValue
}

@Entity
@DiscriminatorValue("string")
class StringAttribute : Attribute() {

    @Column(name = "string_value", nullable = true)
    val stringValue: String = ""

    override val value: String
        get() = stringValue
}
The Error:
Caused by: java.lang.IncompatibleClassChangeError: class com.nemethlegtechnika.products.model.Attribute$HibernateProxy$D0WxdNVz cannot inherit from sealed class com.nemethlegtechnika.products.model.Attribute
    at java.base/java.lang.ClassLoader.defineClass0(Native Method) ~[na:na]
    at java.base/java.lang.System$2.defineClass(System.java:2307) ~[na:na]
    at java.base/java.lang.invoke.MethodHandles$Lookup$ClassDefiner.defineClass(MethodHandles.java:2439) ~[na:na]
    at java.base/java.lang.invoke.MethodHandles$Lookup$ClassDefiner.defineClass(MethodHandles.java:2416) ~[na:na]
    at java.base/java.lang.invoke.MethodHandles$Lookup.defineClass(MethodHandles.java:1843) ~[na:na]
    at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ~[na:na]
    at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77) ~[na:na]
    at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[na:na]
    at java.base/java.lang.reflect.Method.invoke(Method.java:568) ~[na:na]
    at net.bytebuddy.utility.Invoker$Dispatcher.invoke(Unknown Source) ~[na:na]
    at net.bytebuddy.utility.dispatcher.JavaDispatcher$Dispatcher$ForNonStaticMethod.invoke(JavaDispatcher.java:1032) ~[byte-buddy-1.12.23.jar:na]
    at net.bytebuddy.utility.dispatcher.JavaDispatcher$ProxiedInvocationHandler.invoke(JavaDispatcher.java:1162) ~[byte-buddy-1.12.23.jar:na]
    at jdk.proxy2/jdk.proxy2.$Proxy118.defineClass(Unknown Source) ~[na:na]
    at net.bytebuddy.dynamic.loading.ClassInjector$UsingLookup.injectRaw(ClassInjector.java:1638) ~[byte-buddy-1.12.23.jar:na]
    at net.bytebuddy.dynamic.loading.ClassInjector$AbstractBase.inject(ClassInjector.java:118) ~[byte-buddy-1.12.23.jar:na]
    at net.bytebuddy.dynamic.loading.ClassLoadingStrategy$UsingLookup.load(ClassLoadingStrategy.java:519) ~[byte-buddy-1.12.23.jar:na]
    at net.bytebuddy.dynamic.TypeResolutionStrategy$Passive.initialize(TypeResolutionStrategy.java:101) ~[byte-buddy-1.12.23.jar:na]
    at net.bytebuddy.dynamic.DynamicType$Default$Unloaded.load(DynamicType.java:6317) ~[byte-buddy-1.12.23.jar:na]
    at org.hibernate.bytecode.internal.bytebuddy.ByteBuddyState$1.run(ByteBuddyState.java:203) ~[hibernate-core-6.1.7.Final.jar:6.1.7.Final]
    at org.hibernate.bytecode.internal.bytebuddy.ByteBuddyState$1.run(ByteBuddyState.java:199) ~[hibernate-core-6.1.7.Final.jar:6.1.7.Final]
    at org.hibernate.bytecode.internal.bytebuddy.ByteBuddyState.lambda$load$0(ByteBuddyState.java:212) ~[hibernate-core-6.1.7.Final.jar:6.1.7.Final]
    at net.bytebuddy.TypeCache.findOrInsert(TypeCache.java:168) ~[byte-buddy-1.12.23.jar:na]
2

There are 2 answers

7
VonC On BEST ANSWER

That looks like a conflict between Kotlin's sealed classes and Hibernate's proxy creation.

  • Sealed classes in Kotlin have a restriction that prevents any other classes outside their file (and hence outside their control) from inheriting from them.
  • Hibernate, however, often uses proxy classes for lazy loading, which are essentially subclasses of the target class.

When you run this code with Hibernate in IntelliJ, Hibernate is trying to create a proxy subclass of Attribute. Since Attribute is a sealed class, Kotlin denies this subclassing, hence the IncompatibleClassChangeError error.

The reason you might not see this in Docker could be due to different configurations or versions of libraries being used between your local setup and your Docker setup. For example, perhaps in the Docker setup, lazy loading is not being used, or maybe there is a different version of Hibernate or Kotlin that behaves differently.

See for instance "java.lang.IncompatibleClassChangeError", which states:

I think you are using incompatible versions of hibernate core and annotations.

First, make sure your development environment (IntelliJ) is as close as possible to your production environment (Docker, in this case). That might include matching Java, Kotlin, and library versions and ensuring any JVM arguments or configurations are consistent.

Second, for testing, if the issue still persists, try and disable lazy loading for Attribute``. That will prevent Hibernate from creating proxy subclasses, and thus you will avoid this error. You can try using the @Proxy(lazy = false)` annotation on your entity to disable the creation of proxy instances for that specific entity.

@Entity
@Table(name = "attribute")
@Proxy(lazy = false)
sealed class Attribute : BaseEntity() {
    // ...
}

I forgot to add that I configured in gradle that all my classes with Entity or MappedSuperclass annotation is open by default

The "Why can't entity class in JPA be final?" link highlights a key issue when working with JPA (Java Persistence API) providers, like Hibernate. These providers often create runtime proxies (subclasses) of entity classes to enable features like lazy loading. If a class is marked as final, it cannot be subclassed, and hence, Hibernate cannot create its proxies. That leads to errors when the application runs.

Kotlin classes are final by default. In the Kotlin world, a common workaround for the Hibernate proxy issue is to use the kotlin-allopen Gradle plugin. That plugin makes specified classes (e.g., those annotated with @Entity or @MappedSuperclass) non-final during compilation, allowing Hibernate to subclass them for proxy creation.

So, if the OP has configured their Gradle build with kotlin-allopen to treat classes annotated with @Entity or @MappedSuperclass as open, then those classes should be able to be proxied by Hibernate.

However, the problem here is not with a final class but with a sealed class. Sealed classes in Kotlin, by design, restrict which classes can inherit from them. That means that even if the class itself is not final, Hibernate cannot create a proxy subclass because it will not be one of the pre-defined subclasses allowed by the sealed class definition.

Therefore, the solution would be to not use sealed classes for entities when working with JPA providers like Hibernate, or find a way to configure Hibernate not to use proxying (like disabling lazy loading) for these entities, as mentioned above.


Chris Hinshaw suggests in the comments, as a workaround, to use Data class (as in data class Attribute) which, while not eliminating inheritance, will keep the contract tidy.

I objected it would bring Kotlin's data classes automatically generated methods like equals(), hashCode(), and copy(), generated methods which might not behave correctly with JPA's lazy loading or proxying mechanisms.

But Chris confirmed:

We currently use Spring and R2DBC (Reactive Relational Database Connectivity), which should function the same. It will likely require the Kotlin / All-open compiler plugin to override the finality of Kotlin classes.

I have seen the proxy code in hibernate-core at one point and it has a filter to skip equals() and hashcode() functions, not sure if they have a filter for Kotlin's copy function but I would imagine it would work as expected.

(possible code: hibernate/hibernate-orm hibernate-core/src/main/java/org/hibernate/proxy/pojo/bytebuddy/ByteBuddyProxyHelper.java)

Actually after thinking about it the "All Open Plugin", looks like it works for sealed classes as well. It's worth a try adding it to your plugins to check.


However, as noted in the comments by the OP Botond Németh, it is important to note that using data classes as JPA entities is generally not recommended (See tut-spring-boot-kotlin / Persistence with JPA), because:

  1. Data classes automatically generate equals() and hashCode() methods. Although Chris mentioned that Hibernate has filters to skip these methods during proxying, the auto-generated methods can still conflict with JPA's mechanisms for proxying and lazy loading.

  2. Data classes also generate a copy() function, and it is unclear how well this will play with JPA's lifecycle and state management. It might introduce unexpected behavior, especially when dealing with persisted objects.

So, even though Hibernate may have specific filters to deal with some of these methods, using data classes as JPA entities could still introduce subtle issues and is generally not aligned with best practices.

0
rahat On

I am not an expert in Hibernate, but I know Kotlin, Please go through below

sealed class Test


class Test1 : Test()

class Test2 : Test()

Generated Bytecode would be as follows

public abstract class Test {
   private Test() {
   }

   // $FF: synthetic method
   public Test(DefaultConstructorMarker $constructor_marker) {
      this();
   }
}


public final class Test1 extends Test {
   public Test1() {
      super((DefaultConstructorMarker)null);
   }
}


public final class Test2 extends Test {
   public Test2() {
      super((DefaultConstructorMarker)null);
   }
}

Now as you said you don't face issues with abstract classes let's jump into that,

abstract class TestAbstract


class TestAb1 : TestAbstract()
class TestAb2 : TestAbstract()

Generated bytecode would be as follows


public abstract class TestAbstract {
}


public final class TestAb1 extends TestAbstract {
}

public final class TestAb2 extends TestAbstract {
}

Now

If you have gone through the code and bytecode thoroughly, you might have observed that,

public abstract class Test {
   private Test() {
   }

   // $FF: synthetic method
   public Test(DefaultConstructorMarker $constructor_marker) {
      this();
   }
}

is the byte code for the parent sealed class, what is the deal here, the thing is the default constructor is private and there is another overloaded constructor with DefaultConstructorMarker as a param type.

Now since the compiler / Docker while does compile i.e. generates a proxy class that would essentially inherit the sealed i.e. the compiled Test class it is unable to find the default constructor and hence is unable to generate the class. This is why it is complaining that it can not inherit, i.e. can not inherit from a class whose default constructor is private

Try like below you will get the error as in the below picture, in the editor itself.

abstract class with private default constructor

Note: Hibernate does not know who is DefaultConstructorMarker, it is a Kotlin JVM class and the Kotlin compiler does the improvisation to allow sealed class creation through abstract class under the hood. But the Hibernate SDK/annotation processor knows that it should get a default public constructor to call when generating a Proxy class.

I hope the above explanation and given details answer the question.