Skip to content
V1 V2

Creating a Neo4j Integration Module

This guide turns a third-party Java driver into a reusable Kora module. You will connect configuration, resource ownership and telemetry to the application graph, then verify a database write through HTTP. Choose Java or Kotlin for the integration library and the application; both branches implement the same configuration, lifecycle and telemetry contracts.

What You'll Build

  • A neo4j-integration library with typed configuration and a lifecycle-managed client
  • A factory module that connects the client to the compile-time graph
  • Duration metrics, an operation span and failure logging
  • PUT /people/{name} that creates or finds one person and returns their name
  • A local database with a uniqueness constraint and repeatable verification

What You'll Need

  • JDK 25 or later and Gradle 9+ Wrapper
  • A working application from Getting Started
  • Docker with Compose v2 supporting up --wait
  • An IDE or editor and basic Cypher familiarity

Use the baseline Kora 2.0.0.RC2 BOM. Kotlin applications keep the baseline Kotlin 2.4.20 and KSP 2.3.12; the Kotlin library uses KSP to generate its typed configuration.

Prerequisites

Required: a working HTTP application

Complete Getting Started first. Keep its application plugin, Kora processors, HTTP server, the selected HOCON/YAML config backend and Logback dependencies. This guide adds a library subproject to that application.

Read Dependency Injection for factory modules and Observability for metrics and tracing. All commands below run from the application project root.

Overview

An integration is the boundary between an external library and your application. Neo4j owns the network protocol and transactions; Kora owns how the client is configured, constructed, started and stopped. Business code depends on Neo4jClient rather than creating drivers or reading environment variables.

Configuration and the graph

@ConfigSource("neo4j") generates a config implementation, mapper and factory module. Neo4jModule contributes the remaining factories. Their parameters describe graph edges: config and telemetry are dependencies of the client, and the client is a dependency of the controller. Ordinary factories are enough; a custom AP/KSP generator is a later, optional convenience.

Resource ownership

One thread-safe Driver owns the connection pool. A session belongs to one operation, and its transaction result must be consumed before the session closes. Kora calls init() before serving requests and release() during graph shutdown. A plain constructor call outside the graph does not arrange that lifecycle.

Operation telemetry

Instrumentation wraps the whole savePerson operation, including connection acquisition and managed transaction retries. It records one outcome and one child span per invocation. It does not measure each query or expose driver pool statistics. The HTTP handler runs synchronously on a virtual thread, so the blocking driver API fits this example.

The flow is: create the library, generate config, add telemetry and lifecycle, publish factories, connect the root, expose HTTP, then check data and observability.

Dependencies and Project Layout

Add include("neo4j-integration") to the root settings.gradle or settings.gradle.kts. Configure neo4j-integration/build.gradle or neo4j-integration/build.gradle.kts:

plugins { id 'java-library' }
repositories { mavenCentral() }
java { toolchain { languageVersion = JavaLanguageVersion.of(25) } }
dependencies {
    annotationProcessor platform("io.koraframework:kora-bom:2.0.0.RC2")
    annotationProcessor "io.koraframework:annotation-processors"
    api platform("io.koraframework:kora-bom:2.0.0.RC2")
    api "io.koraframework:common"
    api "io.koraframework:config-common"
    api "io.koraframework:application-graph"
    api "io.koraframework:micrometer-module"
    api "io.koraframework:opentelemetry-tracing"
    implementation "org.neo4j.driver:neo4j-java-driver:6.1.0"
}
plugins { id("java-library") }
repositories { mavenCentral() }
java { toolchain { languageVersion.set(JavaLanguageVersion.of(25)) } }
dependencies {
    annotationProcessor(platform("io.koraframework:kora-bom:2.0.0.RC2"))
    annotationProcessor("io.koraframework:annotation-processors")
    api(platform("io.koraframework:kora-bom:2.0.0.RC2"))
    api("io.koraframework:common")
    api("io.koraframework:config-common")
    api("io.koraframework:application-graph")
    api("io.koraframework:micrometer-module")
    api("io.koraframework:opentelemetry-tracing")
    implementation("org.neo4j.driver:neo4j-java-driver:6.1.0")
}
plugins {
    id 'java-library'
    id 'org.jetbrains.kotlin.jvm' version '2.4.20'
    id 'com.google.devtools.ksp' version '2.3.12'
}
repositories { mavenCentral() }
kotlin { jvmToolchain(25) }
dependencies {
    ksp "io.koraframework:symbol-processors:2.0.0.RC2"
    api platform("io.koraframework:kora-bom:2.0.0.RC2")
    api "io.koraframework:common"
    api "io.koraframework:config-common"
    api "io.koraframework:application-graph"
    api "io.koraframework:micrometer-module"
    api "io.koraframework:opentelemetry-tracing"
    implementation "org.neo4j.driver:neo4j-java-driver:6.1.0"
}
plugins {
    id("java-library")
    id("org.jetbrains.kotlin.jvm") version "2.4.20"
    id("com.google.devtools.ksp") version "2.3.12"
}
repositories { mavenCentral() }
kotlin { jvmToolchain(25) }
dependencies {
    ksp("io.koraframework:symbol-processors:2.0.0.RC2")
    api(platform("io.koraframework:kora-bom:2.0.0.RC2"))
    api("io.koraframework:common")
    api("io.koraframework:config-common")
    api("io.koraframework:application-graph")
    api("io.koraframework:micrometer-module")
    api("io.koraframework:opentelemetry-tracing")
    implementation("org.neo4j.driver:neo4j-java-driver:6.1.0")
}

Select the library source language, then one Gradle DSL. Java uses AP; Kotlin uses KSP. Keep the Kotlin/KSP versions aligned with the root and omit repeated plugin versions if already declared there. The source language and Gradle DSL are independent. api exposes Kora types in public signatures; implementation keeps the Neo4j driver behind the wrapper. See the driver manual for server compatibility.

Add the runtime project to the existing application dependencies:

dependencies {
    // Keep existing Kora dependencies.
    implementation project(":neo4j-integration")
}
dependencies {
    // Keep existing Kora dependencies.
    implementation(project(":neo4j-integration"))
}
dependencies {
    // Keep existing Kora dependencies.
    implementation project(":neo4j-integration")
}
dependencies {
    // Keep existing Kora dependencies.
    implementation(project(":neo4j-integration"))
}

Put Java library files in neo4j-integration/src/main/java/example/neo4j/ or Kotlin files in neo4j-integration/src/main/kotlin/example/neo4j/, according to the selected branch. Create only one version of each type. The application files use example.app.

settings.gradle[.kts]
build.gradle[.kts]
compose.yaml
neo4j-integration/
  build.gradle[.kts]
  src/main/java/example/neo4j/
    Neo4jConfig.java
    Neo4jTelemetry.java
    Neo4jClient.java
    Neo4jModule.java
  src/main/kotlin/example/neo4j/                # Kotlin library branch
    Neo4jConfig.kt
    Neo4jTelemetry.kt
    Neo4jClient.kt
    Neo4jModule.kt
src/main/resources/application.conf           # HOCON branch
src/main/resources/application.yaml           # YAML branch
src/main/java/example/app/       # Java application
src/main/kotlin/example/app/     # Kotlin application

Configuration format

Keep exactly one config backend in the application. HOCON uses config-hocon, HoconConfigModule and application.conf; YAML uses config-yaml, YamlConfigModule and application.yaml. Replace the baseline config dependency with the selected one. The complete roots below show both module choices.

dependencies {
    implementation "io.koraframework:config-hocon"
}
dependencies {
    implementation("io.koraframework:config-hocon")
}
dependencies {
    implementation "io.koraframework:config-yaml"
}
dependencies {
    implementation("io.koraframework:config-yaml")
}

Configuration

Create neo4j-integration/src/main/java/example/neo4j/Neo4jConfig.java:

package example.neo4j;

import io.koraframework.config.common.annotation.ConfigSource;
import java.time.Duration;

@ConfigSource("neo4j")
public interface Neo4jConfig {
    String uri();
    String username();
    String password();
    default String database() { return "neo4j"; }
    default Duration connectionTimeout() { return Duration.ofSeconds(5); }
    default Duration acquisitionTimeout() { return Duration.ofSeconds(10); }
    default Duration retryTimeout() { return Duration.ofSeconds(15); }
}

Create neo4j-integration/src/main/kotlin/example/neo4j/Neo4jConfig.kt:

package example.neo4j

import io.koraframework.config.common.annotation.ConfigSource
import java.time.Duration

@ConfigSource("neo4j")
interface Neo4jConfig {
    fun uri(): String
    fun username(): String
    fun password(): String
    fun database(): String = "neo4j"
    fun connectionTimeout(): Duration = Duration.ofSeconds(5)
    fun acquisitionTimeout(): Duration = Duration.ofSeconds(10)
    fun retryTimeout(): Duration = Duration.ofSeconds(15)
}

URI, username and password are required. Defaults belong to the typed contract; the application can override them without rebuilding the library.

Choose one configuration format and add the matching resource:

src/main/resources/application.conf:

neo4j {
  uri = "bolt://localhost:7687"
  username = "neo4j"
  password = ${NEO4J_PASSWORD}
  database = "neo4j"
  connectionTimeout = "5s"
  acquisitionTimeout = "10s"
  retryTimeout = "15s"
}
httpServer.port = 8080
httpServer.system.port = 8085
metrics.enabled = true
httpServer.telemetry.metrics.enabled = true
tracing.enabled = true
httpServer.telemetry.tracing.enabled = true

src/main/resources/application.yaml:

neo4j:
  uri: "bolt://localhost:7687"
  username: "neo4j"
  password: "${NEO4J_PASSWORD}"
  database: "neo4j"
  connectionTimeout: "5s"
  acquisitionTimeout: "10s"
  retryTimeout: "15s"
httpServer:
  port: 8080
  system:
    port: 8085
  telemetry:
    metrics:
      enabled: true
    tracing:
      enabled: true
metrics:
  enabled: true
tracing:
  enabled: true

The password is resolved from the environment at startup. These are driver connection/acquisition and retry limits, not a total HTTP deadline or a Cypher query timeout. An exporter is a separate tracing setup; enabling tracing alone does not send spans to a collector.

Telemetry

Create neo4j-integration/src/main/java/example/neo4j/Neo4jTelemetry.java:

package example.neo4j;

import io.koraframework.common.telemetry.OpentelemetryContext;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import io.opentelemetry.api.trace.StatusCode;
import io.opentelemetry.api.trace.Tracer;
import io.opentelemetry.context.Context;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.concurrent.TimeUnit;
import java.util.function.Supplier;

public final class Neo4jTelemetry {
    private static final Logger log = LoggerFactory.getLogger(Neo4jTelemetry.class);
    private final Tracer tracer;
    private final Timer success;
    private final Timer failure;

    public Neo4jTelemetry(MeterRegistry registry, Tracer tracer) {
        this.tracer = tracer;
        this.success = Timer.builder("neo4j.operation.duration")
            .tag("operation", "savePerson").tag("result", "success").register(registry);
        this.failure = Timer.builder("neo4j.operation.duration")
            .tag("operation", "savePerson").tag("result", "failure").register(registry);
    }

    public <T> T observeSavePerson(Supplier<T> callback) {
        var started = System.nanoTime();
        var parent = Context.current();
        var span = tracer.spanBuilder("neo4j.savePerson").setParent(parent).startSpan();
        try {
            return ScopedValue.where(OpentelemetryContext.VALUE, parent.with(span)).call(() -> {
                try {
                    var result = callback.get();
                    success.record(System.nanoTime() - started, TimeUnit.NANOSECONDS);
                    return result;
                } catch (RuntimeException | Error e) {
                    span.setStatus(StatusCode.ERROR);
                    log.warn("Neo4j operation savePerson failed: {}", e.getClass().getSimpleName());
                    throw e;
                }
            });
        } catch (RuntimeException | Error e) {
            failure.record(System.nanoTime() - started, TimeUnit.NANOSECONDS);
            throw e;
        } finally {
            span.end();
        }
    }
}

Create neo4j-integration/src/main/kotlin/example/neo4j/Neo4jTelemetry.kt:

package example.neo4j

import io.koraframework.common.telemetry.OpentelemetryContext
import io.micrometer.core.instrument.MeterRegistry
import io.micrometer.core.instrument.Timer
import io.opentelemetry.api.trace.StatusCode
import io.opentelemetry.api.trace.Tracer
import io.opentelemetry.context.Context
import org.slf4j.LoggerFactory
import java.util.concurrent.TimeUnit

class Neo4jTelemetry(registry: MeterRegistry, private val tracer: Tracer) {
    private val log = LoggerFactory.getLogger(Neo4jTelemetry::class.java)
    private val success = Timer.builder("neo4j.operation.duration")
        .tag("operation", "savePerson").tag("result", "success").register(registry)
    private val failure = Timer.builder("neo4j.operation.duration")
        .tag("operation", "savePerson").tag("result", "failure").register(registry)

    fun <T> observeSavePerson(callback: () -> T): T {
        val started = System.nanoTime()
        val parent = Context.current()
        val span = tracer.spanBuilder("neo4j.savePerson").setParent(parent).startSpan()
        try {
            return ScopedValue.where(OpentelemetryContext.VALUE, parent.with(span)).call<T, Throwable> {
                try {
                    val result = callback()
                    success.record(System.nanoTime() - started, TimeUnit.NANOSECONDS)
                    result
                } catch (e: Throwable) {
                    span.setStatus(StatusCode.ERROR)
                    log.warn("Neo4j operation savePerson failed: {}", e.javaClass.simpleName)
                    throw e
                }
            }
        } catch (e: Throwable) {
            failure.record(System.nanoTime() - started, TimeUnit.NANOSECONDS)
            throw e
        } finally {
            span.end()
        }
    }
}

Timers are registered once with fixed operation and result tags, so person names do not create unbounded metric series. Context.current() captures the HTTP parent; the new span is bound through Kora’s OpentelemetryContext.VALUE for the callback. Leaving ScopedValue.where(...).call(...) restores the parent even on failure, and finally ends the span. Avoid makeCurrent() with Kora’s scoped context storage.

Only the error class is logged; credentials, names and parameter values are not included. If you add other operations, give each its own bounded telemetry contract instead of passing arbitrary user strings as operation names.

Client and Lifecycle

Create neo4j-integration/src/main/java/example/neo4j/Neo4jClient.java:

package example.neo4j;

import io.koraframework.application.graph.Lifecycle;
import org.neo4j.driver.AuthTokens;
import org.neo4j.driver.Driver;
import org.neo4j.driver.Config;
import java.util.concurrent.TimeUnit;
import org.neo4j.driver.GraphDatabase;
import org.neo4j.driver.SessionConfig;
import org.neo4j.driver.Values;

public final class Neo4jClient implements Lifecycle {
    private final Driver driver;
    private final SessionConfig sessionConfig;
    private final Neo4jTelemetry telemetry;

    public Neo4jClient(Neo4jConfig config, Neo4jTelemetry telemetry) {
        var driverConfig = Config.builder()
            .withConnectionTimeout(config.connectionTimeout().toMillis(), TimeUnit.MILLISECONDS)
            .withConnectionAcquisitionTimeout(config.acquisitionTimeout().toMillis(), TimeUnit.MILLISECONDS)
            .withMaxTransactionRetryTime(config.retryTimeout().toMillis(), TimeUnit.MILLISECONDS)
            .build();
        this.driver = GraphDatabase.driver(config.uri(),
            AuthTokens.basic(config.username(), config.password()), driverConfig);
        this.sessionConfig = SessionConfig.builder().withDatabase(config.database()).build();
        this.telemetry = telemetry;
    }

    @Override
    public void init() {
        try {
            driver.verifyConnectivity();
        } catch (RuntimeException e) {
            try {
                driver.close();
            } catch (RuntimeException closeError) {
                e.addSuppressed(closeError);
            }
            throw e;
        }
    }

    @Override
    public void release() { driver.close(); }

    public String savePerson(String name) {
        return telemetry.observeSavePerson( () -> {
            try (var session = driver.session(sessionConfig)) {
                return session.executeWrite(tx -> tx.run(
                    "MERGE (p:Person {name: $name}) RETURN p.name AS name",
                    Values.parameters("name", name)
                ).single().get("name").asString());
            }
        });
    }
}

Create neo4j-integration/src/main/kotlin/example/neo4j/Neo4jClient.kt:

package example.neo4j

import io.koraframework.application.graph.Lifecycle
import org.neo4j.driver.AuthTokens
import org.neo4j.driver.Config
import org.neo4j.driver.GraphDatabase
import org.neo4j.driver.SessionConfig
import org.neo4j.driver.Values
import java.util.concurrent.TimeUnit

class Neo4jClient(config: Neo4jConfig, private val telemetry: Neo4jTelemetry) : Lifecycle {
    private val driver = GraphDatabase.driver(config.uri(),
        AuthTokens.basic(config.username(), config.password()), Config.builder()
            .withConnectionTimeout(config.connectionTimeout().toMillis(), TimeUnit.MILLISECONDS)
            .withConnectionAcquisitionTimeout(config.acquisitionTimeout().toMillis(), TimeUnit.MILLISECONDS)
            .withMaxTransactionRetryTime(config.retryTimeout().toMillis(), TimeUnit.MILLISECONDS)
            .build())
    private val sessionConfig = SessionConfig.builder().withDatabase(config.database()).build()

    override fun init() {
        try {
            driver.verifyConnectivity()
        } catch (e: RuntimeException) {
            try {
                driver.close()
            } catch (closeError: RuntimeException) {
                e.addSuppressed(closeError)
            }
            throw e
        }
    }

    override fun release() { driver.close() }

    fun savePerson(name: String): String = telemetry.observeSavePerson {
        driver.session(sessionConfig).use { session ->
            session.executeWrite { tx ->
                tx.run("MERGE (p:Person {name: \$name}) RETURN p.name AS name",
                    Values.parameters("name", name)).single().get("name").asString()
            }
        }
    }
}

The constructor creates the shared driver; init() verifies connectivity and closes it if verification fails. A cleanup failure is attached as a suppressed exception so the original connectivity error remains visible. release() closes the pool when the graph stops.

The session is local to one invocation. single().get("name").asString() materializes the value inside the transaction; returning a lazy Result would leave the caller dependent on a closed session. $name is a Cypher parameter rather than string interpolation.

Managed executeWrite may repeat its callback. Keep email, HTTP calls and other external side effects outside that callback. The transaction manual explains managed transaction behavior.

Modules

Create neo4j-integration/src/main/java/example/neo4j/Neo4jModule.java:

package example.neo4j;

import io.koraframework.common.annotation.Module;
import io.micrometer.core.instrument.MeterRegistry;
import io.opentelemetry.api.trace.Tracer;

@Module
public interface Neo4jModule extends Neo4jConfigModule {
    default Neo4jTelemetry neo4jTelemetry(MeterRegistry registry, Tracer tracer) {
        return new Neo4jTelemetry(registry, tracer);
    }

    default Neo4jClient neo4jClient(Neo4jConfig config, Neo4jTelemetry telemetry) {
        return new Neo4jClient(config, telemetry);
    }
}

Create neo4j-integration/src/main/kotlin/example/neo4j/Neo4jModule.kt:

package example.neo4j

import io.koraframework.common.annotation.Module
import io.micrometer.core.instrument.MeterRegistry
import io.opentelemetry.api.trace.Tracer

@Module
interface Neo4jModule : Neo4jConfigModule {
    fun neo4jTelemetry(registry: MeterRegistry, tracer: Tracer): Neo4jTelemetry =
        Neo4jTelemetry(registry, tracer)

    fun neo4jClient(config: Neo4jConfig, telemetry: Neo4jTelemetry): Neo4jClient =
        Neo4jClient(config, telemetry)
}

Create src/main/java/example/app/Application.java:

package example.app;

import example.neo4j.Neo4jModule;
import io.koraframework.application.graph.KoraApplication;
import io.koraframework.common.annotation.KoraApp;
import io.koraframework.config.hocon.HoconConfigModule;
import io.koraframework.http.server.undertow.UndertowPublicHttpServerModule;
import io.koraframework.logging.logback.LogbackModule;
import io.koraframework.micrometer.module.MetricsModule;
import io.koraframework.opentelemetry.tracing.OpentelemetryTracingModule;

@KoraApp
public interface Application extends HoconConfigModule, LogbackModule,
        UndertowPublicHttpServerModule, Neo4jModule, MetricsModule,
        OpentelemetryTracingModule {
    static void main(String[] args) {
        KoraApplication.run(ApplicationGraph::graph);
    }
}
package example.app;

import example.neo4j.Neo4jModule;
import io.koraframework.application.graph.KoraApplication;
import io.koraframework.common.annotation.KoraApp;
import io.koraframework.config.yaml.YamlConfigModule;
import io.koraframework.http.server.undertow.UndertowPublicHttpServerModule;
import io.koraframework.logging.logback.LogbackModule;
import io.koraframework.micrometer.module.MetricsModule;
import io.koraframework.opentelemetry.tracing.OpentelemetryTracingModule;

@KoraApp
public interface Application extends YamlConfigModule, LogbackModule,
        UndertowPublicHttpServerModule, Neo4jModule, MetricsModule,
        OpentelemetryTracingModule {
    static void main(String[] args) {
        KoraApplication.run(ApplicationGraph::graph);
    }
}

Create src/main/kotlin/example/app/Application.kt:

package example.app

import example.neo4j.Neo4jModule
import io.koraframework.application.graph.KoraApplication
import io.koraframework.common.annotation.KoraApp
import io.koraframework.config.hocon.HoconConfigModule
import io.koraframework.http.server.undertow.UndertowPublicHttpServerModule
import io.koraframework.logging.logback.LogbackModule
import io.koraframework.micrometer.module.MetricsModule
import io.koraframework.opentelemetry.tracing.OpentelemetryTracingModule

@KoraApp
interface Application : HoconConfigModule, LogbackModule,
    UndertowPublicHttpServerModule, Neo4jModule, MetricsModule,
    OpentelemetryTracingModule

fun main() {
    KoraApplication.run(ApplicationGraph::graph)
}
package example.app

import example.neo4j.Neo4jModule
import io.koraframework.application.graph.KoraApplication
import io.koraframework.common.annotation.KoraApp
import io.koraframework.config.yaml.YamlConfigModule
import io.koraframework.http.server.undertow.UndertowPublicHttpServerModule
import io.koraframework.logging.logback.LogbackModule
import io.koraframework.micrometer.module.MetricsModule
import io.koraframework.opentelemetry.tracing.OpentelemetryTracingModule

@KoraApp
interface Application : YamlConfigModule, LogbackModule,
    UndertowPublicHttpServerModule, Neo4jModule, MetricsModule,
    OpentelemetryTracingModule

fun main() {
    KoraApplication.run(ApplicationGraph::graph)
}

Neo4jModule extends the generated Neo4jConfigModule so its config factory is explicitly inherited across the library boundary. The application root then extends Neo4jModule. An annotation on a module in a dependency JAR alone is not sufficient to connect that factory. MetricsModule supplies MeterRegistry; OpentelemetryTracingModule supplies Tracer. Kora recognizes the returned client’s Lifecycle contract.

Because this root uses a new package, update the existing application block:

application {
    mainClass = "example.app.Application"
}
application {
    mainClass.set("example.app.Application")
}
application {
    mainClass = "example.app.ApplicationKt"
}
application {
    mainClass.set("example.app.ApplicationKt")
}

Replace the old @KoraApp root rather than retaining two roots. The original /hello controller may remain.

Controller

Create src/main/java/example/app/PeopleController.java:

package example.app;

import example.neo4j.Neo4jClient;
import io.koraframework.common.annotation.Component;
import io.koraframework.http.common.HttpMethod;
import io.koraframework.http.common.annotation.HttpRoute;
import io.koraframework.http.common.body.HttpBody;
import io.koraframework.http.server.common.annotation.HttpController;
import io.koraframework.http.common.annotation.Path;
import io.koraframework.http.server.common.response.HttpServerResponse;

@Component
@HttpController
public final class PeopleController {
    private final Neo4jClient client;

    public PeopleController(Neo4jClient client) {
        this.client = client;
    }

    @HttpRoute(method = HttpMethod.PUT, path = "/people/{name}")
    public HttpServerResponse savePerson(@Path String name) {
        return HttpServerResponse.of(200, HttpBody.plaintext(client.savePerson(name)));
    }
}

Create src/main/kotlin/example/app/PeopleController.kt:

package example.app

import example.neo4j.Neo4jClient
import io.koraframework.common.annotation.Component
import io.koraframework.http.common.HttpMethod
import io.koraframework.http.common.annotation.HttpRoute
import io.koraframework.http.common.body.HttpBody
import io.koraframework.http.server.common.annotation.HttpController
import io.koraframework.http.common.annotation.Path
import io.koraframework.http.server.common.response.HttpServerResponse

@Component
@HttpController
class PeopleController(private val client: Neo4jClient) {
    @HttpRoute(method = HttpMethod.PUT, path = "/people/{name}")
    fun savePerson(@Path name: String): HttpServerResponse =
        HttpServerResponse.of(200, HttpBody.plaintext(client.savePerson(name)))
}

PUT matches the write semantics: the name identifies the resource, and repeating the request returns the same name. A uniqueness constraint below makes that identity explicit in the database. GET must not create data. The driver exception follows the ordinary HTTP error path; domain error mapping is beyond this small integration.

Generated Configuration and Graph

After compiling the library, inspect:

neo4j-integration/build/generated/sources/annotationProcessor/java/main/example/neo4j/Neo4jConfigModule.java
neo4j-integration/build/generated/sources/annotationProcessor/java/main/example/neo4j/$Neo4jConfig_ConfigValueMapper.java
neo4j-integration/build/generated/ksp/main/kotlin/example/neo4j/Neo4jConfigModule.kt
neo4j-integration/build/generated/ksp/main/kotlin/example/neo4j/$Neo4jConfig_ConfigValueMapper.kt

The config module contains this factory (annotations omitted):

public interface Neo4jConfigModule {
    default Neo4jConfig neo4jConfig(
            io.koraframework.config.common.Config config,
            io.koraframework.config.common.mapper.ConfigValueMapper<Neo4jConfig> mapper) {
        return mapper.mapOrThrow(config.get("neo4j"));
    }
}
interface Neo4jConfigModule {
    fun neo4jConfig(
        config: io.koraframework.config.common.Config,
        mapper: io.koraframework.config.common.mapper.ConfigValueMapper<Neo4jConfig>
    ): Neo4jConfig = mapper.mapOrThrow(config.get("neo4j"))
}

The mapper reads fields and applies interface defaults. In the application’s ApplicationGraph, follow PeopleController → Neo4jClient → Neo4jConfig + Neo4jTelemetry → MeterRegistry + Tracer. Java graph output is under build/generated/sources/annotationProcessor/java/main/example/app/; Kotlin graph output is under build/generated/ksp/main/kotlin/example/app/. Generated sources explain wiring; edit input declarations, never generated files.

Run Application

Create compose.yaml in the application root:

services:
  neo4j:
    image: neo4j:5.26-community
    ports:
      - "127.0.0.1:7687:7687"
    environment:
      NEO4J_AUTH: neo4j/localpassword
    healthcheck:
      test: ["CMD", "cypher-shell", "-u", "neo4j", "-p", "localpassword", "RETURN 1"]
      interval: 5s
      timeout: 10s
      retries: 30
      start_period: 20s

These credentials are for the local example. Wait for a healthy database, then create the schema constraint:

docker compose up -d --wait
docker compose exec neo4j cypher-shell -u neo4j -p localpassword "CREATE CONSTRAINT person_name_unique IF NOT EXISTS FOR (p:Person) REQUIRE p.name IS UNIQUE"

Compile and start the application; in PowerShell use gradlew.bat:

./gradlew clean classes
NEO4J_PASSWORD=localpassword ./gradlew run
./gradlew.bat clean classes
$env:NEO4J_PASSWORD = 'localpassword'
./gradlew.bat run

Check Application

In another terminal, call the same resource twice (curl.exe in PowerShell):

curl -i -X PUT http://localhost:8080/people/Alice
curl -i -X PUT http://localhost:8080/people/Alice

Both responses have status 200 and body Alice. Confirm the database contains one matching node:

docker compose exec neo4j cypher-shell -u neo4j -p localpassword "MATCH (p:Person {name: 'Alice'}) RETURN count(p) AS people"

The people value is 1. Check operation metrics:

curl http://localhost:8085/metrics

Look for neo4j_operation_duration_seconds_count with operation="savePerson" and result="success"; after these two requests its count increases by two. Duration sum is also exported; histogram buckets depend on registry configuration. With an exporter connected, find neo4j.savePerson under the HTTP request span.

Check failures and shutdown

Stop the application, set NEO4J_PASSWORD to an incorrect value and run again. Initialization must fail before the HTTP service becomes usable. Restore localpassword and restart. To exercise operation failure, stop the database with docker compose stop neo4j and send another PUT: expect a server error, a warning with the error class and an increased failure timer count. Restore the database with docker compose up -d --wait. Driver retry limits are not an overall request deadline.

Stop the application with Ctrl+C so the graph releases the driver, then run docker compose down. This compose file has no persistent volume; its data is disposable.

Best Practices

  • Keep one driver per graph and one session per operation.
  • Parameterize Cypher and define database constraints for business keys.
  • Keep transaction callbacks safe to retry; materialize results before closing resources.
  • Configure limits for the environment and keep secrets outside source code.
  • Use bounded metric tags and operation names; attach traces through Kora context.
  • Separate integration mechanics from domain error mapping and schema migrations.

Summary

You created a reusable library, generated typed config, connected client lifecycle and telemetry to the graph, and verified a repeatable PUT against a real local database. Java and Kotlin share the same integration implementation.

Key Concepts

  • @ConfigSource generates mapping and a config factory module.
  • Factory parameters describe dependencies; @Module provides reusable graph components.
  • Lifecycle connects startup verification and cleanup to graph ownership.
  • Driver is shared; Session and transaction results stay within an operation.
  • Metrics, logs and traces describe the application operation, including retries.

Troubleshooting

Missing Neo4jConfig or mapper at compilation

Check AP in the library, the library runtime dependency and generated Neo4jConfigModule. A generated source in an unrelated output directory is not sufficient.

Missing Tracer or MeterRegistry

Extend the corresponding modules on Application. A dependency JAR alone does not connect its factories.

Startup connectivity or authentication failure

Run docker compose ps, check health and Bolt port 7687, and compare the application password with NEO4J_AUTH. No HTTP call is needed to trigger this check.

No metrics or exported spans

Enable metrics.enabled, call PUT and inspect port 8085. For traces check both tracing flags and exporter configuration; the driver wrapper does not install an exporter.

Non-unique result or duplicate person nodes

Create the uniqueness constraint before requests. Existing duplicate data must be cleaned before the constraint can be created.

What's Next?

Help

Compare graph wiring with Dependency Injection, config with Configuration, and telemetry with Metrics and Tracing. For driver behavior use the Neo4j Java driver manual.