Every Neo4j engagement we join eventually produces the same question from the client's Java team: how do we talk to this thing without writing driver boilerplate in every service? The answer is Spring Data Neo4j (SDN), the officially supported Spring Data module that sits on top of the Neo4j Java driver and maps your graph onto records and entities. Used well, it removes a lot of plumbing. Used badly, it generates Cartesian-product queries, loads half the graph on every request, and leaves everyone blaming "the graph database".
This tutorial builds a small Spring Boot 3 service on Neo4j — domain mapping, derived and custom queries, projections, transactions, bulk writes, and Testcontainers-backed tests — and flags the traps we see most often in code review.
Prerequisites
- JDK 21+ and Maven (or Gradle)
- Docker, for the database and the tests — see Getting Started with Neo4j 2026.x in Docker
- A Neo4j 5.x / 2025.x instance or Aura
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-neo4j</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>neo4j</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
# application.properties
spring.neo4j.uri=neo4j://localhost:7687
spring.neo4j.authentication.username=neo4j
spring.neo4j.authentication.password=change-me-please
spring.data.neo4j.database=neo4j
The starter configures the driver, the Neo4jClient, the mapping context and a transaction manager. Note what it does not do: it never creates your indexes or constraints. Schema belongs in migrations — see Neo4j in CI/CD: Versioned Schema Migrations and Testcontainers.
Step 1 — model the domain deliberately
We will model a small supply-chain graph: companies that supply parts to other companies.
@Node("Company")
public class Company {
@Id
private String registrationNumber; // natural key — use it when you have one
private String name;
private String country;
@Relationship(type = "SUPPLIES", direction = Relationship.Direction.OUTGOING)
private List<Supply> supplies = new ArrayList<>();
// constructors, getters, setters
}
@RelationshipProperties
public class Supply {
@RelationshipId
private String id;
private String partNumber;
private Integer leadTimeDays;
@TargetNode
private Company customer;
}
Three decisions worth arguing about before anyone writes a repository:
- Ids.
@Idon a business key beats@GeneratedValuewhenever a real key exists: it makes upserts idempotent and makes debugging possible. Never persist Neo4j's internalid(); if you need a surrogate, use@GeneratedValue(UUIDStringGenerator.class). - Relationship direction. Map each relationship once, from the side you actually traverse. Mapping it from both sides creates mutual loading that can pull in an entire connected component.
- Depth. SDN loads the entity and its mapped relationships. On a densely connected model one
findByIdcan become an enormous query — which brings us to the most important section here.
Step 2 — projections, or how to stop loading the whole graph
Default repository methods return full entities. For a list endpoint that is almost always wrong. Define interface projections; SDN turns them into narrow Cypher that returns only what you asked for.
public interface CompanySummary {
String getName();
String getCountry();
}
public interface SupplierView {
String getName();
List<SuppliedPart> getSupplies();
interface SuppliedPart {
String getPartNumber();
Integer getLeadTimeDays();
CompanySummary getCustomer();
}
}
public interface CompanyRepository extends Neo4jRepository<Company, String> {
List<CompanySummary> findAllByCountry(String country); // derived query
Optional<SupplierView> findSupplierViewByRegistrationNumber(String reg);
}
The rule we give client teams: full entities on the write path, projections on the read path. It is frequently the difference between a 6 ms endpoint and a 600 ms one, and it is the first thing we look at in a slow Spring/Neo4j service.
Step 3 — custom Cypher when derived queries run out
Derived query methods cover equality and simple traversal. Anything interesting — aggregation, variable-length paths, WITH pipelines — gets explicit Cypher.
public interface CompanyRepository extends Neo4jRepository<Company, String> {
@Query("MATCH path = (start:Company {registrationNumber: $reg})-[:SUPPLIES]->{1,4}(downstream:Company) " +
"WHERE downstream.country IN $riskyCountries " +
"RETURN downstream.name AS name, downstream.country AS country, length(path) AS hops " +
"ORDER BY hops ASC LIMIT $limit")
List<ExposureRow> findDownstreamExposure(String reg,
List<String> riskyCountries,
int limit);
}
public record ExposureRow(String name, String country, long hops) {}
-[:SUPPLIES]->{1,4} is a quantified path pattern — modern Cypher, covered in Quantified Path Patterns and SHORTEST k. Returning a flat record rather than entities keeps both the result set and the mapping small.
When you need full control — dynamic filters, no mapping at all — drop to Neo4jClient rather than concatenating strings into @Query:
@Service
public class ExposureService {
private final Neo4jClient client;
public ExposureService(Neo4jClient client) {
this.client = client;
}
public Collection<Map<String, Object>> search(String country, Optional<Integer> maxLead) {
var cypher = new StringBuilder(
"MATCH (c:Company)-[s:SUPPLIES]->(:Company) WHERE c.country = $country ");
Map<String, Object> params = new HashMap<>();
params.put("country", country);
maxLead.ifPresent(v -> {
cypher.append("AND s.leadTimeDays <= $maxLead ");
params.put("maxLead", v);
});
cypher.append("RETURN c.name AS name, avg(s.leadTimeDays) AS avgLead");
return client.query(cypher.toString()).bindAll(params).fetch().all();
}
}
Build the structure in Java; never interpolate user input into query text. Parameters are both the security boundary and the key to plan caching — the same argument we make in Text-to-Cypher with Guardrails.
Step 4 — transactions, routing and bookmarks
@Transactional behaves as you expect; the Neo4j transaction manager is wired by the starter. Two Neo4j-specific notes:
- Read-only means routed to a secondary.
@Transactional(readOnly = true)routes the transaction to a follower in a cluster. Put it on every read service method — it is free horizontal read capacity. - Bookmarks give you read-your-own-writes. Spring's bookmark manager propagates them between transactions inside one application, but not across service boundaries. If service A writes and service B must see the write, pass the bookmark — see Causal Consistency in Neo4j Clusters.
@Transactional
public void recordSupply(String supplierReg, String customerReg, String partNumber) {
client.query("MATCH (s:Company {registrationNumber: $s}), (c:Company {registrationNumber: $c}) " +
"MERGE (s)-[r:SUPPLIES {partNumber: $part}]->(c) " +
"ON CREATE SET r.createdAt = datetime()")
.bindAll(Map.of("s", supplierReg, "c", customerReg, "part", partNumber))
.run();
}
Transient failures (leader switches, lock contention) are retried by the driver inside managed transactions, but a TransientException can still reach your code. Wrap write paths that matter with Spring Retry on TransientException.
Step 5 — bulk writes do not belong in the repository
Saving 200,000 entities through saveAll() produces an enormous number of round trips and an unhappy afternoon. Batch with parameters instead:
client.query("UNWIND $rows AS row " +
"MERGE (c:Company {registrationNumber: row.reg}) " +
"SET c.name = row.name, c.country = row.country")
.bind(batch).to("rows")
.run();
Chunk batch into lists of 5k–20k maps and commit per chunk. Above a few hundred thousand rows, go further: Bulk Writes Without the Deadlocks.
Step 6 — test against a real database
Mocking a graph database tests your mocks. Testcontainers starts a real Neo4j per test class in seconds.
@SpringBootTest
@Testcontainers
class CompanyRepositoryTest {
@Container
@ServiceConnection
static Neo4jContainer<?> neo4j = new Neo4jContainer<>("neo4j:2025.09");
@Autowired CompanyRepository repository;
@Autowired Neo4jClient client;
@BeforeEach
void seed() {
client.query("MATCH (n) DETACH DELETE n").run();
client.query("CREATE (a:Company {registrationNumber:'A1', name:'Acme', country:'DE'}) " +
"CREATE (b:Company {registrationNumber:'B2', name:'Bolt', country:'PL'}) " +
"CREATE (a)-[:SUPPLIES {partNumber:'X-9', leadTimeDays: 12}]->(b)").run();
}
@Test
void findsDownstreamExposure() {
var rows = repository.findDownstreamExposure("A1", List.of("PL"), 10);
assertThat(rows).singleElement()
.extracting(ExposureRow::name)
.isEqualTo("Bolt");
}
}
@ServiceConnection (Spring Boot 3.1+) wires the container's URI and credentials into the context — no property juggling. Pin the image to the version production runs, and run your migrations against the container in a setup step so tests exercise the same constraints as production.
The five problems we find in code review
- No projections. Full entities on list endpoints, loading relationship trees nobody renders.
- Bidirectional mappings that make every load transitively huge. Map one direction; traverse the other with a query.
findAll()behind a paginated UI. UsePageable, and check that the generated count query is not a full scan.- Missing constraints. SDN will happily
MERGEon an unindexed property. OneCREATE CONSTRAINT ... IS UNIQUEper@Idproperty, in a migration, turns a scan into a seek. - Nobody ever runs
PROFILE. Setlogging.level.org.springframework.data.neo4j.cypher=DEBUGin dev, take the generated Cypher, and profile it — here is how to read the plan.
Where to go next
The setup above gets a JVM team productive on Neo4j without hiding the database behind a leaky abstraction. The next steps on a real project are schema migrations in CI, a load-test regression gate (we built one here), and a data model reviewed by someone who has lived with one in production.
If you want that review, or senior Neo4j and Spring developers on the team for a sprint or two, see our Neo4j app development services or get in touch.