Skip to content

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
@GraphView
data 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)
@Bean
fun 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: @NodeFragment labels 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 when checking in Kotlin
  • Subtypes are automatically discovered (no registration needed)

Reference the sealed class in your @GraphView:

@GraphView
data 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")
}
}

There are two approaches to filter by polymorphic subtype:

Approach 1: Type-Specific View (Compile-Time)

Create a view that uses the specific subtype:

@GraphView
data 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 AnonymousWebUser
val 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 users
val results = graphObjectManager.loadAll<GuideUserWithPolymorphicWebUser> {
where {
webUser.instanceOf<AnonymousWebUser>()
}
}
// Combine with other conditions
val activeAnonymous = graphObjectManager.loadAll<GuideUserWithPolymorphicWebUser> {
where {
core.guideProgress gte 10
webUser.instanceOf<AnonymousWebUser>()
}
}
// Use in OR conditions
val anonymousOrRegistered = graphObjectManager.loadAll<GuideUserWithPolymorphicWebUser> {
where {
anyOf {
webUser.instanceOf<AnonymousWebUser>()
webUser.instanceOf<RegisteredWebUser>()
}
}
}

The instanceOf<T>() function:

  • Extracts labels from the @NodeFragment annotation on type T
  • Generates a Cypher label check: WHERE EXISTS { ... WHERE webUser:WebUser:Anonymous }
  • Works with anyOf for 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