Key Concepts
1. NodeFragment - Mapping Nodes
Section titled “1. NodeFragment - Mapping Nodes”A @NodeFragment represents a single node in the graph:
@NodeFragmentdata class Person( @NodeId val uuid: String, val name: String, val bio: String?)
@NodeFragmentdata class Organization( @NodeId val uuid: String, val name: String)Defaults for missing properties. When a node is missing a property, the value loads as null — which fails for a non-nullable type. Two annotations handle that without any Jackson knowledge:
@NodeFragment(labels = ["User"])data class UserNode( @NodeId val id: String, @Default val roles: List<String> = emptyList(), // missing/null → the declared default [] @Default val status: String = "active", // missing/null → "active" @EmptyWhenAbsent val tags: List<String>, // missing/null → [] (no default needed))@Defaultfalls back to the property’s declared default (a Kotlin constructor default, or a Java field initializer). Works for any type; a provided value always wins.@EmptyWhenAbsentmaps an absent/null collection or map to empty, with no declared default required — the right choice for Java records, whose components have no field initializer:public record UserNode(@NodeId String id, @EmptyWhenAbsent List<String> roles) {}
2. RelationshipFragment - Capturing Relationship Properties
Section titled “2. RelationshipFragment - Capturing Relationship Properties”A @RelationshipFragment captures properties on relationship edges, not just the target node:
@RelationshipFragmentdata 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
3. GraphView - Composing Views
Section titled “3. GraphView - Composing Views”A @GraphView composes multiple fragments and relationships into a single query result:
@GraphViewdata 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.
4. Recursive Relationships - Hierarchies & Ontologies
Section titled “4. Recursive Relationships - Hierarchies & Ontologies”Graph databases excel at recursive structures — ontologies, org charts, location hierarchies. Drivine supports self-referential @GraphView classes where a relationship targets its own type, expanding to a configurable depth using nested pattern comprehensions.
Define a recursive view:
@NodeFragment(labels = ["Location"])data class Location( @NodeId val uuid: UUID, val name: String, val type: String)
@GraphViewdata class LocationHierarchy( val location: Location, @GraphRelationship(type = "HAS_LOCATION", direction = Direction.OUTGOING, maxDepth = 3) val subLocations: List<LocationHierarchy> // Self-referential!)maxDepth = 3 means Drivine expands 3 levels deep. Loading a continent produces:
Europe (continent)├── Western Europe (region)│ ├── France (country) → subLocations: []│ ├── Germany (country) → subLocations: []│ └── ...├── Northern Europe (region)│ ├── Sweden (country) → subLocations: []│ └── ...└── ...At the terminal depth, collections become [] and nullable singles become null.
Generated Cypher (abbreviated):
MATCH (location:Location)WITH location { name: location.name, type: location.type, uuid: location.uuid } AS location, [(location)-[:HAS_LOCATION]->(sub_d1:Location) | sub_d1 { location: { name: sub_d1.name, type: sub_d1.type, uuid: sub_d1.uuid }, subLocations: [(sub_d1)-[:HAS_LOCATION]->(sub_d2:Location) | sub_d2 { location: { name: sub_d2.name, ... }, subLocations: [(sub_d2)-[:HAS_LOCATION]->(sub_d3:Location) | sub_d3 { location: { ... }, subLocations: [] } ] } ] } ] AS subLocationsRETURN { location: location, subLocations: subLocations } AS resultTraversing upward — create a different view with Direction.INCOMING:
@GraphViewdata class LocationAncestry( val location: Location, @GraphRelationship(type = "HAS_LOCATION", direction = Direction.INCOMING, maxDepth = 3) val parent: LocationAncestry? // Nullable single — each location has at most one parent)Loading “France” returns France → Western Europe → Europe → (terminated).
Query-time depth override:
graphObjectManager.loadAll<LocationHierarchy> { depth("subLocations", 5) // Override annotation's maxDepth=3 to 5 where { query.location.type eq "continent" }}Chain cycles (A → B → A) are also supported. When a relationship targets a @GraphView that forms a cycle through other types, Drivine tracks visit counts and terminates at maxDepth:
@GraphViewdata class PersonOrgView( val person: Person, @GraphRelationship(type = "WORKS_FOR", direction = Direction.OUTGOING) val employer: OrgPersonView?)
@GraphViewdata class OrgPersonView( val org: Organization, @GraphRelationship(type = "EMPLOYS", direction = Direction.OUTGOING, maxDepth = 2) val employees: List<PersonOrgView>)Saving. A view nested in itself, as above, is saved: each level is one relationship from the level above. A list of fragments read over several hops (maxDepth above 1 on a List<Fragment> field) is loaded and never written, like a path field: see @ReadOnly. A recursive collection cannot be sorted in the query; use @SortedBy, which orders every level.
5. Path Traversal - Skipping Intermediary Nodes
Section titled “5. Path Traversal - Skipping Intermediary Nodes”@GraphRelationship is a single hop. @GraphPath traverses several and maps only the final node, skipping the ones in between:
@GraphViewdata class ActorDirectors( @Root val actor: Actor, @ReadOnly @GraphPath([ Hop("ACTED_IN", Direction.OUTGOING, label = "Movie"), // through Movie — not mapped Hop("DIRECTED_BY", Direction.OUTGOING), // to Director ]) val directors: List<Director>,)The far node is de-duplicated (an actor who made two movies by the same director gets that director once). Field cardinality mirrors @GraphRelationship: List<T> is a collection, T? a single optional, T a required single (roots lacking the path are filtered out). Each Hop’s label optionally constrains the node it reaches; maxDepth does not apply (a path is a fixed hop list, not variable-length recursion).
One hop may name an order: a property of the node it reaches, read lowest first unless ascending = false. A list then holds its targets in the order of the first node of that hop that leads to each, and a single field holds the target the first such node leads to:
@GraphViewdata class SessionStarter( @Root val session: Session, @ReadOnly @GraphPath([ Hop("HAS_MESSAGE", Direction.OUTGOING, label = "Message", orderBy = "sentAt"), // earliest message first Hop("AUTHORED_BY", Direction.OUTGOING), ]) val startedBy: Person?, // who wrote the first message)A node of the ordered hop that has no value for the property comes last, whichever way the path is read; an empty text is a value, and the lowest text. Targets whose first values are equal come in no set order. The property should hold values of one type: every backend reads the order the same way where it does, and a text of digits such as '0100' is ordered as text. Where it holds texts on some nodes and numbers on others, Neo4j, FalkorDB and Oracle read every text before every number, lowest first, and Memgraph refuses to compare them.
6. Aggregates - Counting & Summarizing Without Loading
Section titled “6. Aggregates - Counting & Summarizing Without Loading”@Count and @Aggregate add per-root scalar fields computed in the query, so you don’t load a collection just to size or summarize it:
@GraphViewdata class ActorStats( @Root val actor: Actor, @ReadOnly @Count("ACTED_IN") val movieCount: Long, @ReadOnly @Aggregate(AggregateFunction.AVG, type = "RATED", property = "score") val avgRating: Double, @ReadOnly @Aggregate(AggregateFunction.SUM, type = "RATED", property = "score") val totalRating: Double,)@Count needs no property; SUM/AVG/MIN/MAX aggregate a numeric property of the related nodes. Aggregates are single-hop, and belong to one root. To group nodes by a key and count or rank the groups (a top-N), use a grouped read.
All three — path traversal and aggregates — work identically across Neo4j, Memgraph, and FalkorDB.

