neo4j-driver-java-skill
Neo4j Java Driver
Maven group: org.neo4j.driver:neo4j-java-driver
Current stable: v6
Docs: https://neo4j.com/docs/java-manual/current/
API ref: https://neo4j.com/docs/api/java-driver/current/
When to Use
- Writing Java code that connects to Neo4j
- Setting up
GraphDatabase.driver(),executableQuery(), or managed transactions in Maven/Gradle projects - Questions about async/reactive patterns,
SessionConfig, data type mapping, or Spring integration - Debugging sessions, transactions, result handling, or causal consistency in Java
When NOT to Use
- Writing or optimizing Cypher queries → use
neo4j-cypher-skill - Upgrading from an older driver version → use
neo4j-migration-skill - Spring Data Neo4j (
@Node,@Relationship,Neo4jRepository) → useneo4j-spring-data-skill
1. Installation
Maven (pom.xml)
<dependency>
<groupId>org.neo4j.driver</groupId>
<artifactId>neo4j-java-driver</artifactId>
<version>6.0.5</version>
</dependency>
Gradle (build.gradle)
implementation 'org.neo4j.driver:neo4j-java-driver:6.0.5'
Check Maven Central for the latest version.
2. Driver Lifecycle
Driver is thread-safe, immutable, and expensive to create — create exactly one instance per application, share it everywhere, and close it on shutdown.
import org.neo4j.driver.AuthTokens;
import org.neo4j.driver.Driver;
import org.neo4j.driver.GraphDatabase;
public class Neo4jConfig {
public static Driver createDriver(String uri, String user, String password) {
// URI examples:
// "neo4j://localhost" — unencrypted, cluster routing
// "neo4j+s://xxx.databases.neo4j.io" — TLS, cluster routing (Aura)
// "bolt://localhost:7687" — unencrypted, single instance
// "bolt+s://localhost:7687" — TLS, single instance
var driver = GraphDatabase.driver(uri, AuthTokens.basic(user, password));
driver.verifyConnectivity(); // fail fast if unreachable
return driver;
}
}
// In try-with-resources (preferred for short-lived use):
try (var driver = GraphDatabase.driver(uri, AuthTokens.basic(user, password))) {
driver.verifyConnectivity();
// ... do work ...
}
// Or close explicitly (long-lived singleton):
driver.close();
Auth Options
AuthTokens.basic(user, password) // username + password
AuthTokens.bearer(token) // SSO / JWT
AuthTokens.kerberos(base64Ticket) // Kerberos
AuthTokens.none() // unauthenticated (dev only)
3. Choosing the Right API
| API | When to use | Auto-retry? | Streaming? |
|---|---|---|---|
driver.executableQuery() |
Most queries — simple, safe default | ✅ | ❌ (eager) |
session.executeRead/Write() |
Large results, complex callback logic | ✅ | ✅ |
session.beginTransaction() |
Multi-function work, external coordination | ❌ | ✅ |
session.run() |
Self-managing queries only (see below) | ❌ | ✅ |
driver.asyncSession() |
Non-blocking I/O with CompletableFuture |
✅ | ✅ |
Reactive (RxSession) |
Project Reactor / RxJava backpressure | ✅ | ✅ |
Self-managing transactions —
CALL { … } IN TRANSACTIONSandUSING PERIODIC COMMITmanage their own transactions internally and fail if run inside a managed transaction. Usesession.run()(auto-commit) for these queries; neitherexecutableQuerynorexecuteRead/Writewill work.
4. executableQuery — Recommended Default
The highest-level API. Manages sessions, transactions, retries, and bookmarks automatically.
import java.util.Map;
import java.util.concurrent.TimeUnit;
import org.neo4j.driver.QueryConfig;
import org.neo4j.driver.RoutingControl;
// Read query — route to replicas
var result = driver.executableQuery("""
MATCH (p:Person {name: $name})-[:KNOWS]->(friend)
RETURN friend.name AS name
""")
.withParameters(Map.of("name", "Alice"))
.withConfig(QueryConfig.builder()
.withDatabase("neo4j") // always specify — avoids a round-trip
.withRouting(RoutingControl.READ) // route reads to replicas
.build())
.execute();
// Access records
result.records().forEach(r -> System.out.println(r.get("name").asString()));
// Query summary / counters
var summary = result.summary();
System.out.printf("Returned %d records in %d ms%n",
result.records().size(),
summary.resultAvailableAfter(TimeUnit.MILLISECONDS));
// Write query
var writeResult = driver.executableQuery("""
CREATE (p:Person {name: $name, age: $age})
RETURN p
""")
.withParameters(Map.of("name", "Bob", "age", 30))
.withConfig(QueryConfig.builder().withDatabase("neo4j").build())
.execute();
System.out.printf("Created %d nodes%n",
writeResult.summary().counters().nodesCreated());
⚠ Never string-interpolate Cypher. Always use .withParameters() with a Map — prevents injection and enables query plan caching.
5. Managed Transactions (executeRead / executeWrite)
Use when you need lazy result streaming (large data sets) or more control within the transaction callback.
import org.neo4j.driver.Session;
import org.neo4j.driver.SessionConfig;
// Sessions are NOT thread-safe — create one per request/thread and close promptly
try (var session = driver.session(SessionConfig.builder()
.withDatabase("neo4j") // always specify
.build())) {
// Read — routes to replicas automatically
var names = session.executeRead(tx -> {
var result = tx.run("""
MATCH (p:Person)
WHERE p.name STARTS WITH $prefix
RETURN p.name AS name
""",
Map.of("prefix", "Al"));
// ✅ Collect INSIDE the callback — Result is invalid after the tx closes
return result.stream()
.map(r -> r.get("name").asString())
.toList();
});
// Write — routes to leader
session.executeWriteWithoutResult(tx ->
tx.run("CREATE (p:Person {name: $name})", Map.of("name", "Carol"))
);
}
Critical: Result Lifecycle in Callbacks
Result is a lazy cursor tied to the open transaction. The transaction closes the moment the callback returns. Any attempt to read a Result after that throws ResultConsumedException.
// ❌ WRONG — returns a Result, which is already closed by the time the caller uses it
var result = session.executeRead(tx ->
tx.run("MATCH (p:Person) RETURN p.name AS name")
// Result returned here; transaction immediately closes
);
result.stream().forEach(...); // throws ResultConsumedException at runtime
// ✅ CORRECT — collect to a List inside the callback
var names = session.executeRead(tx -> {
var result = tx.run("MATCH (p:Person) RETURN p.name AS name");
return result.stream()
.map(r -> r.get("name").asString())
.toList(); // fully consumed before callback exits
});
Multiple tx.run() Calls in One Callback
When running several queries in sequence, each Result must also be consumed before the next tx.run() or before the callback returns:
var summary = session.executeWrite(tx -> {
// First run — consume it before the second run
var people = tx.run("MATCH (p:Person) RETURN p.name AS name")
.stream()
.map(r -> r.get("name").asString())
.toList(); // consumed ✅
// Second run using first result
for (var name : people) {
tx.run("MERGE (p:Person {name: $name})-[:VISITED]->(c:City {name: $city})",
Map.of("name", name, "city", "London"));
// No return value needed — tx.run() result auto-consumed when ignored
}
return people.size();
});
When you call tx.run() and don't assign the result, the cursor is discarded and the statement is still sent — this is fine for fire-and-forget writes. But if you need to inspect the result, always assign and consume it.
Retry Safety
The callback may execute more than once if the server returns a transient error. Design callbacks to be idempotent:
// ❌ Dangerous — counter incremented on every retry attempt
int[] count = {0};
session.executeWriteWithoutResult(tx -> {
count[0]++; // could be 1, 2, 3... depending on retries
tx.run("CREATE (p:Person {name: $name})", Map.of("name", "Alice"));
});
// ✅ Safe — MERGE is idempotent; no external state touched
session.executeWriteWithoutResult(tx ->
tx.run("MERGE (p:Person {name: $name})", Map.of("name", "Alice"))
);
Key rules for transaction callbacks:
- Never return a
Resultobject — collect toList,Map, or a domain object before the callback exits. - Consume each
Resultbefore callingtx.run()again — multiple open cursors on a single transaction is undefined behaviour in most server configurations. - No side effects (HTTP calls, emails, metric increments) inside the callback — it may be retried.
executeRead→ replica routing;executeWrite→ leader routing.
TransactionConfig — Timeouts & Metadata
import org.neo4j.driver.TransactionConfig;
import java.time.Duration;
var config = TransactionConfig.builder()
.withTimeout(Duration.ofSeconds(5))
.withMetadata(Map.of("app", "myService", "user", userId))
.build();
session.executeRead(tx -> { /* ... */ }, config);
session.executeWrite(tx -> { /* ... */ }, config);
Metadata appears in SHOW TRANSACTIONS and server query logs — great for observability.
6. Explicit Transactions
Use when transaction work spans multiple methods or requires coordination with external systems. Not automatically retried.
try (var session = driver.session(SessionConfig.builder().withDatabase("neo4j").build())) {
var tx = session.beginTransaction();
try {
doPartA(tx);
doPartB(tx);
tx.commit();
} catch (Exception e) {
// Rollback — but rollback itself can throw (e.g. network failure).
// Suppress the rollback exception so the original exception propagates cleanly.
try {
tx.rollback();
} catch (Exception rollbackEx) {
e.addSuppressed(rollbackEx); // visible in the stack trace but doesn't hide e
}
throw e;
}
}
private static void doPartA(Transaction tx) {
tx.run("CREATE (p:Person {name: $name})", Map.of("name", "Alice"));
}
Rollback Can Throw
tx.rollback() is a network call. If the connection is broken or the server is unavailable, it throws. Never let a rollback exception silently swallow the original error:
// ❌ Swallows the real exception if rollback also throws
} catch (Exception e) {
tx.rollback(); // throws → e is lost
throw e;
}
// ✅ Use addSuppressed so both exceptions are visible
} catch (Exception e) {
try { tx.rollback(); } catch (Exception rb) { e.addSuppressed(rb); }
throw e;
}
Commit Uncertainty
If tx.commit() throws a network-level exception (e.g. ServiceUnavailableException), the commit may or may not have succeeded on the server — the driver cannot know. This is the "commit uncertainty" problem:
try {
tx.commit();
} catch (ServiceUnavailableException e) {
// The transaction may have committed or not.
// Do NOT retry blindly — you could create duplicate data.
// Options:
// 1. Make the operation idempotent (MERGE, not CREATE) so a retry is safe.
// 2. Query afterward to check whether the data now exists.
// 3. Accept the uncertainty and surface an appropriate error to the caller.
throw new DataAccessException("Commit uncertain — check database state", e);
}
The safest mitigation: design writes to be idempotent using MERGE and unique constraints, so that retrying an uncertain commit is always safe.
When to Use Explicit vs Managed Transactions
Need automatic retry? → Use executeRead / executeWrite
Work spans multiple methods? → Explicit transaction (pass tx as parameter)
Coordinating with external I/O → Explicit transaction (commit only after I/O succeeds)
Prefer executeRead/Write unless you specifically need explicit boundary control.
7. Session Configuration
import org.neo4j.driver.AccessMode;
// Read-only session (manual routing hint)
var session = driver.session(SessionConfig.builder()
.withDatabase("neo4j")
.withDefaultAccessMode(AccessMode.READ)
.build());
// Per-session auth (cheaper than a new Driver for multi-tenant apps)
var session = driver.session(SessionConfig.builder()
.withDatabase("neo4j")
.withAuthToken(AuthTokens.basic("tenant-user", "pass"))
.build());
// User impersonation (no password needed; executing user must have privilege)
var session = driver.session(SessionConfig.builder()
.withDatabase("neo4j")
.withImpersonatedUser("jane")
.build());
Sessions are short-lived and NOT thread-safe — open inside a try-with-resources or close explicitly. Never share a session between threads.
8. Async API
For non-blocking execution using CompletableFuture / CompletionStage. The async API mirrors the sync API (executeReadAsync, executeWriteAsync, beginTransactionAsync) but every method returns a CompletionStage rather than blocking.
Correct Session Lifecycle — Close in Both Paths
The most common async mistake is leaking sessions when an error occurs. Always chain closeAsync() in both the success and error paths:
import org.neo4j.driver.async.AsyncSession;
import org.neo4j.driver.async.ResultCursor;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
CompletableFuture<List<String>> getNames(Driver driver) {
AsyncSession session = driver.asyncSession(
SessionConfig.builder().withDatabase("neo4j").build());
return session
.executeReadAsync(tx ->
tx.runAsync("MATCH (p:Person) RETURN p.name AS name")
.thenCompose(ResultCursor::listAsync) // collect all records
.thenApply(records -> records.stream()
.map(r -> r.get("name").asString())
.toList())
)
// ✅ Close session after success — thenCompose chains the close
.thenCompose(names -> session.closeAsync().thenApply(v -> names))
// ✅ Close session after failure — handle re-throws after closing
.exceptionallyCompose(e ->
session.closeAsync().thenApply(v -> { throw new RuntimeException(e); })
)
.toCompletableFuture();
}
exceptionally vs exceptionallyCompose: Use exceptionallyCompose (Java 12+) when the recovery step is itself async (like closeAsync()). If you use exceptionally, the session close may not complete before the exception propagates.
Reading Results — listAsync vs forEachAsync
session.executeReadAsync(tx ->
tx.runAsync("MATCH (p:Person) RETURN p.name AS name, p.age AS age")
.thenCompose(cursor ->
// listAsync — collect everything into a List<Record>
cursor.listAsync(r -> new Person(
r.get("name").asString(),
r.get("age").asInt()
))
)
)
// forEachAsync — process records one at a time without building a List
session.executeReadAsync(tx ->
tx.runAsync("MATCH (p:Person) RETURN p.name AS name")
.thenCompose(cursor -> cursor.forEachAsync(
r -> System.out.println(r.get("name").asString())
))
)
Async Write
CompletionStage<Void> createPerson(Driver driver, String name) {
AsyncSession session = driver.asyncSession(
SessionConfig.builder().withDatabase("neo4j").build());
return session
.executeWriteAsync(tx ->
tx.runAsync("CREATE (p:Person {name: $name})", Map.of("name", name))
.thenCompose(ResultCursor::consumeAsync) // discard results, get summary
)
.thenCompose(summary -> session.closeAsync())
.exceptionallyCompose(e ->
session.closeAsync().thenApply(v -> { throw new RuntimeException(e); })
);
}
⚠ Do Not Mix Blocking and Async Code
Never call .toCompletableFuture().get() or join() on an async chain from within another async callback — this blocks a thread that the driver's async machinery may need, causing deadlock under load.
// ❌ Deadlock risk — blocking inside an async callback
session.executeReadAsync(tx ->
tx.runAsync(query)
.thenApply(cursor -> cursor.listAsync().toCompletableFuture().join()) // blocks!
);
// ✅ Chain with thenCompose instead
session.executeReadAsync(tx ->
tx.runAsync(query).thenCompose(ResultCursor::listAsync)
);
9. Error Handling
import org.neo4j.driver.exceptions.Neo4jException;
import org.neo4j.driver.exceptions.ServiceUnavailableException;
import org.neo4j.driver.exceptions.SessionExpiredException;
import org.neo4j.driver.exceptions.TransientException;
try {
driver.executableQuery("...").execute();
} catch (ServiceUnavailableException e) {
// No servers available — check connection
log.error("Database unavailable", e);
} catch (SessionExpiredException e) {
// Session was closed by server; open a new one
log.error("Session expired", e);
} catch (TransientException e) {
// Transient failure — managed transactions retry automatically,
// but you must retry explicit transactions yourself
log.warn("Transient error: {}", e.code(), e);
} catch (Neo4jException e) {
// Server-side Cypher or constraint error; e.code() gives GQL status
log.error("Neo4j error [{}]: {}", e.code(), e.getMessage());
}
Managed transactions (executeRead/Write) automatically retry on transient errors — no catch needed. Explicit transactions require your own retry logic.
10. Data Types & Value Extraction
Cypher values come back as Value objects. Use typed accessors to extract them safely.
| Cypher type | Java type / accessor |
|---|---|
Integer |
value.asLong() / value.asInt() |
Float |
value.asDouble() |
String |
value.asString() |
Boolean |
value.asBoolean() |
List |
value.asList() |
Map |
value.asMap() |
Node |
value.asNode() |
Relationship |
value.asRelationship() |
Path |
value.asPath() |
Date |
value.asLocalDate() |
DateTime |
value.asZonedDateTime() |
null |
value.isNull() → true |
// From a record
var record = result.records().get(0);
String name = record.get("name").asString();
long age = record.get("age").asLong();
boolean alive = record.get("alive").asBoolean();
// Node access
var node = record.get("p").asNode();
String personName = node.get("name").asString();
Iterable<String> labels = node.labels(); // e.g. ["Person"]
Map<String,Object> props = node.asMap();
// Relationship
var rel = record.get("r").asRelationship();
String type = rel.type();
Null Safety — Absent Key vs Graph Null
These are two distinct situations that the driver handles differently. Confusing them is a common source of runtime errors.
Absent key — the column was never projected by the RETURN clause (or a typo in the key name):
// Query: RETURN p.name AS name
var record = result.records().get(0);
// ❌ record.get("nme") — typo; key is absent
Value v = record.get("nme"); // returns Value.NULL — does NOT throw
v.asString(); // ← throws NoSuchElementException (absent key)
// ✅ Check key existence first
if (record.containsKey("nme")) { ... } // false for absent key
// ✅ Or use the index-based accessor which throws clearly:
record.get("nme"); // always check containsKey for optional columns
Graph null — the key is projected but the property or optional match returned null from the database:
// Query: MATCH (p:Person) OPTIONAL MATCH (p)-[:LIVES_IN]->(c:City)
// RETURN p.name AS name, c.name AS city
var record = result.records().get(0);
Value cityValue = record.get("city"); // key EXISTS; value may be null
cityValue.isNull(); // true when no City was matched
// ❌ This throws even though the key exists:
String city = cityValue.asString(); // throws Uncoercible if value is null
// ✅ Null-safe with a default:
String city = cityValue.asString("Unknown"); // returns "Unknown" when null
// ✅ Or check first:
String city = cityValue.isNull() ? null : cityValue.asString();
Summary table:
| Situation | record.get(key) returns |
.asString() behaviour |
|---|---|---|
| Key present, value non-null | the value | returns the string |
| Key present, value is graph null | Value where .isNull() is true |
throws Uncoercible |
| Key absent (typo / not projected) | Value.NULL sentinel |
throws NoSuchElementException |
The safe pattern for optional data:
// Check key presence AND null in one shot:
String city = record.containsKey("city") && !record.get("city").isNull()
? record.get("city").asString()
: "Unknown";
// Or rely on the default overload (handles graph null but NOT absent key):
String city = record.get("city").asString("Unknown"); // only safe if key is always projected
11. Performance
Always Specify the Database
Omitting the database name causes a round-trip to resolve the home database on every call.
// executableQuery:
.withConfig(QueryConfig.builder().withDatabase("neo4j").build())
// Session:
SessionConfig.builder().withDatabase("neo4j").build()
Route Reads to Replicas
// executableQuery:
.withConfig(QueryConfig.builder()
.withDatabase("neo4j")
.withRouting(RoutingControl.READ)
.build())
// Managed transaction:
session.executeRead(tx -> { /* automatically routes to replica */ });
Batch Writes with UNWIND
UNWIND expects a List<Map<String, Object>> as the parameter — each map becomes one row in the Cypher loop. This is the shape the driver serialises correctly over Bolt.
// Build the parameter list — each element is a Map matching your Cypher variable
record PersonData(String name, int age, String city) {}
List<PersonData> people = List.of(
new PersonData("Alice", 30, "London"),
new PersonData("Bob", 25, "Paris")
);
// Convert to List<Map<String, Object>> — the required shape for UNWIND
List<Map<String, Object>> rows = people.stream()
.map(p -> Map.<String, Object>of(
"name", p.name(),
"age", p.age(),
"city", p.city()
))
.toList();
// ❌ Wrong — passing a List<PersonData> directly; Bolt can't serialise custom objects
driver.executableQuery("UNWIND $items AS item MERGE (p:Person {name: item.name})")
.withParameters(Map.of("items", people)) // ← fails at runtime
.execute();
// ✅ Correct — List of plain Maps
driver.executableQuery("""
UNWIND $items AS item
MERGE (p:Person {name: item.name})
SET p.age = item.age
MERGE (c:City {name: item.city})
MERGE (p)-[:LIVES_IN]->(c)
""")
.withParameters(Map.of("items", rows)) // ← List<Map<String,Object>>
.withConfig(QueryConfig.builder().withDatabase("neo4j").build())
.execute();
Allowed parameter value types (the leaves of the Map structure):
| Java type | Cypher type |
|---|---|
String |
String |
Long / Integer / Short / Byte |
Integer |
Double / Float |
Float |
Boolean |
Boolean |
List<?> (of the above) |
List |
Map<String, ?> (of the above) |
Map |
null |
null |
Custom objects, enums, and dates must be converted to one of the above before being put in the parameter map. Passing anything else (e.g. a LocalDate directly) throws ClientException: Unable to convert at runtime.
Group Multiple Queries in One Transaction
// Bad: one transaction per query (high overhead)
for (int i = 0; i < 1000; i++) {
session.executeWrite(tx -> tx.run("<QUERY>", params));
}
// Good: all in one transaction callback
session.executeWriteWithoutResult(tx -> {
for (int i = 0; i < 1000; i++) {
tx.run("<QUERY>", params);
}
});
CREATE vs MERGE
Use CREATE when the data is guaranteed new — MERGE issues an internal match before creating, adding overhead.
Connection Pool vs Session Limits — Two Distinct Failure Modes
The driver has two separate resource pools. They fail differently and are tuned separately.
Connection pool — physical TCP connections to Neo4j. Controlled by Config:
var driver = GraphDatabase.driver(uri, auth,
Config.builder()
.withMaxConnectionPoolSize(50) // default: 100
.withConnectionAcquisitionTimeout(30, TimeUnit.SECONDS) // wait for a free connection
.withMaxConnectionLifetime(1, TimeUnit.HOURS)
.withConnectionLivenessCheckTimeout(30, TimeUnit.MINUTES)
.build());
When the pool is exhausted and a new connection is needed, the driver waits up to connectionAcquisitionTimeout then throws ClientException: Unable to acquire connection from the pool within configured maximum time. This means your application is creating more concurrent sessions than connections available — either increase the pool size or reduce concurrency.
Session limit — sessions are logical (not a separate pool), but each open session holds a connection for the duration of a transaction. The practical limit on concurrent sessions is therefore maxConnectionPoolSize.
// This pattern silently starves the pool under concurrency:
// ❌ Opening many sessions and not closing them promptly
List<Session> sessions = new ArrayList<>();
for (int i = 0; i < 200; i++) {
sessions.add(driver.session(...)); // borrows a connection each time
// ... never closed → pool exhausted after 100 (default)
}
// ✅ Always use try-with-resources so connections return to the pool immediately
try (var session = driver.session(...)) {
// connection held only for the duration of this block
}
Diagnosing which pool is exhausted:
| Error message | Cause | Fix |
|---|---|---|
Unable to acquire connection from the pool within configured maximum time |
Connection pool full | Increase maxConnectionPoolSize or reduce concurrency |
Connection to the database terminated / ServiceUnavailableException |
Network/server issue, not pool | Check server health, firewall, TLS |
| Session appears to hang with no error | Session leak — connection never returned | Audit for sessions not closed in all code paths |
The session-level liveness check (withConnectionLivenessCheckTimeout) validates idle connections before handing them back to your code — useful for long-lived applications where connections may have been silently dropped by a firewall or load balancer.
12. Causal Consistency & Bookmarks
Within a single session, transactions are automatically causally chained — nothing to do.
Across sessions (parallel workers), use executableQuery (auto-managed) or pass bookmarks explicitly:
import org.neo4j.driver.Bookmark;
List<Bookmark> bookmarks = new ArrayList<>();
try (var sessionA = driver.session(SessionConfig.builder().withDatabase("neo4j").build())) {
sessionA.executeWriteWithoutResult(tx -> createPerson(tx, "Alice"));
bookmarks.addAll(sessionA.lastBookmarks());
}
try (var sessionB = driver.session(SessionConfig.builder().withDatabase("neo4j").build())) {
sessionB.executeWriteWithoutResult(tx -> createPerson(tx, "Bob"));
bookmarks.addAll(sessionB.lastBookmarks());
}
// sessionC waits until Alice and Bob both exist
try (var sessionC = driver.session(SessionConfig.builder()
.withDatabase("neo4j")
.withBookmarks(bookmarks) // ← causal dependency
.build())) {
sessionC.executeWriteWithoutResult(tx -> connectPeople(tx, "Alice", "Bob"));
}
executableQuery shares a BookmarkManager automatically — use it for most cases and only drop to explicit bookmarks for complex coordination.
13. Advanced Connection Configuration
import org.neo4j.driver.Config;
import org.neo4j.driver.Logging;
import org.neo4j.driver.net.ServerAddress;
import org.neo4j.driver.NotificationConfig;
import org.neo4j.driver.NotificationSeverity;
var driver = GraphDatabase.driver(uri, auth,
Config.builder()
// Custom resolver for local dev against cluster
.withResolver(address -> Set.of(ServerAddress.of("localhost", 7687)))
// TLS configuration
.withEncryption()
.withTrustStrategy(Config.TrustStrategy.trustAllCertificates()) // dev only!
// Reduce notification noise
.withNotificationConfig(NotificationConfig.defaultConfig()
.enableMinimumSeverity(NotificationSeverity.WARNING))
// Bolt-level debug logging
.withLogging(Logging.slf4j()) // or Logging.console(Level.DEBUG)
// Fetch size (controls record batching from server)
.withFetchSize(1000) // default: 1000
.build());
14. Repository Pattern — Recommended Structure
Wrap the driver behind an interface for testability and clean separation:
public interface PersonRepository {
List<Person> findByNamePrefix(String prefix);
void createPerson(String name, int age);
}
public class Neo4jPersonRepository implements PersonRepository {
private final Driver driver;
private final String database;
public Neo4jPersonRepository(Driver driver, String database) {
this.driver = driver;
this.database = database;
}
@Override
public List<Person> findByNamePrefix(String prefix) {
var result = driver.executableQuery("""
MATCH (p:Person)
WHERE p.name STARTS WITH $prefix
RETURN p.name AS name, p.age AS age
""")
.withParameters(Map.of("prefix", prefix))
.withConfig(QueryConfig.builder()
.withDatabase(database)
.withRouting(RoutingControl.READ)
.build())
.execute();
return result.records().stream()
.map(r -> new Person(r.get("name").asString(), r.get("age").asInt()))
.toList();
}
@Override
public void createPerson(String name, int age) {
driver.executableQuery("CREATE (p:Person {name: $name, age: $age})")
.withParameters(Map.of("name", name, "age", age))
.withConfig(QueryConfig.builder().withDatabase(database).build())
.execute();
}
}
16. Quick Reference: Common Mistakes
| Mistake | Fix |
|---|---|
| String-interpolating Cypher params | Always use .withParameters(Map.of(...)) |
| Omitting database name | Set in QueryConfig or SessionConfig — every time |
Creating a new Driver per request |
Create once at startup, close on shutdown |
Sharing a Session across threads |
Sessions are single-threaded; use one per request |
Returning Result from a tx callback |
Collect to List/Map before the callback exits |
Leaving a Result open before next tx.run() |
Consume each result before the next run call |
| Side effects inside managed tx callbacks | Move them outside — the callback may be retried |
| Passing custom objects to UNWIND params | Convert to List<Map<String,Object>> first |
Calling record.get("key").asString() on graph null |
Use .asString("default") or check .isNull() first |
Calling .asString() on an absent key |
Check record.containsKey() before accessing optional columns |
Naked tx.rollback() in a catch block |
Wrap in its own try/catch and use addSuppressed |
Assuming commit() failure = no commit |
Commit uncertainty is real — design writes as idempotent |
Blocking inside an async callback (.join(), .get()) |
Chain with thenCompose instead |
| Not closing async session in error path | Use exceptionallyCompose to close before re-throwing |
| One transaction per write in a loop | Batch with UNWIND or group in one tx callback |
Using MERGE for guaranteed-new data |
Use CREATE — MERGE costs an extra match round-trip |
| Not closing sessions | Use try-with-resources; leaked sessions exhaust the connection pool |
Using executeWrite for a read |
Use executeRead — routes to replicas, not the leader |
| Ignoring pool exhaustion errors | Tune maxConnectionPoolSize or audit for session leaks |