Neo4j Spring Data Skill
SkillDev toolsUse when building Spring Boot applications with Neo4j using Spring Data Neo4j (SDN 7.x/8.x) —
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Neo4j Spring Data Skill skill
What this skill tells your AI
The instructions your AI receives, as published by neo4j-contrib/neo4j-skills in neo4j-spring-data-skill/SKILL.md and read by ahel’s review.
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
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-neo4j</artifactId>
</dependency>
Gradle
implementation 'org.springframework.boot:spring-boot-starter-data-neo4j'
Reactive stack (add alongside above)
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
Configuration
application.yml — imperative (standard)
spring:
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)
spring:
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
import 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.
@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;
}
@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
import 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
// 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
Page<PersonEntity> findByBirthYearGreaterThan(int year, Pageable pageable);
List<PersonEntity> findTop10ByOrderByNameAsc();
List<PersonEntity> findByName(String name, Sort sort);
Usage:
Pageable page = PageRequest.of(0, 20, Sort.by("name").ascending());
Page<PersonEntity> result = repo.findByBirthYearGreaterThan(1980, page);
Projections
Interface projection (closed — query-optimizable)
public interface PersonSummary {
String getName();
Integer getBirthYear();
}
List<PersonSummary> findByBirthYearLessThan(int year);
DTO projection (record — preferred in Java 17+)
public record PersonDto(String name, Integer birthYear) {}
List<PersonDto> findByName(String name);
Dynamic projection
<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)
public interface FullName {
@Value("#{target.name + ' (' + target.birthYear + ')'}") String getDisplayName();
}
Reactive Repository
import 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.
// 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.
// 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
@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
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-vector-store-neo4j</artifactId>
</dependency>
application.yml
spring:
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
@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:
// 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)
Signals
- GitHub stars
- 110
- Forks
- 38
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
neo4j-spring-data-skill- Source
- github.com/neo4j-contrib/neo4j-skills