Neo4j Spring Data Skill

SkillDev tools

Use 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.

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 @Node entity classes and @Relationship/@RelationshipProperties mappings
  • Defining Neo4jRepository or ReactiveNeo4jRepository interfaces
  • Writing @Query annotations with Cypher on repository methods
  • Using Spring projections (interface-based, DTO, dynamic) with Neo4j
  • Configuring application.yml for Neo4j connection
  • Custom queries via Neo4jClient or Neo4jTemplate
  • Spring AI Neo4jVectorStore for vector search in Spring apps
  • Transaction management, auditing, optimistic locking

When NOT to Use

  • Raw Java driver without Springneo4j-driver-java-skill
  • Cypher query authoringneo4j-cypher-skill
  • Driver version upgradesneo4j-migration-skill
  • GDS algorithmsneo4j-gds-skill

Version Matrix

SDNSpring BootSpring FrameworkJavaNeo4j
8.0.x3.3.x / 3.4.x6.2.x17+5.15+
8.1.x3.4.x+7.0.x17+5.15+
7.5.x3.2.x6.1.x17+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

ErrorCauseFix
MappingException: Could not find entityEntity not scannedCheck @EnableNeo4jRepositories base package
Relationships null after loadDefault depth may skip deep relsUse @Query with RETURN m, collect(r), collect(p)
N+1 queriesPer-entity relationship fetchRewrite with single @Query; use projections
OptimisticLockingFailureExceptionStale @Version on concurrent writeRetry in service layer
IllegalStateException: Cannot mix reactive/imperativeBoth repo types in same contextPick one stack
Projection null fieldsGetter name mismatchMatch getter to property name; check @Property alias
@Query empty with relsMissing collect(r), collect(p)Return root node + rels + related nodes together
Cannot delete node, node has relationshipsdeleteById without detachUse @Query with DETACH DELETE
Transaction not rolling back@Transactional on interfaceApply 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


Checklist

  • @Node uses explicit label string, not default class name
  • @Id @GeneratedValue (or @Id + @Version for business key with optimistic lock)
  • @RelationshipProperties class has @RelationshipId and @TargetNode
  • @Relationship direction is explicit (OUTGOING / INCOMING)
  • @Query Cypher uses $paramName — no string concatenation
  • Relationship-rich @Query returns collect(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
  • @Transactional on concrete service class (not interface)
  • No imperative + reactive mix in same application context
  • Credentials in env vars; .env in .gitignore
  • spring.ai.vectorstore.neo4j.initialize-schema: true for 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