A Neo4j client library for Java and Kotlin with two approaches to graph mapping:
- PersistenceManager - Low-level API with manual Cypher queries (classic Drivine approach)
- GraphObjectManager - High-level API with annotated models and type-safe DSL (new in 4.0)
A typical ORM defines a reusable object model. From this model statements are generated to hydrate to and from the model to the database. This addresses the so-called impedance mismatch between the object model and the database. However, there are drawbacks:
- Generated queries work well for simple cases, but can get out of hand and degrade performance when it's more complex. Debugging these generated statements can be painful.
- One model for many use cases is a big ask - the original CRUD cases work well, but more complex cases mean the model gets in the way more than it helps.
These trade-offs might be acceptable for relational databases, but with graph databases this mismatch doesn't really exist.
Just as we favor composition over inheritance in software development, we prefer composition when mapping results from complex queries. A person can play many roles: sometimes we're here to help them have a great holiday, other times to manage a team, in others they're a person of interest. With Drivine, you compose views as needed:
@GraphView
data class HolidayingPerson(
@Root val person: Person,
@GraphRelationship(type = "BOOKED_HOLIDAY")
val holidays: List<Holiday>
)Behind the scenes, Drivine generates efficient Cypher:
MATCH (person:Person {firstName: $firstName})
WITH person, [(person)-[:BOOKED_HOLIDAY]->(holiday:Holiday) | holiday {.*}] AS holidays
RETURN {
person: properties(person),
holidays: holidays
}Composition lets us mix and match as needed.
- Java 21+
- Kotlin:
- For PersistenceManager API: Any Kotlin version
- For GraphObjectManager API: Kotlin 2.2.0+ (requires context parameters feature)
dependencies {
implementation("org.drivine:drivine4j:0.0.1-SNAPSHOT")
}dependencies {
implementation 'org.drivine:drivine4j:0.0.1-SNAPSHOT'
}<dependency>
<groupId>org.drivine</groupId>
<artifactId>drivine4j</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>If you want to use GraphObjectManager with the type-safe query DSL, you need to add the code generation processor.
Note for Java Projects: The code generator (KSP) only processes Kotlin source files. For the best experience:
- Define your
@GraphViewclasses in Kotlin to get the generated type-safe DSL- Your
@NodeFragmentclasses can be in Java or Kotlin- At runtime, both Java and Kotlin classes work fully with
GraphObjectManagerSee the Java Interoperability section for details and examples.
plugins {
id("com.google.devtools.ksp") version "2.2.20-2.0.4"
kotlin("jvm") version "2.2.0"
}
kotlin {
compilerOptions {
// Required for context parameters DSL
freeCompilerArgs.addAll("-Xcontext-parameters")
}
}
dependencies {
implementation("org.drivine:drivine4j:0.0.1-SNAPSHOT")
ksp("org.drivine:drivine4j-codegen:0.0.1-SNAPSHOT")
}<build>
<plugins>
<plugin>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-plugin</artifactId>
<version>2.2.0</version>
<configuration>
<compilerPlugins>
<compilerPlugin>ksp</compilerPlugin>
</compilerPlugins>
<args>
<!-- Required for context parameters DSL -->
<arg>-Xcontext-parameters</arg>
</args>
</configuration>
<dependencies>
<!-- KSP extension for Maven -->
<dependency>
<groupId>com.dyescape</groupId>
<artifactId>kotlin-maven-symbol-processing</artifactId>
<version>1.6</version>
</dependency>
<!-- Drivine code generator -->
<dependency>
<groupId>org.drivine</groupId>
<artifactId>drivine4j-codegen</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>
</dependencies>
</plugin>
</plugins>
</build>Note: Maven support for KSP uses the third-party kotlin-maven-symbol-processing extension.
@Configuration
@ComponentScan("org.drivine")
class AppConfig {
@Bean
fun dataSourceMap(): DataSourceMap {
val props = ConnectionProperties(
host = "localhost",
port = 7687,
username = "neo4j",
password = "password",
database = "neo4j"
)
return DataSourceMap(mapOf("neo" to props))
}
}data class Person(
val uuid: String,
val firstName: String,
val lastName: String,
val email: String?,
val age: Int
)@Component
class PersonRepository @Autowired constructor(
@Qualifier("neoManager") val manager: PersistenceManager
) {
@Transactional
fun findByCity(city: String): List<Person> {
return manager.query(
QuerySpecification
.withStatement<Any>("MATCH (p:Person {city: \$city}) RETURN properties(p)")
.bind(mapOf("city" to city))
.transform(Person::class.java)
)
}
@Transactional
fun findById(id: String): Person? {
return manager.maybeGetOne(
QuerySpecification
.withStatement<Any>("MATCH (p:Person {uuid: \$id}) RETURN properties(p)")
.bind(mapOf("id" to id))
.transform(Person::class.java)
)
}
@Transactional
fun create(person: Person): Person {
return manager.getOne(
QuerySpecification
.withStatement<Any>("CREATE (p:Person) SET p = \$props RETURN properties(p)")
.bindObject("props", person)
.transform(Person::class.java)
)
}
@Transactional
fun update(uuid: String, patch: Partial<Person>): Person {
val props = patch.toMap()
return manager.getOne(
QuerySpecification
.withStatement<Any>(
"MATCH (p:Person {uuid: \$uuid}) SET p += \$props RETURN properties(p)"
)
.bind(mapOf("uuid" to uuid, "props" to props))
.transform(Person::class.java)
)
}
}GraphObjectManager provides a high-level API for working with graph-mapped objects using annotated models. It generates efficient Cypher queries automatically and provides a type-safe DSL for filtering and ordering.
A @NodeFragment represents a single node in the graph:
@NodeFragment
data class Person(
@NodeId val uuid: String,
val name: String,
val bio: String?
)
@NodeFragment
data class Organization(
@NodeId val uuid: String,
val name: String
)A @RelationshipFragment captures properties on relationship edges, not just the target node:
@RelationshipFragment
data class WorkHistory(
val startDate: LocalDate, // Property on the edge
val role: String, // Property on the edge
val target: Organization // Target node
)This is useful for modeling:
- Employment history (start date, role, organization)
- Transaction records (timestamp, amount, target account)
- Audit trails (timestamp, action, target entity)
- Any relationship with metadata
A @GraphView composes multiple fragments and relationships into a single query result:
@GraphView
data class PersonCareer(
@Root val person: Person, // Root fragment
@GraphRelationship(type = "WORKS_FOR")
val employmentHistory: List<WorkHistory> // Relationship with properties
)The @Root annotation marks which fragment is the query's starting point.
@Component
class PersonService @Autowired constructor(
private val graphObjectManager: GraphObjectManager
) {
fun getAllPeople(): List<PersonCareer> {
return graphObjectManager.loadAll(PersonCareer::class.java)
}
}fun getPerson(uuid: String): PersonCareer? {
return graphObjectManager.load(uuid, PersonCareer::class.java)
}The code generator creates a type-safe DSL for each @GraphView, giving you IntelliJ autocomplete and compile-time type checking.
// Load people whose bio contains "Lead"
val leads = graphObjectManager.loadAll<PersonCareer> {
where {
person.bio contains "Lead" // Direct property access!
}
}val results = graphObjectManager.loadAll<PersonCareer> {
where {
person.name eq "Alice Engineer"
person.bio.isNotNull()
}
}
// Generates: WHERE person.name = $p0 AND person.bio IS NOT NULLval results = graphObjectManager.loadAll<PersonCareer> {
where {
anyOf {
person.name eq "Alice"
person.name eq "Bob"
}
}
}
// Generates: WHERE (person.name = $p0 OR person.name = $p1)val results = graphObjectManager.loadAll<PersonCareer> {
where {
person.bio.isNotNull()
}
orderBy {
person.name.asc()
}
}Comparison:
eq- equals (=)neq- not equals (<>)gt- greater than (>)gte- greater than or equal (>=)lt- less than (<)lte- less than or equal (<=)in- IN operator
String Operations:
contains- CONTAINSstartsWith- STARTS WITHendsWith- ENDS WITH
Null Checking:
isNull()- IS NULLisNotNull()- IS NOT NULL
Ordering:
asc()- ascending orderdesc()- descending order
GraphObjectManager tracks loaded objects and only saves changed fields:
// Load an object
val person = graphObjectManager.load(uuid, PersonCareer::class.java)!!
// Modify it
val updated = person.copy(
person = person.person.copy(bio = "Updated bio")
)
// Save - only dirty fields are written!
graphObjectManager.save(updated)val person = graphObjectManager.load(uuid, PersonCareer::class.java)!!
// Remove all employment history
val updated = person.copy(employmentHistory = emptyList())
graphObjectManager.save(updated, CascadeType.NONE)GraphObjectManager provides type-safe methods for deleting graph objects.
// Delete a single node by UUID
val deleted = graphObjectManager.delete<Person>(uuid)
// Delete a GraphView's root node (relationships are detached)
graphObjectManager.delete<RaisedAndAssignedIssue>(issueUuid)// Delete only if condition is met
graphObjectManager.delete<Issue>(uuid, "n.state = 'closed'")
// For GraphViews, use the root fragment alias
graphObjectManager.delete<RaisedAndAssignedIssue>(uuid, "issue.state = 'closed'")// Delete all matching a condition
graphObjectManager.deleteAll<Issue>("n.state = 'closed'")
// For GraphViews
graphObjectManager.deleteAll<RaisedAndAssignedIssue>("issue.locked = true")The most powerful way - uses generated DSL for compile-time type checking:
// Delete closed issues
graphObjectManager.deleteAll<RaisedAndAssignedIssue> {
where {
issue.state eq "closed"
}
}
// Delete with multiple conditions
graphObjectManager.deleteAll<RaisedAndAssignedIssue> {
where {
issue.state eq "open"
issue.locked eq true
}
}
// Delete by relationship property
graphObjectManager.deleteAll<RaisedAndAssignedIssue> {
where {
assignedTo.name eq "Former Employee"
}
}
// Delete all (no filter)
graphObjectManager.deleteAll<RaisedAndAssignedIssue> { }All delete operations use DETACH DELETE:
- Removes the node and all its relationships
- Related nodes are not deleted (only the relationships to them)
- Returns the count of deleted nodes
// Delete an issue - persons remain, only ASSIGNED_TO/RAISED_BY relationships removed
graphObjectManager.delete<RaisedAndAssignedIssue>(issueUuid)
// Verify related nodes still exist
val person = graphObjectManager.load<Person>(personUuid) // Still there!When saving @GraphView objects with modified relationships, CascadeType determines what happens to target nodes:
Only deletes the relationship, leaves target nodes intact:
graphObjectManager.save(updated, CascadeType.NONE)Use when: Target nodes are shared or should persist independently.
Deletes relationship and target only if no other relationships exist to the target:
graphObjectManager.save(updated, CascadeType.DELETE_ORPHAN)Use when: You want to clean up orphaned nodes but preserve shared ones.
Example: Removing a person's employment at a solo startup deletes the startup (orphaned), but removing employment at a company with other employees keeps the company.
Always deletes both the relationship and target nodes:
graphObjectManager.save(updated, CascadeType.DELETE_ALL)Use when: Target nodes are exclusively owned and should be deleted with the relationship.
GraphObjectManager maintains a session that tracks loaded objects:
- On Load: Takes a snapshot of the object's state
- On Save: Compares current state to snapshot
- Optimization: Only writes changed fields (dirty checking)
This means:
- Loaded objects: Optimized saves (only dirty fields)
- New objects: Full saves (all fields written)
graphObjectManager.loadAll(PersonCareer::class.java)Generates:
MATCH (person:Person:Mapped)
WITH
person {
bio: person.bio,
name: person.name,
uuid: person.uuid
} AS person,
[(person)-[employmentHistory_rel:WORKS_FOR]->(employmentHistory_target:Organization) |
{
startDate: employmentHistory_rel.startDate,
role: employmentHistory_rel.role,
target: employmentHistory_target {
name: employmentHistory_target.name,
uuid: employmentHistory_target.uuid
}
}
] AS employmentHistory
RETURN {
person: person,
employmentHistory: employmentHistory
} AS resultgraphObjectManager.loadAll<PersonCareer> {
where {
person.bio contains "Lead"
}
}Generates:
MATCH (person:Person:Mapped)
WHERE person.bio CONTAINS $p0
WITH person { ... } AS person,
[...] AS employmentHistory
RETURN { person: person, employmentHistory: employmentHistory } AS resultDrivine supports polymorphic relationship targets using Kotlin sealed classes with label-based type discrimination. This allows a single relationship to point to different node types.
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: "[email protected]"})
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
@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 |
Drivine distinguishes between required (non-nullable) and optional (nullable) relationships in @GraphView classes.
When a relationship property is nullable, Drivine returns all root nodes, even those without the relationship:
@GraphView
data class GuideUserWithOptionalWebUser(
@Root val core: GuideUser,
@GraphRelationship(type = "IS_WEB_USER", direction = Direction.OUTGOING)
val webUser: WebUser? // Nullable - relationship is optional
)
// Returns ALL GuideUsers, even those without a WebUser
val results = graphObjectManager.loadAll<GuideUserWithOptionalWebUser> { }
results.forEach { guide ->
if (guide.webUser != null) {
println("Has web user: ${guide.webUser.displayName}")
} else {
println("No web user")
}
}When a relationship property is non-nullable, Drivine automatically filters out root nodes that don't have the relationship:
@GraphView
data class GuideUserWithRequiredWebUser(
@Root val core: GuideUser,
@GraphRelationship(type = "IS_WEB_USER", direction = Direction.OUTGOING)
val webUser: WebUser // Non-nullable - relationship is required!
)
// Only returns GuideUsers that HAVE a WebUser
val results = graphObjectManager.loadAll<GuideUserWithRequiredWebUser> { }
// All results guaranteed to have webUser != nullThe generated Cypher includes a WHERE EXISTS clause:
MATCH (core:GuideUser)
WHERE EXISTS { (core)-[:IS_WEB_USER]->(:WebUser) } -- Filters out nodes without relationship
WITH core, ...
RETURN { ... }This prevents MissingKotlinParameterException that would occur if a null value was deserialized into a non-nullable property.
| Property Type | Behavior | Use Case |
|---|---|---|
val webUser: WebUser? |
Returns all root nodes | Optional relationship, handle null in code |
val webUser: WebUser |
Filters to only nodes with relationship | Required relationship, guaranteed non-null |
val webUsers: List<WebUser> |
Returns all root nodes (empty list if none) | Collection relationships are always safe |
val activeAdults = manager.query(
QuerySpecification
.withStatement<Any>("MATCH (p:Person) RETURN properties(p)")
.transform(Person::class.java)
.filter { it.age >= 18 } // Client-side filtering
.filter { it.email != null }
.map { it.firstName } // Transform to String
.limit(10)
)val fullNames: List<String> = manager.query(
QuerySpecification
.withStatement<Any>("MATCH (p:Person) RETURN properties(p)")
.transform(Person::class.java) // Map to Person
.filter { it.age > 25 } // Filter
.map { "${it.firstName} ${it.lastName}" } // Transform to String
)@Component
class UserService @Autowired constructor(
private val personRepo: PersonRepository,
private val emailService: EmailService
) {
@Transactional // Spring's @Transactional works
fun registerUser(person: Person) {
val created = personRepo.create(person)
emailService.sendWelcome(created.email)
// Auto-commits on success, rolls back on exception
}
@DrivineTransactional // Or use Drivine's annotation
fun updateUserProfile(uuid: String, updates: Partial<Person>) {
personRepo.update(uuid, updates)
}
}val updates = partial<Person> {
set(Person::email, "[email protected]")
set(Person::age, 30)
}
personRepo.update(personId, updates)Place .cypher files in src/main/resources/queries/:
// queries/findActiveUsers.cypher
MATCH (p:Person)
WHERE p.isActive = true
RETURN properties(p)Load and use:
@Configuration
class QueryConfig @Autowired constructor(
private val loader: QueryLoader
) {
@Bean
fun findActiveUsers() = CypherStatement(loader.load("findActiveUsers"))
}
@Component
class PersonRepository @Autowired constructor(
@Qualifier("neoManager") val manager: PersistenceManager,
val findActiveUsers: CypherStatement
) {
fun getActive(): List<Person> {
return manager.query(
QuerySpecification
.withStatement<Any>(findActiveUsers.statement)
.transform(Person::class.java)
)
}
}// Expect exactly one result (throws if 0 or >1)
val person: Person = manager.getOne(spec)
// Expect 0 or 1 result (returns null if not found)
val maybePerson: Person? = manager.maybeGetOne(spec)
// Return all results
val people: List<Person> = manager.query(spec)
// Execute without returning results (for mutations)
manager.execute(spec)interface PersistenceManager {
fun <T> query(spec: QuerySpecification<T>): List<T>
fun <T> getOne(spec: QuerySpecification<T>): T
fun <T> maybeGetOne(spec: QuerySpecification<T>): T?
fun <T> execute(spec: QuerySpecification<T>)
}QuerySpecification
.withStatement<T>(cypherQuery) // Start with Cypher query
.bind(params) // Bind parameters
.transform(TargetClass::class.java) // Map to target type
.filter { predicate } // Client-side filtering
.map { transformation } // Transform results
.limit(n) // Limit results
.skip(n) // Skip first n resultsdata class ConnectionProperties(
val host: String = "localhost",
val port: Int = 7687,
val username: String? = null,
val password: String? = null,
val database: String? = null,
val encrypted: Boolean = false
)Use bindObject() to serialize objects to Neo4j-compatible types using Jackson:
// Automatically converts Enums to String, UUID to String, Instant to ZonedDateTime
val task = Task(id = "1", priority = Priority.HIGH, status = Status.OPEN, dueDate = Instant.now())
manager.execute(
QuerySpecification
.withStatement("CREATE (t:Task) SET t = $props")
.bindObject("props", task)
)The Neo4j ObjectMapper automatically:
- Converts
EnumtoString - Converts
UUIDtoString - Converts
InstanttoZonedDateTime - Converts
DatetoZonedDateTime - Includes null values by default (allows explicit property removal)
- Ignores unknown properties when deserializing
To exclude nulls on specific properties, use @JsonInclude(JsonInclude.Include.NON_NULL).
@Configuration
class MultiDbConfig {
@Bean
fun dataSourceMap(): DataSourceMap {
return DataSourceMap(mapOf(
"analytics" to ConnectionProperties(
host = "analytics.neo4j.com",
database = "analytics"
),
"users" to ConnectionProperties(
host = "users.neo4j.com",
database = "users"
)
))
}
}
@Component
class AnalyticsRepository @Autowired constructor(
@Qualifier("analytics") val manager: PersistenceManager
) { /* ... */ }
@Component
class UserRepository @Autowired constructor(
@Qualifier("users") val manager: PersistenceManager
) { /* ... */ }@SpringBootTest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class PersonRepositoryTest @Autowired constructor(
private val repository: PersonRepository
) {
@Test
fun `should find person by city`() {
val results = repository.findByCity("New York")
assertThat(results).isNotEmpty
}
}Drivine provides @EnableDrivineTestConfig for seamless test setup that works in both local development and CI:
1. Define datasource in application-test.yml:
database:
datasources:
neo:
host: localhost
port: 7687
username: neo4j
password: password
type: NEO4J
database-name: neo4j2. Use @EnableDrivineTestConfig in your test configuration:
@Configuration
@EnableDrivine
@EnableDrivineTestConfig
class TestConfig3. Control behavior with environment variable:
# Use local Neo4j (for development - fast, inspectable)
export USE_LOCAL_NEO4J=true
./gradlew test
# Use Testcontainers (for CI - isolated, default)
./gradlew test # USE_LOCAL_NEO4J defaults to falseWhat happens automatically:
- Local Mode (
USE_LOCAL_NEO4J=true): Uses your application-test.yml settings as-is, connects to your local Neo4j - CI Mode (default): Starts a Neo4j Testcontainer automatically and overrides host/port/password from your properties
Benefits:
- ✅ One configuration works for both local dev and CI
- ✅ Zero boilerplate - no manual container setup
- ✅ Fast local development with real Neo4j
- ✅ Reliable CI with Testcontainers
- ✅ Easy debugging - set
@Rollback(false)and inspect your local DB
If you need more control, you can still configure Testcontainers manually:
@Configuration
@EnableDrivine
class TestConfig {
@Bean
fun dataSourceMap(): DataSourceMap {
val props = ConnectionProperties(
host = extractHost(DrivineTestContainer.getConnectionUrl()),
port = extractPort(DrivineTestContainer.getConnectionUrl()),
userName = DrivineTestContainer.getConnectionUsername(),
password = DrivineTestContainer.getConnectionPassword(),
type = DatabaseType.NEO4J,
databaseName = "neo4j"
)
return DataSourceMap(mapOf("neo" to props))
}
private fun extractHost(boltUrl: String): String =
boltUrl.substringAfter("bolt://").substringBefore(":")
private fun extractPort(boltUrl: String): Int =
boltUrl.substringAfter("bolt://").substringAfter(":").toIntOrNull() ?: 7687
}Drivine4j provides full runtime support for Java, but with some considerations for the type-safe DSL.
✅ Full Runtime Support:
- All
@NodeFragmentclasses work in both Java and Kotlin @GraphViewclasses work at runtime in both languages@RelationshipFragmentclasses work in both languagesGraphObjectManagerloading and saving works with Java classes- Polymorphic types work with Java classes (using
@JsonSubTypesor manual registration)
✅ Java Features:
- Generic collections (
List<Person>) are properly handled using Java reflection - Nested
@GraphViewrelationships work - All annotations (
@Root,@GraphRelationship,@NodeId, etc.) work on Java fields
❌ DSL Generation (KSP limitation):
- The code generator only processes Kotlin source files
- Java
@GraphViewclasses won't get generated DSL - You can still load them, just without the type-safe query builder
Best Practice: Define @GraphView classes in Kotlin, everything else can be Java.
// Java fragments work great!
@NodeFragment(labels = {"Person"})
public class Person {
@NodeId public UUID uuid;
public String name;
public String bio;
}
@NodeFragment(labels = {"Organization"})
public class Organization {
@NodeId public UUID uuid;
public String name;
}// Define GraphViews in Kotlin to get DSL generation
@GraphView
data class PersonContext(
@Root val person: Person, // Java class!
@GraphRelationship(type = "WORKS_FOR")
val worksFor: List<Organization> // Java class!
)// Use from Java with full type-safe DSL support!
public class PersonService {
@Autowired
private GraphObjectManager graphObjectManager;
public List<PersonContext> findByOrganization(String orgName) {
return graphObjectManager.loadAll(
PersonContext.class,
spec -> {
spec.where(ctx -> {
ctx.getQuery().getWorksFor().getName().eq(orgName);
});
return null;
}
);
}
}If you prefer pure Java, you can still use GraphObjectManager without the DSL:
@GraphView
public class JavaPersonContext {
@Root public Person person;
@GraphRelationship(type = "WORKS_FOR", direction = Direction.OUTGOING)
public List<Organization> worksFor;
}
// Works at runtime - no DSL, but fully functional
List<JavaPersonContext> all = graphObjectManager.loadAll(JavaPersonContext.class);
JavaPersonContext person = graphObjectManager.load(uuid, JavaPersonContext.class);| Feature | Java Support | Notes |
|---|---|---|
@NodeFragment |
✅ Full | Works identically in Java and Kotlin |
@RelationshipFragment |
✅ Full | Works identically in Java and Kotlin |
@GraphView runtime |
✅ Full | Loading, saving, polymorphism all work |
@GraphView DSL generation |
❌ Kotlin only | KSP limitation |
| Generic collections | ✅ Full | Java reflection handles List<T>, Set<T> |
| Nested relationships | ✅ Full | Works with Java classes |
| Polymorphic types | ✅ Full | Works with @JsonSubTypes |
Recommendation: Use Kotlin for @GraphView definitions to get the type-safe DSL, and use Java for everything else if preferred.
# Run tests
./gradlew test
# Build library
./gradlew build
# Publish to local Maven (~/.m2/repository)
./gradlew publishToMavenLocalApache License 2.0