Spring Data Neo4j
|
This section documents Spring Boot 4.1.x and Spring Framework 7.0.x on the Java 17/21+ baseline, as described by the official Spring Boot reference documentation and each sub-project’s own site (Spring Data, Spring for Apache Kafka, Micrometer, Project Reactor, springdoc-openapi, Spring gRPC, and the others listed in this section’s Bibliography) — which are the references these pages are written and verified against. This content was generated with the assistance of AI and should be verified against those official docs before being relied on in production. Spring Boot and its ecosystem continue to evolve; the examples here target the current 4.1.x / 7.0.x releases. This section’s bibliography lists the reference material consulted while preparing these pages. |
Spring Data Neo4j (SDN) maps Java objects onto nodes and relationships in a Neo4j graph database, and layers Spring Data’s familiar repository abstraction — derived queries, custom Cypher, projections, auditing — on top of the graph.
Mapping nodes with @Node
A domain class becomes a graph node with @Node, an identifier with @Id, and (for internally generated,
non-business identifiers) @GeneratedValue:
@Node("Movie")
public class Movie {
@Id
@GeneratedValue(GeneratedValue.InternalIdGenerator.class)
private Long id;
private String title;
private int releaseYear;
// getters and setters omitted
}
@Node optionally takes a primary label ("Movie" here); without it, the simple class name is used. A class
can also carry additional labels via @Node(labels = \{"Movie", "Media"\}). Business identifiers (a UUID or a
natural key assigned by the application) simply skip @GeneratedValue and keep @Id on its own. See
Spring Data Neo4j — Object Mapping for
the full set of mapping annotations.
Modeling relationships with @Relationship
Relationships are modeled as fields annotated @Relationship, either as a direct reference or, when the
relationship itself carries properties, through an intermediate relationship entity:
@Node("Movie")
public class Movie {
@Id
@GeneratedValue(GeneratedValue.InternalIdGenerator.class)
private Long id;
private String title;
@Relationship(type = "ACTED_IN", direction = Relationship.Direction.INCOMING)
private List<Roles> actors = new ArrayList<>();
}
@RelationshipProperties
public class Roles {
@RelationshipId
private Long id;
@TargetNode
private Person actor;
private List<String> roleNames;
}
@Node("Person")
public class Person {
@Id
@GeneratedValue(GeneratedValue.InternalIdGenerator.class)
private Long id;
private String name;
}
@Relationship(type = …, direction = …) sets the relationship type and its direction relative to the
annotated entity (OUTGOING, INCOMING, or UNDIRECTED); @RelationshipProperties marks a class that maps to
the relationship itself rather than to a node, @RelationshipId identifies that relationship instance, and
@TargetNode marks the field pointing at the node on the other end. See
Spring Data Neo4j — Mapping Annotations.
Node inheritance with multiple labels
A graph node can carry more than one label at once, which makes inheritance the most natural of the four stores
covered in this reference: a subclass node is simply labeled with both its own type and every ancestor type.
Unlike Java inheritance itself, @Node is not automatically inherited from a superclass — every level of
the hierarchy that should participate in polymorphic queries needs its own @Node annotation, and this is only
supported starting from an abstract base class:
@Node("Pet") // abstract base -- every concrete subclass node also gets the "Pet" label
public abstract class Pet {
@Id
@GeneratedValue(GeneratedValue.InternalIdGenerator.class)
private Long id;
private String name;
}
@Node("Dog") // a node created from this class is labeled :Pet:Dog
public class Dog extends Pet {
private String breed;
}
@Node("Cat") // a node created from this class is labeled :Pet:Cat
public class Cat extends Pet {
private boolean indoor;
}
A repository declared against the abstract base type runs a query matching any node carrying the Pet label
and returns each one hydrated as its actual concrete class:
public interface PetRepository extends Neo4jRepository<Pet, Long> {
List<Pet> findByName(String name);
}
// each element is really a Dog or Cat instance, resolved from
// which additional label the underlying node actually carries
List<Pet> pets = petRepository.findByName("Rex");
See Spring Data
Neo4j — Metadata-based Mapping for the full label-inheritance model, including how it interacts with
@Relationship fields declared on the abstract base.
Neo4jRepository
Neo4jRepository<T, ID> extends PagingAndSortingRepository and CrudRepository with graph-specific defaults
(such as controlling how many relationship levels deep a save or find operation traverses):
public interface MovieRepository extends Neo4jRepository<Movie, Long> {
}
@Service
public class MovieService {
private final MovieRepository movies;
public MovieService(MovieRepository movies) {
this.movies = movies;
}
public Movie create(String title, int releaseYear) {
Movie movie = new Movie();
movie.setTitle(title);
movie.setReleaseYear(releaseYear);
return movies.save(movie); // inherited CrudRepository.save
}
public Optional<Movie> findById(Long id) {
return movies.findById(id);
}
public void delete(Long id) {
movies.deleteById(id);
}
}
Derived query methods
Method names are parsed into Cypher the same way Spring Data JPA parses them into JPQL/SQL:
public interface MovieRepository extends Neo4jRepository<Movie, Long> {
List<Movie> findByTitleContainingIgnoreCase(String fragment);
List<Movie> findByReleaseYearGreaterThanEqual(int year);
Optional<Movie> findByTitleAndReleaseYear(String title, int year);
long countByReleaseYear(int year);
List<Movie> findByActors_Actor_Name(String actorName);
}
findByActors_Actor_Name traverses the actors relationship collection into the nested Roles.actor node and
matches on that node’s name property — the underscore forces the property-path split when the derivation would
otherwise be ambiguous. See
Spring Data Neo4j — Query
Methods.
Custom Cypher with @Query
When a derived name would be unreadable, or the query needs graph-native constructs (variable-length paths,
OPTIONAL MATCH, aggregation), annotate the method with @Query and write Cypher directly:
public interface MovieRepository extends Neo4jRepository<Movie, Long> {
@Query("""
MATCH (m:Movie)<-[r:ACTED_IN]-(p:Person)
WHERE m.title = $title
RETURN m, collect(r), collect(p)
""")
Optional<Movie> findWithCastByTitle(@Param("title") String title);
@Query(value = "MATCH (m:Movie) WHERE m.releaseYear >= $fromYear RETURN m ORDER BY m.releaseYear DESC",
countQuery = "MATCH (m:Movie) WHERE m.releaseYear >= $fromYear RETURN count(m)")
Page<Movie> findRecentMovies(@Param("fromYear") int fromYear, Pageable pageable);
}
@Param binds a named Cypher parameter ($title) to a method argument; returning the relationship and the
related node collections alongside the root node (collect(r), collect(p)) lets SDN hydrate the mapped
actors association from a single round trip instead of issuing a follow-up query per movie. A separate
countQuery lets a @Query method return a Page<T> like a derived finder would. See
Spring Data Neo4j — Custom
Queries for the full syntax, including paging and sorting inside @Query methods.
Geospatial queries
Neo4j has a native Point spatial type (Cartesian or WGS-84/geographic), Cypher point()/distance()
functions, and point indexes built in. Spring Data Neo4j maps this through its own Point type, so spatial
querying is available both as a derived-keyword finder and as custom Cypher.
Mapping a Point field and querying with a derived Near method
A @Node entity can carry a Point field directly, and Neo4jRepository supports the derived Near keyword
against it:
@Node("Place")
public class Place {
@Id
@GeneratedValue(GeneratedValue.InternalIdGenerator.class)
private Long id;
private String name;
private Point location; // org.springframework.data.neo4j.types.Point (WGS-84 by default)
}
public interface PlaceRepository extends Neo4jRepository<Place, Long> {
List<Place> findByLocationNear(Point point, Distance distance);
}
List<Place> nearby = placeRepository.findByLocationNear(
new Point(-3.7038, 40.4168), new Distance(5, Metrics.KILOMETERS));
Custom Cypher with point() and distance()
For cases the derived Near keyword can’t express — for example returning a computed distance value alongside
each match — write the equivalent query with Cypher’s point()/distance() functions directly:
public interface PlaceRepository extends Neo4jRepository<Place, Long> {
@Query("""
MATCH (p:Place)
WHERE point.distance(p.location, point({longitude: $lon, latitude: $lat})) <= $radiusMeters
RETURN p, point.distance(p.location, point({longitude: $lon, latitude: $lat})) AS distance
ORDER BY distance
""")
List<Place> findWithinRadius(@Param("lon") double lon, @Param("lat") double lat,
@Param("radiusMeters") double radiusMeters);
}
Projections
Projections return a narrower view of a node without loading (or mapping) the whole entity graph. An interface-based (closed) projection declares only the accessors it needs:
public interface MovieSummary {
String getTitle();
int getReleaseYear();
}
public interface MovieRepository extends Neo4jRepository<Movie, Long> {
List<MovieSummary> findByReleaseYearGreaterThanEqual(int year);
<T> List<T> findByTitleContainingIgnoreCase(String fragment, Class<T> projectionType);
}
A DTO-based projection is a plain class (or record) instead of an interface, useful when the view needs
computed fields or custom construction logic — record MovieDto(String title, int releaseYear) works directly
as the second example’s T type parameter. See
Spring Data Neo4j — Projections.
Auditing
Enabling Neo4j auditing populates creation/modification metadata automatically, mirroring Spring Data JPA’s auditing support:
@Configuration
@EnableNeo4jAuditing
public class Neo4jAuditingConfig {
}
@Node("Movie")
public class Movie {
@Id
@GeneratedValue(GeneratedValue.InternalIdGenerator.class)
private Long id;
private String title;
@CreatedDate
private Instant createdAt;
@LastModifiedDate
private Instant updatedAt;
@CreatedBy
private String createdBy;
@LastModifiedBy
private String updatedBy;
}
@CreatedBy / @LastModifiedBy require an AuditorAware<String> bean supplying the current principal’s
identifier; without one registered, only the timestamp fields (@CreatedDate, @LastModifiedDate) are
populated. See
Spring Data Neo4j — Auditing.
Transactions
Spring Data Neo4j participates in Spring’s declarative transaction management through @Transactional, backed by
a Neo4jTransactionManager. Spring Boot’s auto-configuration registers one as soon as
spring-boot-starter-data-neo4j is on the classpath; declaring it explicitly is only needed when customizing it
(for example, to target a specific database in a multi-database instance):
@Configuration
public class Neo4jTransactionConfig {
@Bean
Neo4jTransactionManager transactionManager(Driver driver, DatabaseSelectionProvider databaseSelection) {
return new Neo4jTransactionManager(driver, databaseSelection);
}
}
@Service
public class CastingService {
private final MovieRepository movies;
private final PersonRepository people;
public CastingService(MovieRepository movies, PersonRepository people) {
this.movies = movies;
this.people = people;
}
@Transactional
public void castActor(String movieTitle, String actorName, List<String> roleNames) {
Movie movie = movies.findByTitle(movieTitle).orElseThrow();
Person actor = people.findByName(actorName).orElseThrow();
Roles roles = new Roles(); // the @RelationshipProperties type declared on Movie.actors
roles.setActor(actor);
roles.setRoleNames(roleNames);
movie.getActors().add(roles);
movies.save(movie); // the Movie node and the new ACTED_IN relationship commit together
}
}
Repository methods are transactional on their own, so a boundary is only needed when several operations must succeed or fail together, exactly as in the other Spring Data modules.
Isolation level
A custom @Transactional isolation level is not supported: Neo4jTransactionManager throws
InvalidIsolationLevelException for any non-default Isolation value (it also restricts propagation to
REQUIRED/REQUIRES_NEW), because Neo4j does not expose the ANSI levels. Neo4j transactions run at
read-committed isolation and take write locks on the nodes and relationships they modify, holding them until
commit — so a concurrent writer to the same node waits rather than conflicting, while readers are never blocked.
See Transaction Isolation & Locking for how this
compares with the relational stores and with the @Version optimistic locking described next.
Optimistic locking with @Version
A Long field annotated @Version protects a node against lost updates the same way it does in every other
Spring Data module (see Spring Data Overview for the
cross-store mechanism):
@Node("Movie")
public class Movie {
@Id
@GeneratedValue(GeneratedValue.InternalIdGenerator.class)
private Long id;
private String title;
@Version
private Long version; // starts at 0; bumped automatically on every update -- never set manually
}
On save, SDN includes the version it read as a match condition in the generated Cypher’s WHERE/SET
clauses and increments it. If a concurrent transaction already saved the same node in between, that condition
matches nothing, and the save fails with OptimisticLockingFailureException — the caller should catch it and
retry with a fresh read, exactly as with the JPA and MongoDB pages' examples. @Version is not just
recommended but mandatory when the entity uses a business (application-assigned) identifier instead of a
generated one — without a generated ID, Spring Data Neo4j relies on the version field being null to tell a
new entity apart from one being updated.
Custom queries with Neo4jClient
Neo4jClient is the lower-level, repository-independent API for running arbitrary Cypher — ad hoc reports,
bulk operations, or queries whose result shape does not correspond to any mapped @Node entity. It is
autoconfigured as a bean and can be injected directly:
@Repository
public class MovieStatsRepository {
private final Neo4jClient neo4jClient;
public MovieStatsRepository(Neo4jClient neo4jClient) {
this.neo4jClient = neo4jClient;
}
public List<YearlyMovieCount> countMoviesByYear() {
return neo4jClient
.query("MATCH (m:Movie) RETURN m.releaseYear AS year, count(m) AS total ORDER BY year DESC")
.fetchAs(YearlyMovieCount.class)
.mappedBy((typeSystem, record) -> new YearlyMovieCount(
record.get("year").asInt(),
record.get("total").asLong()))
.all()
.stream()
.toList();
}
public Optional<YearlyMovieCount> countMoviesForYear(int year) {
return neo4jClient
.query("MATCH (m:Movie) WHERE m.releaseYear = $year RETURN m.releaseYear AS year, count(m) AS total")
.bind(year).to("year")
.fetchAs(YearlyMovieCount.class)
.mappedBy((typeSystem, record) -> new YearlyMovieCount(record.get("year").asInt(), record.get("total").asLong()))
.one();
}
public void renameGenre(String oldName, String newName) {
neo4jClient
.query("MATCH (g:Genre \\{name: $oldName}) SET g.name = $newName")
.bind(oldName).to("oldName")
.bind(newName).to("newName")
.run();
}
}
public record YearlyMovieCount(int year, long total) {
}
.query(cypher) accepts any Cypher string, including multi-statement scripts; .bind(value).to("name") binds a
single named parameter (chain several .bind(…).to(…) calls, or pass a Map<String, Object> to .bindAll,
for multiple parameters); .fetchAs(SomeDto.class).mappedBy(…) converts each result record into a plain,
non-entity DTO through a BiFunction<TypeSystem, Record, T> — there is no requirement that the target type be
annotated @Node; .all() returns every matching record as a collection while .one() returns a single
Optional<T>; and .run() executes a statement (an update, DELETE, or MERGE) without expecting rows back.
This is the tool of choice whenever a report or migration needs full control over the Cypher without the
overhead of designing an entity graph for a result set that is only ever read once. See
Spring Data Neo4j — reference guide for the Neo4jClient
API, and Spring Data Neo4j — Custom Queries for how it complements repository-level @Query methods.
Prefer Neo4jRepository / @Query whenever the result maps onto a @Node-annotated entity graph and CRUD,
derived finders, paging, or auditing already cover the need; reach for Neo4jClient for flat projections,
aggregate reports, or dynamically built/multi-statement Cypher with no corresponding entity. Both sit on the same
Neo4jClient/Neo4jTemplate infrastructure and can be mixed freely — even within the same repository
implementation, by injecting Neo4jClient alongside a generated Neo4jRepository.
See also
This page covers Spring Data Neo4j’s own @Node/@Relationship/Neo4jClient mapping layer. For the underlying
Neo4j concepts it builds on:
-
The Property Graph Model — the nodes, relationships, labels and properties that
@Node,@Relationshipand@RelationshipPropertiesmap Java types onto. -
Cypher Fundamentals — the query language behind every derived finder and
@Queryannotation on aNeo4jRepository. -
Indexes & Constraints — how to back
@Id/unique fields and frequently filtered properties with the range, uniqueness and node-key constraints Spring Data itself does not create for you. -
Transactions & Drivers — the Bolt driver transaction functions that
Neo4jTransactionManagerandNeo4jClientare built on underneath@Transactional. -
Graph Data Science Fundamentals
Pathfinding & Centrality Algorithms — running GDS algorithms over data mapped with this page’s entities, beyond what repository queries can express. -
Vector Search & GenAI — vector indexes and GraphRAG patterns for building retrieval-augmented search on top of a Spring Data Neo4j domain model.