Calling Graph Data Science from Spring Boot
|
This section documents Spring Boot 4.1.x and Spring Framework 7.0.x on the Java 17/21+ baseline, as described by the official Spring Boot reference documentation and each sub-project’s own site (Spring Data, Spring for Apache Kafka, Micrometer, Project Reactor, springdoc-openapi, Spring gRPC, and the others listed in this section’s Bibliography) — which are the references these pages are written and verified against. This content was generated with the assistance of AI and should be verified against those official docs before being relied on in production. Spring Boot and its ecosystem continue to evolve; the examples here target the current 4.1.x / 7.0.x releases. This section’s bibliography lists the reference material consulted while preparing these pages. |
Every use case in this section ends up calling Graph Data Science (GDS) from Java: project a subgraph into memory,
run an algorithm on it, read or persist the result, and release the memory. This page fixes that recipe once — the
helper classes, the transaction rules, the memory guard and the scheduling pattern — so that the use-case pages only
show the Cypher and the domain code that is specific to them. The algorithms themselves are explained in
Graph Data Science Fundamentals, and the mapping and
Neo4jClient basics in Spring Data Neo4j; neither is repeated here.
GDS is Cypher: there is no Java client
The GDS manual describes its API as Cypher procedures and functions, and Neo4j publishes no official Java client
library for it. A Spring Boot application therefore calls GDS exactly like any other Cypher: through Neo4jClient
(or a @Query method on a repository), with string statements, bound parameters and result rows mapped by hand. The
consequence is that the application owns three things a client library would otherwise hide: the lifecycle of the
in-memory graph, the mapping of YIELD columns to Java types, and the guards (memory, concurrency, duplicate
runs) around long jobs. The rest of this page is those three things.
A GDS procedure only runs where the plugin is installed and allowed. With a manual install, the plugin jar
goes into the plugins folder and the configuration needs dbms.security.procedures.unrestricted=gds.* (plus
dbms.security.procedures.allowlist=gds.* when an allow list is active); the Docker images install it for you
through the NEO4J_PLUGINS variable shown in the setup notes. See
GDS — Neo4j Server installation.
|
Setup notes and version baseline
The pages of this section are written against the following baseline, checked against the official documentation:
| Component | Baseline |
|---|---|
Spring Boot |
4.1.2 (Spring Framework 7.0.x) |
Spring Data Neo4j |
8.1.2, managed by the Spring Boot 4.1.2 dependency versions |
Neo4j Java Driver |
6.1.0, managed by the Spring Boot 4.1.2 dependency versions |
Testcontainers Neo4j module |
|
Neo4j and GDS |
Neo4j 2026.10.x with GDS 2026.10 (the Neo4j and GDS year.month must match) |
APOC Core |
2026.10.x for Neo4j 2026.10.x (the year and month must match the server) |
The versions of Spring Data Neo4j and the driver come from the Spring Boot — Dependency Versions appendix, so a project that inherits the Boot dependency management declares neither version. The Neo4j/GDS pairing comes from the GDS — Supported Neo4j versions table and the APOC pairing from the APOC — Installation page.
The connection is configured with the standard Spring Boot properties (Spring Boot — NoSQL, Neo4j):
spring:
neo4j:
uri: bolt://localhost:7687
authentication:
username: neo4j
password: ${NEO4J_PASSWORD}
data:
neo4j:
database: neo4j # the database SDN and Neo4jClient use; omit it for the default database
Integration tests start a real server with the GDS and APOC plugins through Neo4jContainer, and
@ServiceConnection replaces the connection properties above with the container’s address
(Spring Boot — Testcontainers):
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.neo4j.Neo4jContainer;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
@Testcontainers
@SpringBootTest
class GdsSmokeTest {
@Container
@ServiceConnection
static final Neo4jContainer NEO4J = new Neo4jContainer("neo4j:2026.10.1")
.withEnv("NEO4J_PLUGINS", "[\"graph-data-science\", \"apoc\"]");
// tests call the application beans, which talk to the container through Neo4jConnectionDetails
}
The NEO4J_PLUGINS value is the same one the plugin documentation uses for Docker
(GDS — Docker installation,
APOC — Installation); both pages describe it as a convenience for
development, and a production image mounts the matching jars instead. The fixtures, container reuse and deterministic
assertions are the subject of the testing page of this section.
Procedure names used in this section
The use-case pages call the following procedures and functions. The names are the ones the current manual documents for GDS 2026.10; the tier column records what the manual says about each.
| Algorithm | Procedures and functions | Tier as documented |
|---|---|---|
Weakly Connected Components |
|
No tier label on the page |
Louvain |
|
No tier label on the page |
Betweenness Centrality |
|
No tier label on the page |
Node Similarity |
|
No tier label on the page |
Filtered Node Similarity |
|
No tier label on the page |
K-Nearest Neighbors |
|
No tier label on the page |
Dijkstra Source-Target |
|
No tier label on the page |
A* |
|
No tier label on the page |
Yen’s k-shortest paths |
|
No tier label on the page |
Strongly Connected Components |
|
No tier label on the page |
Topological link prediction |
functions |
The function pages carry no tier label; the algorithm overview lists the topological link prediction family as Alpha |
Link prediction pipelines |
|
Beta |
Every algorithm procedure also has a .estimate variant that works with any execution mode, for example
gds.louvain.write.estimate. See the GDS manual pages
Algorithms,
Topological link prediction — Adamic Adar
and Link prediction pipelines.
Treat Alpha and Beta procedures as subject to change between releases, and pin the Neo4j image tag in tests so that an
upgrade is a deliberate change.
The projection lifecycle
Whatever the algorithm, a GDS job has the same five steps, and the catalog entry it creates lives in the memory of the Neo4j server until somebody drops it:
The steps of the loop are project (copy the selected nodes and relationships into the catalog), run in one of the
four execution modes (stream returns rows, stats returns a summary, mutate stores the result on the in-memory
graph for the next algorithm, write persists it to the database) and drop. The execution modes and the memory
model are explained in
Graph Data Science Fundamentals; the part that matters for
Java is that a projection is not transactional. A rolled-back Spring transaction does not remove it, an exception
between project and drop leaks it, and two jobs that choose the same name collide. Three rules follow:
-
give every run a unique graph name (a prefix that identifies the job plus a random suffix),
-
tie the lifetime of the catalog entry to a Java scope with
AutoCloseableandtry/finally, -
drop with
failIfMissingset tofalse, so that cleanup after a failed projection does not throw a second exception that hides the first one.
A small AutoCloseable helper
The helper below is the only GDS-specific infrastructure the use-case pages need. It projects with either a native or
a Cypher projection, remembers the generated name, and drops the graph on close():
import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.springframework.data.neo4j.core.Neo4jClient;
import org.springframework.stereotype.Component;
@Component
public class GdsGraphs {
private final Neo4jClient client;
public GdsGraphs(Neo4jClient client) {
this.client = client;
}
/** Native projection: node labels and relationship types only. */
public ProjectedGraph projectNative(String prefix, List<String> nodeLabels,
Map<String, Object> relationshipProjection) {
var name = uniqueName(prefix);
var counts = client
.query("""
CALL gds.graph.project($name, $nodeLabels, $relationships)
YIELD nodeCount, relationshipCount
RETURN nodeCount, relationshipCount
""")
.bind(name).to("name")
.bind(nodeLabels).to("nodeLabels")
.bind(relationshipProjection).to("relationships")
.fetchAs(GraphSize.class)
.mappedBy((typeSystem, record) -> new GraphSize(
record.get("nodeCount").asLong(), record.get("relationshipCount").asLong()))
.one()
.orElseThrow();
return new ProjectedGraph(client, name, counts);
}
/** Cypher projection: the caller supplies a MATCH that ends in the gds.graph.project aggregation. */
public ProjectedGraph projectCypher(String prefix, String projectionQuery, Map<String, Object> parameters) {
var name = uniqueName(prefix);
var counts = client
.query(projectionQuery)
.bind(name).to("graphName")
.bindAll(parameters)
.fetchAs(GraphSize.class)
.mappedBy((typeSystem, record) -> new GraphSize(
record.get("nodeCount").asLong(), record.get("relationshipCount").asLong()))
.one()
.orElseThrow();
return new ProjectedGraph(client, name, counts);
}
private static String uniqueName(String prefix) {
return prefix + "-" + UUID.randomUUID();
}
}
public record GraphSize(long nodeCount, long relationshipCount) {
}
public final class ProjectedGraph implements AutoCloseable {
private final Neo4jClient client;
private final String name;
private final GraphSize size;
ProjectedGraph(Neo4jClient client, String name, GraphSize size) {
this.client = client;
this.name = name;
this.size = size;
}
public String name() {
return name;
}
public GraphSize size() {
return size;
}
@Override
public void close() {
client.query("CALL gds.graph.drop($name, false) YIELD graphName RETURN graphName")
.bind(name).to("name")
.run();
}
}
The native form maps to gds.graph.project(graphName, nodeProjection, relationshipProjection, configuration); the
Cypher form is the aggregation-function call shown in
the projection section below. The drop call is
gds.graph.drop(graphName, failIfMissing, ...), whose failIfMissing argument defaults to true. See
GDS — Native projection,
GDS — Cypher projection
and GDS — Dropping graphs.
Using it reads like any resource scope:
public List<CommunityScore> louvainCommunities(Instant since) {
try (var graph = graphs.projectCypher("transfers", TRANSFERS_SINCE,
Map.of("since", since.toString(), "minimumAmount", 0))) {
return client
.query("""
CALL gds.louvain.stream($graph)
YIELD nodeId, communityId
RETURN elementId(gds.util.asNode(nodeId)) AS accountId, communityId
""")
.bind(graph.name()).to("graph")
.fetchAs(CommunityScore.class)
.mappedBy((typeSystem, record) -> new CommunityScore(
record.get("accountId").asString(), record.get("communityId").asLong()))
.all()
.stream()
.toList();
}
}
try-with-resources already is try/finally: close() runs whether the algorithm returns, throws, or the thread is
interrupted. If the process itself dies between project and drop, the entry stays in the catalog until the server
restarts, which is why the helper uses a recognisable prefix: a startup or scheduled cleanup can list the catalog with
gds.graph.list and drop the leftovers whose names start with the application’s prefixes. See
GDS — Dropping graphs.
Mapping YIELD rows to records
Neo4jClient binds named parameters with .bind(value).to("name") or .bindAll(map), and converts each result row
with fetchAs(Type.class).mappedBy(...), a function from the driver’s TypeSystem and Record to the target type;
.all() returns every row, .one() an Optional. This is the same API as in
the Spring Data Neo4j page, and Java
records are the natural result type because a GDS row is a fixed tuple of named columns:
public record RankedAccount(String accountId, double score) {
}
public List<RankedAccount> topBetweenness(ProjectedGraph graph, int limit) {
return client
.query("""
CALL gds.betweenness.stream($graph)
YIELD nodeId, score
RETURN elementId(gds.util.asNode(nodeId)) AS accountId, score
ORDER BY score DESC
LIMIT $limit
""")
.bind(graph.name()).to("graph")
.bind(limit).to("limit")
.fetchAs(RankedAccount.class)
.mappedBy((typeSystem, record) -> new RankedAccount(
record.get("accountId").asString(), record.get("score").asDouble()))
.all()
.stream()
.toList();
}
Three details recur on every page of this section:
-
A GDS row carries an internal node id (
nodeId), which is only meaningful inside the projection. Convert it withgds.util.asNode(nodeId)and return a stable business key orelementId(...); neverid(...), which is deprecated and not stable across stores. -
YIELDlists the columns the Java code reads. Naming them explicitly (and aliasing withAS) keeps the mapper independent of columns that later GDS versions add. -
The graph name, the algorithm configuration values and every filter value are parameters, never concatenated into the statement. Procedure names and the execution mode cannot be parameters, so they stay in the statement text, chosen from a fixed set in code.
A write or stats call returns one summary row, mapped the same way:
public record WriteSummary(long nodePropertiesWritten, long communityCount) {
}
WriteSummary summary = client
.query("""
CALL gds.louvain.write($graph, {writeProperty: $property, concurrency: 1})
YIELD nodePropertiesWritten, communityCount
RETURN nodePropertiesWritten, communityCount
""")
.bind(graph.name()).to("graph")
.bind("communityId").to("property")
.fetchAs(WriteSummary.class)
.mappedBy((typeSystem, record) -> new WriteSummary(
record.get("nodePropertiesWritten").asLong(), record.get("communityCount").asLong()))
.one()
.orElseThrow();
See Spring Data Neo4j — reference guide for Neo4jClient, and
GDS — Louvain for the columns each execution
mode yields.
Transaction semantics
Three facts decide where a GDS call can live:
-
Neo4jClienttakes part in the Spring transaction. Inside@Transactionalit uses the transaction opened by theNeo4jTransactionManager; outside one, each statement runs on its own and commits when it completes. Do not rely on the exact kind of transaction the client opens in that case: statements that only work in an implicit (auto-commit) transaction, such asCALL { ... } IN TRANSACTIONS, are better replaced by a batching loop, as the similarity job of the opportunity page does. See Transactions in the Spring Data Neo4j page. -
The graph catalog is not transactional.
projectanddroptake effect immediately and do not roll back, whatever surrounds them. Put them in thetry/finallyof the helper above, not in a transaction you expect to undo them. -
@Transactional(readOnly = true)selects read access. Spring Data Neo4j opens the session in read mode, which a cluster uses to route the work to a follower.streamandstatscalls and ordinary read queries belong in such a method; awritecall changes the database, so it must run in a writable transaction. Routing and consistency in a cluster are covered in Clustering and Multi-Database.
@Service
public class RingReadService {
private final Neo4jClient client;
public RingReadService(Neo4jClient client) {
this.client = client;
}
@Transactional(readOnly = true)
public List<RankedAccount> persistedScores(int limit) {
return client
.query("""
MATCH (a:Account)
WHERE a.fraudScore IS NOT NULL
RETURN a.accountNumber AS accountId, a.fraudScore AS score
ORDER BY score DESC
LIMIT $limit
""")
.bind(limit).to("limit")
.fetchAs(RankedAccount.class)
.mappedBy((typeSystem, record) -> new RankedAccount(
record.get("accountId").asString(), record.get("score").asDouble()))
.all()
.stream()
.toList();
}
}
See Spring Data Neo4j — reference guide and Neo4j Driver manual for the transaction functions underneath.
Long jobs do not belong in a request transaction. A projection followed by an algorithm run can take minutes. Held
inside a web request’s @Transactional method it would pin a driver connection and a database transaction for the
whole run, hit the request timeout, and, because the catalog is not transactional, still leave the projection behind
when the request dies. The pattern used in this section is the opposite: the HTTP endpoint only reads precomputed
results, and the heavy projection runs in a background job, in its own short transactions, that writes its results
back (for example a communityId or a fraudScore property) for the endpoint to read. For the ordinary
OLTP-versus-analytics split and what repositories can express, see
Spring Data Neo4j.
Estimating before running
A projection that does not fit in memory fails halfway, and an algorithm that does not fit can starve the rest of the server. GDS estimates memory before it runs and aborts a job it can already tell will not fit, but that check only blocks runs that are certain not to fit, so the application adds a stricter guard of its own: estimate first, compare with a configured budget, and refuse to project when the estimate is above it.
@ConfigurationProperties("analytics.gds")
public record GdsLimits(@DefaultValue("2147483648") long maxEstimatedBytes) { // 2 GiB unless configured
}
@Component
public class GdsMemoryGuard {
private final Neo4jClient client;
private final GdsLimits limits;
public GdsMemoryGuard(Neo4jClient client, GdsLimits limits) {
this.client = client;
this.limits = limits;
}
public void requireFits(List<String> nodeLabels, Map<String, Object> relationshipProjection) {
long bytesMax = client
.query("""
CALL gds.graph.project.estimate($nodeLabels, $relationships)
YIELD bytesMax
RETURN bytesMax
""")
.bind(nodeLabels).to("nodeLabels")
.bind(relationshipProjection).to("relationships")
.fetchAs(Long.class)
.mappedBy((typeSystem, record) -> record.get("bytesMax").asLong())
.one()
.orElseThrow();
if (bytesMax > limits.maxEstimatedBytes()) {
throw new AnalyticsRejectedException(
"Projection needs up to %d bytes, budget is %d".formatted(bytesMax, limits.maxEstimatedBytes()));
}
}
}
The budget needs a default because an unset long binds to 0, which would reject every job, including the tiny
graphs of the integration tests. Size it for the server and set it per environment, for example
analytics.gds.max-estimated-bytes=8589934592 on an analytics instance with a large heap; the record is registered by
@ConfigurationPropertiesScan or @EnableConfigurationProperties(GdsLimits.class). See
Spring Boot — Externalized Configuration.
gds.graph.project.estimate takes the same node and relationship projection as a native projection and yields
requiredMemory, bytesMin, bytesMax and the counts it assumed; the algorithm procedures expose the same idea as
gds.<algorithm>.<mode>.estimate, which accepts the name of a projected graph (or a projection configuration) and the
algorithm configuration. Because a Cypher projection cannot be estimated directly, estimate the native shape that
over-approximates the subgraph (the labels and relationship types the MATCH reads) and use the result as an upper
bound. A job that is rejected should surface as a clear error to the operator (a 409 or 422 from an endpoint, a logged
warning from a scheduled job), not as an out-of-memory failure in the middle of the run. The sudo parameter exists
to bypass the server-side check, and this section does not use it. See
GDS — Memory estimation.
Async and scheduled execution
Because the projection is expensive, GDS work runs outside the request thread, in one of two shapes: triggered by an endpoint and run asynchronously, or run on a schedule.
For the scheduled shape, @Scheduled triggers the job and ShedLock makes sure that only one instance of a scaled-out
service starts it per tick; both are explained in Scheduling and
ShedLock, including the choice of lockAtMostFor and lockAtLeastFor. For a GDS job, lockAtMostFor must be longer
than the longest realistic run, otherwise a second instance starts a second projection while the first still holds the
memory:
@Component
public class FraudAnalyticsJob {
private static final Logger log = LoggerFactory.getLogger(FraudAnalyticsJob.class);
private final FraudAnalyticsService analytics;
public FraudAnalyticsJob(FraudAnalyticsService analytics) {
this.analytics = analytics;
}
@Scheduled(cron = "${analytics.fraud.cron:0 0 2 * * *}")
@SchedulerLock(name = "fraud-analytics", lockAtMostFor = "PT2H", lockAtLeastFor = "PT5M")
public void run() {
try {
var summary = analytics.recomputeCommunities();
log.info("Fraud analytics finished: {}", summary);
} catch (AnalyticsRejectedException e) {
log.warn("Fraud analytics skipped: {}", e.getMessage());
}
}
}
For the on-demand shape, @Async (with @EnableAsync and a bounded executor) moves the run to another thread and
returns immediately, so an endpoint can answer 202 Accepted. The same distributed-lock concern applies, because
@Async is per JVM; take the ShedLock lock programmatically inside the method, or reject a second trigger with a
simple "already running" check:
@Service
public class AnalyticsTrigger {
private final FraudAnalyticsService analytics;
private final AtomicBoolean running = new AtomicBoolean();
public AnalyticsTrigger(FraudAnalyticsService analytics) {
this.analytics = analytics;
}
@Async
public CompletableFuture<Void> recomputeAsync() {
if (!running.compareAndSet(false, true)) {
return CompletableFuture.completedFuture(null);
}
try {
analytics.recomputeCommunities();
return CompletableFuture.completedFuture(null);
} finally {
running.set(false);
}
}
}
The results of such jobs are read by the cheap OLTP queries of the application; when those reads are hot, cache them as described in Caching and evict the cache when the job finishes. See Spring Framework — Task Execution and Scheduling and ShedLock.
Native versus Cypher projections
A native projection selects whole labels and relationship types and is the fastest to build; a Cypher projection builds
the in-memory graph from a query, which makes it the tool for a filtered or reshaped subgraph. The trade-offs are
explained in
Graph Data Science Fundamentals.
In current GDS a Cypher projection is not a separate procedure: it is a MATCH that ends in the gds.graph.project
aggregation function, taking the graph name, the source node, the target node and optional data and general
configuration maps, and yielding a graphName, nodeCount and relationshipCount.
Older articles, and some pages in the Neo4j Reference of this site, use the gds.graph.project.cypher
procedure with two query strings. The Cypher projection page of the GDS manual documents only the aggregation form
shown below, and the examples of this section use it.
|
Because the MATCH is an ordinary parameterised Cypher query, the subgraph filter can be driven by request or job
parameters: a time window, a minimum amount, a tenant, a label set. The helper passes the graph name as the
$graphName parameter, so the statement itself is a constant:
static final String TRANSFERS_SINCE = """
MATCH (source:Account)-[transfer:TRANSFER]->(target:Account)
WHERE transfer.timestamp >= datetime($since) AND transfer.amount >= $minimumAmount
WITH gds.graph.project(
$graphName,
source,
target,
{relationshipProperties: transfer {.amount}}
) AS graph
RETURN graph.nodeCount AS nodeCount, graph.relationshipCount AS relationshipCount
""";
try (var graph = graphs.projectCypher("transfers", TRANSFERS_SINCE,
Map.of("since", since.toString(), "minimumAmount", 1_000))) {
// run any algorithm against graph.name()
}
The dataConfig map of the aggregation accepts sourceNodeLabels, targetNodeLabels, sourceNodeProperties,
targetNodeProperties, relationshipProperties and relationshipType; the optional last map takes settings such as
readConcurrency and undirectedRelationshipTypes. Only numeric properties can be projected, which is why the
example projects the amount and not a name. The native counterpart, when the whole label and relationship type is what
is wanted, is the helper’s projectNative:
try (var graph = graphs.projectNative("accounts",
List.of("Account"),
Map.of("TRANSFER", Map.of("orientation", "NATURAL", "properties", List.of("amount"))))) {
// run any algorithm against graph.name()
}
Use the native form as the default and the Cypher form only when the filter cannot be expressed as labels and relationship types. See GDS — Cypher projection and GDS — Native projection.
Managed analytics on Aura
Neo4j Aura offers Aura Graph Analytics, an on-demand environment in which graph analytics runs in sessions that are created and deleted as needed, each as an isolated instance that does not share memory or compute with the database it reads. It is the managed alternative to running GDS inside the database server, and it is documented separately from the plugin used in this section; see Neo4j Aura — Graph Analytics.
Where to go next
-
Spring Data Neo4j — entities, repositories, projections, auditing,
@TransactionalandNeo4jClient. -
Graph Data Science Fundamentals — the graph catalog, projections, execution modes and memory estimation.
-
Scheduling and ShedLock and Caching — the scheduling and cache building blocks the jobs above rely on.
-
Pathfinding and Centrality Algorithms, Community Detection Algorithms and Similarity, Embeddings and ML Pipelines — the algorithms whose procedure names are listed in the setup notes.