skills/neo4j-contrib/neo4j-skills/neo4j-driver-java-skill

neo4j-driver-java-skill

Installation
SKILL.md

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) → use neo4j-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 transactionsCALL { … } IN TRANSACTIONS and USING PERIODIC COMMIT manage their own transactions internally and fail if run inside a managed transaction. Use session.run() (auto-commit) for these queries; neither executableQuery nor executeRead/Write will 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 Result object — collect to List, Map, or a domain object before the callback exits.
  • Consume each Result before calling tx.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 CREATEMERGE 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
Weekly Installs
2
GitHub Stars
28
First Seen
2 days ago