Polymorphic Relationships
Drivine supports polymorphic relationship targets using label-based type discrimination. This allows a single relationship to point to different node types. You can define polymorphic types using either sealed classes or interfaces.
Defining Polymorphic Types with Sealed Classes
Section titled “Defining Polymorphic Types with Sealed Classes”Use a sealed class hierarchy with @NodeFragment labels to define polymorphic types:
// Base sealed class - the "WebUser" label is shared by all subtypes@NodeFragment(labels = ["WebUser"])sealed class WebUser { abstract val uuid: UUID abstract val displayName: String}
// Subtype with additional "Anonymous" label@NodeFragment(labels = ["WebUser", "Anonymous"])data class AnonymousWebUser( override val uuid: UUID, override val displayName: String, val anonymousToken: String // Subtype-specific property) : WebUser()
// Subtype with additional "Registered" label@NodeFragment(labels = ["WebUser", "Registered"])data class RegisteredWebUser( override val uuid: UUID, override val displayName: String, val email: String // Subtype-specific property) : WebUser()In Neo4j, nodes have multiple labels:
(:WebUser:Anonymous {displayName: "Guest", anonymousToken: "abc123"})(:WebUser:Registered {displayName: "Alice", email: "alice@example.com"})
Defining Polymorphic Types with Interfaces
Section titled “Defining Polymorphic Types with Interfaces”For library-friendly polymorphism where implementations are defined externally by consumers, use interfaces with runtime registration.
The library defines the interface:
// Library code - interface with @NodeFragment@NodeFragment(labels = ["SessionUser"])interface SessionUser { @get:NodeId // Required for Drivine change detection during save val id: String val displayName: String}
// Library's GraphView uses the interface@GraphViewdata class StoredSession( @Root val session: SessionData, @GraphRelationship(type = "OWNED_BY", direction = Direction.OUTGOING) val owner: SessionUser // Interface type)Consumers implement the interface and register at startup:
// Consumer's implementation.// Only the subtype's own label is needed — the parent's "SessionUser"// label is inherited from the interface's @NodeFragment automatically,// so saved nodes carry (:AppUser:SessionUser).@NodeFragment(labels = ["AppUser"])data class AppUser( @NodeId override val id: String, override val displayName: String, val email: String // Consumer's custom fields) : SessionUser
// Register in configuration (handles both Drivine and Jackson)@Beanfun persistenceManager(factory: PersistenceManagerFactory): PersistenceManager { val pm = factory.get("neo") pm.registerSubtype( SessionUser::class.java, listOf("AppUser", "SessionUser"), // labels the persisted node carries AppUser::class.java ) return pm}The registerSubtype() call configures both:
- Drivine’s label-based polymorphism for loading
- Jackson’s abstract type mapping for save operations
Note:
@NodeFragmentlabels declared on a parent interface (or superclass) are inherited at save time — a subtype persists with the union of its own labels and every annotated supertype’s labels, de-duplicated. You no longer need to repeat the parent’s labels in each subtype’s@NodeFragment.
Use interfaces when:
- Implementations are defined in different modules/libraries
- You want to allow external extensions
- The type hierarchy isn’t known at compile time
Use sealed classes when:
- All subtypes are defined in your codebase
- You want exhaustive
whenchecking in Kotlin - Subtypes are automatically discovered (no registration needed)
Using Polymorphic Relationships
Section titled “Using Polymorphic Relationships”Reference the sealed class in your @GraphView:
@GraphViewdata class GuideUserWithPolymorphicWebUser( @Root val core: GuideUser, @GraphRelationship(type = "IS_WEB_USER", direction = Direction.OUTGOING) val webUser: WebUser? // Polymorphic - could be Anonymous or Registered)When loading, Drivine automatically deserializes to the correct subtype based on labels:
val results = graphObjectManager.loadAll<GuideUserWithPolymorphicWebUser> { }
results.forEach { guide -> when (val user = guide.webUser) { is AnonymousWebUser -> println("Anonymous: ${user.anonymousToken}") is RegisteredWebUser -> println("Registered: ${user.email}") null -> println("No web user") }}Filtering Polymorphic Types
Section titled “Filtering Polymorphic Types”There are two approaches to filter by polymorphic subtype:
Approach 1: Type-Specific View (Compile-Time)
Create a view that uses the specific subtype:
@GraphViewdata class AnonymousGuideUser( @Root val core: GuideUser, @GraphRelationship(type = "IS_WEB_USER", direction = Direction.OUTGOING) val webUser: AnonymousWebUser // Specific type, not WebUser)
// Only returns guides with AnonymousWebUserval anonymousGuides = graphObjectManager.loadAll<AnonymousGuideUser> { }The generated query automatically filters by the subtype’s labels.
Approach 2: instanceOf DSL (Runtime)
Use instanceOf<T>() to filter at query time while keeping the polymorphic view:
import org.drivine.query.dsl.instanceOf
// Filter to only anonymous usersval results = graphObjectManager.loadAll<GuideUserWithPolymorphicWebUser> { where { webUser.instanceOf<AnonymousWebUser>() }}
// Combine with other conditionsval activeAnonymous = graphObjectManager.loadAll<GuideUserWithPolymorphicWebUser> { where { core.guideProgress gte 10 webUser.instanceOf<AnonymousWebUser>() }}
// Use in OR conditionsval anonymousOrRegistered = graphObjectManager.loadAll<GuideUserWithPolymorphicWebUser> { where { anyOf { webUser.instanceOf<AnonymousWebUser>() webUser.instanceOf<RegisteredWebUser>() } }}The instanceOf<T>() function:
- Extracts labels from the
@NodeFragmentannotation on typeT - Generates a Cypher label check:
WHERE EXISTS { ... WHERE webUser:WebUser:Anonymous } - Works with
anyOffor OR conditions
| Approach | When to Use |
|---|---|
| Type-specific view | You always want a specific subtype; compile-time type safety |
instanceOf<T>() |
Dynamic filtering; single view for multiple subtypes |

