advanced~3h

Spring Boot with Neo4j — Spring Data Neo4j

Where Cypher meets Spring Boot: mapping nodes and relationships onto Java objects with Spring Data Neo4j, and the honest limit of that mapping — the point where a real traversal query needs raw Cypher instead.

Learning objectives

  • Map a Java class to a Neo4j node using @Node, @Id, and @GeneratedValue.
  • Model relationships as object references and collections using @Relationship.
  • Use Neo4jRepository's derived query methods for simple lookups, and @Query with raw Cypher for traversal queries the object mapping can't express cleanly.

Spring Data Neo4j (SDN) maps a plain Java class to a node with three annotations doing almost all the work:

@Node("Person") public class Person { @Id @GeneratedValue private Long id; // Neo4j's internal node id, managed for you private String name; private String email; // constructors, getters/setters omitted }

@Node marks the class as mapping to nodes with that label (Person here — matching the class name if you omit the argument). @Id marks the identifying field, exactly as it does in Spring Data JPA; @GeneratedValue tells SDN to let Neo4j assign the identifier rather than supplying your own. Every other field maps to a node property of the same name by default — a plain String name field becomes the name property from Chapter 01, no separate mapping annotation required for the common case.

💻 Code example

@Node("Person") public class Person { @Id @GeneratedValue private Long id; // Neo4j's internal node id, managed for you private String name; private String email; // constructors, getters/setters omitted }

The graph-native part: a relationship from Chapter 01 shows up in your entity as a field pointing directly at the related object (or a collection of them), annotated with @Relationship.

@Node("Person") public class Person { @Id @GeneratedValue private Long id; private String name; @Relationship(type = "FOLLOWS", direction = Relationship.Direction.OUTGOING) private List<Person> following = new ArrayList<>(); @Relationship(type = "WORKS_AT", direction = Relationship.Direction.OUTGOING) private Company employer; }

type names the relationship type from the graph (:FOLLOWS, :WORKS_AT); direction matches the arrow direction from Chapter 01's patterns. A collection field (List<Person> following) maps a one-to-many fan-out of relationships; a single-object field (Company employer) maps a one-to-one relationship. Loading a Person this way fetches the object graph reachable through its mapped @Relationship fields, not just the node's own properties — considerably more than the equivalent single-row SELECT would give you in a relational mapping, which is exactly the point of a graph-native ORM.

▲ Common mistake

Mapping every relationship in your domain onto every entity "for completeness" pulls in an increasingly large chunk of the graph on every single load — a Person with following, followers, employer, posts, and comments all mapped can turn a simple "load one person" call into a surprisingly expensive query. Map the relationships a given entity's use cases actually need loaded, and reach for a targeted @Query (§4.4) for anything more exploratory.

💻 Code example

@Node("Person") public class Person { @Id @GeneratedValue private Long id; private String name; @Relationship(type = "FOLLOWS", direction = Relationship.Direction.OUTGOING) private List<Person> following = new ArrayList<>(); @Relationship(type = "WORKS_AT", direction = Relationship.Direction.OUTGOING) private Company employer; }
public interface PersonRepository extends Neo4jRepository<Person, Long> { Optional<Person> findByEmail(String email); List<Person> findByName(String name); }

Neo4jRepository<Person, Long> gives you the familiar Spring Data baseline for free — save(), findById(), findAll(), deleteById() — generating the underlying Cypher for you, the same way JpaRepository generates SQL. Derived query methods work the same way you already know from Spring Data JPA: findByEmail becomes a MATCH (p:Person) WHERE p.email = $email RETURN p under the hood, purely from the method's name.

This covers simple, property-based lookups well — anywhere the query is really just "find nodes matching this property." It does not cover multi-hop traversals (Chapter 02's variable-length paths, shortest path, or the recommendation/fraud-ring patterns) — there's no method-name convention for "friends of friends within 3 hops," and trying to force one into a derived method name quickly becomes unreadable or simply isn't supported. That gap is exactly what @Query is for.

💻 Code example

public interface PersonRepository extends Neo4jRepository<Person, Long> { Optional<Person> findByEmail(String email); List<Person> findByName(String name); }

◆ The problem

A recommendation feature needs Chapter 02's shared-connections pattern — friends-of-friends who aren't already followed, ranked by mutual-connection count. There's no derived-method name for that, and forcing SDN's object mapping to reconstruct an arbitrary aggregation like count(*) AS mutualConnections isn't what it's built for. This is common enough with graph traversal queries specifically that reaching for raw Cypher here isn't a workaround — it's the normal, expected tool.

public interface PersonRepository extends Neo4jRepository<Person, Long> { @Query(""" MATCH (me:Person {id: $personId})-[:FOLLOWS]->(friend)-[:FOLLOWS]->(candidate) WHERE candidate <> me AND NOT (me)-[:FOLLOWS]->(candidate) RETURN candidate, count(*) AS mutualConnections ORDER BY mutualConnections DESC LIMIT 10 """) List<Person> findRecommendations(Long personId); }

@Query takes the Cypher directly, with named parameters ($personId) bound from the method's arguments the same way @Query works in Spring Data JPA with named parameters. The return type still gets mapped back onto your @Node-annotated entities automatically — you get the readability of hand-written Cypher for the traversal itself, without giving up object mapping on the way out.

Be honest with yourself about where the line is: simple property lookups belong in derived methods (§4.3) for their readability and low ceremony; anything shaped like Chapter 02 — variable-length paths, shortest path, shared-connection or cycle patterns, or any query where you're thinking in terms of the graph pattern rather than a single entity's properties — belongs in @Query with real Cypher, because that's genuinely what the query is, and pretending otherwise just hides it behind a method name.

💻 Code example

public interface PersonRepository extends Neo4jRepository<Person, Long> { @Query(""" MATCH (me:Person {id: $personId})-[:FOLLOWS]->(friend)-[:FOLLOWS]->(candidate) WHERE candidate <> me AND NOT (me)-[:FOLLOWS]->(candidate) RETURN candidate, count(*) AS mutualConnections ORDER BY mutualConnections DESC LIMIT 10 """) List<Person> findRecommendations(Long personId); }

Want a visual for this concept?

Generate a diagram tailored to “Spring Boot with Neo4j — Spring Data Neo4j” — the AI picks whichever visual (flowchart, comparison, sequence, etc.) best fits.

Sign in to generate a visual →

Practice quiz

Next Step

← Back to all Neo4j chapters