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)An inherited node id. A fragment’s node id may come from a supertype: a @NodeId property it inherits, or one it overrides, as a sealed subtype overrides @get:NodeId val id on its interface. A Kotlin supertype’s private @NodeId is not inherited: a private property isn’t overridden, so a subclass’s property of the same name is its own, and the subclass has no node id unless it marks one. Drivine logs a warning for such a class. Make the supertype’s property protected or public (public, on an interface), or put @NodeId on the subclass’s own. A Java base class’s private @NodeId field, though, is the subclass’s id, as in Java interoperability.
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" }}Querying a level below. The generated DSL reaches each level, built only when it is read, so a where can test the targets of the targets: where { subLocations.subLocations.location.name eq "France" }. A where of a load reaches as deep as the DSL does, subLocations.subLocations.subLocations and below, each level checked in turn, on every store. Conditions on two relationships of one nested view hold of one node of it: where { desks.employer.name eq "Acme"; desks.mentor.name eq "Ann" } finds the offices with a desk that does both. Neo4j and Memgraph check each relationship within the check of the view; FalkorDB and Neptune match one pattern, with each relationship led from the node they share. In a scored search (loadNearest, loadMatching) on a graph engine it reaches two levels, the nested view’s own relationships, as the projection is filtered: one deeper is refused with an IllegalArgumentException.
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>)A list of fragments over several hops. maxDepth above 1 on a List<Fragment> field reads every node of the
target’s labels within that many hops (-[:NEXT*1..2]->), each once, and never over one relationship twice in a
path. It does so at every level, on a view nested in another too: there the graph engines read only one hop before
liberation-data/drivine4j#461, while the SQL stores read the whole depth, so a nested load on Neo4j, Memgraph or
FalkorDB may now give more nodes than it did.
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, Oracle and Memgraph 3.14 and later read every text before every number, lowest first; an earlier 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.

