Neo4j Spring Data Skill
When to Use
- Configuring Spring Boot with Neo4j (
spring-boot-starter-data-neo4j) - Writing
@Nodeentity classes and@Relationship/@RelationshipPropertiesmappings - Defining
Neo4jRepositoryorReactiveNeo4jRepositoryinterfaces - Writing
@Queryannotations with Cypher on repository methods - Using Spring projections (interface-based, DTO, dynamic) with Neo4j
- Configuring
application.ymlfor Neo4j connection - Custom queries via
Neo4jClientorNeo4jTemplate - Spring AI
Neo4jVectorStorefor vector search in Spring apps - Transaction management, auditing, optimistic locking
When NOT to Use
- Raw Java driver without Spring →
neo4j-driver-java-skill - Cypher query authoring →
neo4j-cypher-skill - Driver version upgrades →
neo4j-migration-skill - GDS algorithms →
neo4j-gds-skill
Version Matrix
| SDN | Spring Boot | Spring Framework | Java | Neo4j |
|---|---|---|---|---|
| 8.0.x | 3.3.x / 3.4.x | 6.2.x | 17+ | 5.15+ |
| 8.1.x | 3.4.x+ | 7.0.x | 17+ | 5.15+ |
| 7.5.x | 3.2.x | 6.1.x | 17+ | 4.4+ |
Use spring-boot-starter-data-neo4j — it pulls SDN + driver. No explicit SDN version needed when using Spring Boot BOM.
Setup
Maven
xml<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-neo4j</artifactId> </dependency>
Gradle
gradleimplementation 'org.springframework.boot:spring-boot-starter-data-neo4j'
Reactive stack (add alongside above)
xml<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webflux</artifactId> </dependency>
Configuration
application.yml — imperative (standard)
yamlspring: neo4j: uri: ${NEO4J_URI:bolt://localhost:7687} authentication: username: ${NEO4J_USERNAME:neo4j} password: ${NEO4J_PASSWORD} data: neo4j: database: ${NEO4J_DATABASE:neo4j}
application.yml — Aura (TLS required)
yamlspring: neo4j: uri: ${NEO4J_URI} # neo4j+s://xxxx.databases.neo4j.io authentication: username: ${NEO4J_USERNAME:neo4j} password: ${NEO4J_PASSWORD} data: neo4j: database: ${NEO4J_DATABASE:neo4j}
Credentials: store in .env; never hardcode. Verify .env is in .gitignore.
Entity Mapping
javaimport org.springframework.data.neo4j.core.schema.*; // Internal generated ID (default for most cases) @Node("Person") public class PersonEntity { @Id @GeneratedValue private Long id; // element ID (Long) private String name; @Property("birth_year") private Integer birthYear; // custom property name @Relationship(type = "KNOWS", direction = Relationship.Direction.OUTGOING) private List<PersonEntity> friends = new ArrayList<>(); } // UUID business key @Node("Product") public class ProductEntity { @Id @GeneratedValue(generatorClass = GeneratedValue.UUIDStringGenerator.class) private String id; @Version private Long version; // optimistic locking; required with business key } // User-assigned key (caller sets value; no @GeneratedValue) @Node("Country") public class CountryEntity { @Id private String isoCode; private String name; } // Multiple static labels @Node(primaryLabel = "Vehicle", labels = {"Car", "Auditable"}) public class CarEntity { ... } // Runtime labels @Node("Content") public class ContentEntity { @Id @GeneratedValue private Long id; @DynamicLabels private Set<String> tags = new HashSet<>(); // labels added at runtime }
Relationship Properties
Use @RelationshipProperties when the relationship itself carries data.
java@RelationshipProperties public class RolesRelationship { @RelationshipId // internal relationship ID; required private Long id; private List<String> roles; @TargetNode // marks the other end of the relationship private PersonEntity person; }
java@Node("Movie") public class MovieEntity { @Id @GeneratedValue private Long id; private String title; @Relationship(type = "ACTED_IN", direction = Relationship.Direction.INCOMING) private List<RolesRelationship> actorsAndRoles = new ArrayList<>(); }
Repository Interfaces
Basic CRUD
javaimport org.springframework.data.neo4j.repository.Neo4jRepository; public interface PersonRepository extends Neo4jRepository<PersonEntity, Long> { Optional<PersonEntity> findByName(String name); List<PersonEntity> findByBirthYearBetween(int from, int to); List<PersonEntity> findByNameContainingIgnoreCase(String fragment); long countByBirthYearGreaterThan(int year); void deleteByName(String name); }
@Query — custom Cypher
java// CORRECT: $param bound parameter @Query("MATCH (p:Person {name: $name})-[:KNOWS]->(f:Person) RETURN f") List<PersonEntity> findFriendsOf(String name); // With pagination @Query(value = "MATCH (p:Person) RETURN p ORDER BY p.name", countQuery = "MATCH (p:Person) RETURN count(p)") Page<PersonEntity> findAllPaged(Pageable pageable); // Return relationship-rich entity; map target via @Node return @Query("MATCH (m:Movie)<-[r:ACTED_IN]-(p:Person {name: $name}) RETURN m, collect(r), collect(p)") List<MovieEntity> findMoviesActedInBy(String name);
Security rule: NEVER string-concatenate user input into Cypher. Always use $paramName.
Pagination and sorting
javaPage<PersonEntity> findByBirthYearGreaterThan(int year, Pageable pageable); List<PersonEntity> findTop10ByOrderByNameAsc(); List<PersonEntity> findByName(String name, Sort sort);
Usage:
javaPageable page = PageRequest.of(0, 20, Sort.by("name").ascending()); Page<PersonEntity> result = repo.findByBirthYearGreaterThan(1980, page);
Projections
Interface projection (closed — query-optimizable)
javapublic interface PersonSummary { String getName(); Integer getBirthYear(); } List<PersonSummary> findByBirthYearLessThan(int year);
DTO projection (record — preferred in Java 17+)
javapublic record PersonDto(String name, Integer birthYear) {} List<PersonDto> findByName(String name);
Dynamic projection
java<T> List<T> findByName(String name, Class<T> type); // Usage repo.findByName("Alice", PersonSummary.class); repo.findByName("Alice", PersonEntity.class);
Open projection — SpEL (disables query optimization)
javapublic interface FullName { @Value("#{target.name + ' (' + target.birthYear + ')'}") String getDisplayName(); }
Reactive Repository
javaimport org.springframework.data.neo4j.repository.ReactiveNeo4jRepository; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; public interface ReactivePersonRepository extends ReactiveNeo4jRepository<PersonEntity, Long> { Mono<PersonEntity> findByName(String name); @Query("MATCH (p:Person {name: $name})-[:KNOWS]->(f) RETURN f") Flux<PersonEntity> findFriendsOf(String name); }
Do NOT mix imperative and reactive database access in the same application context.
Custom Repository Implementation
Fragment pattern — use when @Query is not enough.
java// 1. Fragment interface public interface PersonRepositoryCustom { List<PersonEntity> findByComplexCriteria(String criteria); } // 2. Impl — must end with "Impl" public class PersonRepositoryCustomImpl implements PersonRepositoryCustom { private final Neo4jClient neo4jClient; PersonRepositoryCustomImpl(Neo4jClient c) { this.neo4jClient = c; } @Override public List<PersonEntity> findByComplexCriteria(String c) { return new ArrayList<>(neo4jClient .query("MATCH (p:Person) WHERE p.name CONTAINS $c RETURN p").bind(c).to("c") .fetchAs(PersonEntity.class) .mappedBy((t, r) -> { var e = new PersonEntity(); e.setName(r.get("p").asNode().get("name").asString()); return e; }) .all()); } } // 3. Compose public interface PersonRepository extends Neo4jRepository<PersonEntity, Long>, PersonRepositoryCustom {}
Neo4jClient — Low-Level Queries
Use when @Query is insufficient or you need full control over Cypher execution.
java// Bind params + fetch single scalar neo4jClient.query("MATCH (p:Person {name: $name}) RETURN count(*) AS cnt") .bind("Alice").to("name") .fetchAs(Long.class) .mappedBy((t, r) -> r.get("cnt").asLong()) .one(); // Bind + run write (no result) neo4jClient.query("MERGE (p:Person {name: $name})") .bind(personName).to("name") .run(); // Custom object mapping neo4jClient.query("MATCH (p:Person)-[:DIRECTED]->(m:Movie) WHERE p.name=$n RETURN p, collect(m) AS movies") .bind("Lilly Wachowski").to("n") .fetchAs(Director.class) .mappedBy((typeSystem, record) -> new Director( record.get("p").asNode().get("name").asString(), record.get("movies").asList(v -> new Movie(v.get("title").asString())) )).one();
Full API: references/neo4j-client.md
Transaction Management
java@Service @Transactional // class-level: all methods transactional public class PersonService { @Transactional(readOnly = true) // read-only hint public Optional<PersonEntity> findByName(String name) { ... } @Transactional // explicit write public PersonEntity save(PersonEntity p) { return repository.save(p); } }
Neo4jTransactionManager auto-configured. Do NOT mix with JPA PlatformTransactionManager without explicit qualifier. Use @Transactional on concrete class, not interface.
Spring AI — Neo4jVectorStore
Dependency
xml<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-vector-store-neo4j</artifactId> </dependency>
application.yml
yamlspring: ai: vectorstore: neo4j: initialize-schema: true # creates vector index on first run index-name: my-index embedding-dimension: 1536 # must match your embedding model distance-type: cosine # cosine (default) or euclidean label: Document # node label for stored chunks embedding-property: embedding # property for the vector
Requires Neo4j 5.15+. Reuses spring.neo4j.* connection config.
Usage
java@Autowired VectorStore vectorStore; // Store vectorStore.add(List.of(new Document("text", Map.of("author", "alice")))); // Similarity search List<Document> results = vectorStore.similaritySearch( SearchRequest.builder().query("spring neo4j").topK(5).similarityThreshold(0.75).build() ); // With metadata filter vectorStore.similaritySearch( SearchRequest.builder().query("spring neo4j").topK(5) .filterExpression("author == 'alice'").build() );
Common Errors
| Error | Cause | Fix |
|---|---|---|
MappingException: Could not find entity | Entity not scanned | Check @EnableNeo4jRepositories base package |
| Relationships null after load | Default depth may skip deep rels | Use @Query with RETURN m, collect(r), collect(p) |
| N+1 queries | Per-entity relationship fetch | Rewrite with single @Query; use projections |
OptimisticLockingFailureException | Stale @Version on concurrent write | Retry in service layer |
IllegalStateException: Cannot mix reactive/imperative | Both repo types in same context | Pick one stack |
| Projection null fields | Getter name mismatch | Match getter to property name; check @Property alias |
@Query empty with rels | Missing collect(r), collect(p) | Return root node + rels + related nodes together |
Cannot delete node, node has relationships | deleteById without detach | Use @Query with DETACH DELETE |
| Transaction not rolling back | @Transactional on interface | Apply on concrete service class |
Relationship Loading — Key Rule
SDN loads related entities eagerly up to a configured depth (default: 1 hop). For deeper graphs:
java// Explicit @Query to control what gets loaded @Query(""" MATCH (m:Movie)<-[r:ACTED_IN]-(p:Person) WHERE m.title = $title RETURN m, collect(r), collect(p) """) Optional<MovieEntity> findByTitleWithCast(String title);
collect(r), collect(p) in RETURN is required for SDN to map @RelationshipProperties correctly.
References
- Spring Data Neo4j Reference (8.x)
- Spring AI Neo4jVectorStore
- GraphAcademy: Building Neo4j Apps with Spring Data
- Neo4j Getting Started — SDN
- SDN Advanced projections
- SDN Auditing
- Modeling pitfalls, projection guide, type mapping
Checklist
-
@Nodeuses explicit label string, not default class name -
@Id @GeneratedValue(or@Id+@Versionfor business key with optimistic lock) -
@RelationshipPropertiesclass has@RelationshipIdand@TargetNode -
@Relationshipdirection is explicit (OUTGOING / INCOMING) -
@QueryCypher uses$paramName— no string concatenation - Relationship-rich
@Queryreturnscollect(r), collect(p)alongside root node - Database name set in
application.yml(avoids default DB ambiguity) - Unique constraint exists in DB for any business key used in repository lookups
-
@Transactionalon concrete service class (not interface) - No imperative + reactive mix in same application context
- Credentials in env vars;
.envin.gitignore -
spring.ai.vectorstore.neo4j.initialize-schema: truefor first run (Spring AI)

