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, @Relationship and @RelationshipProperties map Java types onto.

  • Cypher Fundamentals — the query language behind every derived finder and @Query annotation on a Neo4jRepository.

  • 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 Neo4jTransactionManager and Neo4jClient are 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.