Skip to content
Robotic builders assembling GraphOperations, Schema DSL, transaction, and coroutine execution model blocks on a 3D workbench
The point of a common API is to reduce repetition, not to hide reality.

Part 1 covered graph storage selection. This part looks at graph-core, the common layer I wanted before the adapters grew too much service-specific code. The goal is simple: service code should speak in vertices, edges, traversal, and algorithms while graph storage adapters handle driver and query details. Without that boundary, every service slowly grows its own tiny graph framework. Most of those frameworks are loved only on the day they are born.

Core API execution flow from domain service to GraphOperations, Schema DSL, execution models, and graph storage adapter
Service code depends on graph concepts; execution and storage selection stay outside the domain flow.

GraphOperations combines GraphSession, GraphVertexRepository, GraphEdgeRepository, and GraphGenericRepository. GraphGenericRepository is the narrower composite for traversal and algorithm operations.

AreaRepresentative methodsWhat service code gets
SessioncreateGraph, dropGraph, graphExistsgraph lifecycle for fixtures or tenant graphs
VertexcreateVertex, createVertices, findVertexById, findVerticesByLabel, updateVertex, deleteVertex, countVerticesnode CRUD without direct driver code
EdgecreateEdge, createEdges, findEdgesByLabel, findEdgesByStartId, findEdgesByEndId, deleteEdgerelationship CRUD and evidence lookup
Traversalneighbors, shortestPath, allPaths, aStarPathshared identity, recommendations, and path queries
AlgorithmpageRank, degreeCentrality, connectedComponents, bfs, dfs, detectCyclesranking, components, traversal visits, and cycle checks
val people = ops.createVertices(
"Person",
listOf(
mapOf("email" to "alice@example.com", "name" to "Alice"),
mapOf("email" to "bob@example.com", "name" to "Bob"),
)
)
ops.createEdges(
"KNOWS",
listOf(BatchEdge(people[0].id, people[1].id, mapOf("since" to 2026)))
)

The default batch methods call the single-row APIs sequentially. If a call fails halfway through, previously created vertices or edges can remain. This is a compatibility baseline, not an atomicity guarantee. Production adapters can override it with native batch writes, but callers still need to verify the transaction boundary of the selected implementation.

Schema is declared with VertexLabel and EdgeLabel, similar to Exposed table objects.

object PersonLabel : VertexLabel("Person") {
val email = string("email")
val name = string("name")
}
object KnowsLabel : EdgeLabel("KNOWS", PersonLabel, PersonLabel) {
val since = integer("since")
}

Schema management is not equally portable across graph storage engines. schemaManager() returns a manager only when the selected adapter implements GraphSchemaManagementOperations. An adapter without that capability throws UnsupportedOperationException instead of reporting a no-op as success. UnsupportedGraphSchemaManager returns empty metadata lists for reads but also rejects schema mutations explicitly.

The transaction DSL exposes vertex and edge CRUD only. Graph lifecycle commands stay outside the block because DDL and auto-commit semantics differ by backend. An implementation that does not provide GraphTransactionalOperations fails instead of silently falling back to auto-commit.

Transaction and batch write sequence for GraphOperations
Transaction blocks group vertex and edge writes, and supported adapters commit or roll back the block.

Merge follows the same rule. Implementations call GraphMergeValidation; mergeVertex requires non-empty match properties, and set properties cannot overwrite identity keys. If the selected storage does not implement GraphMergeOperations, the extension fails rather than pretending a read-then-write fallback is safe under concurrency.

val alice = ops.mergeVertex(
label = "Person",
matchProperties = mapOf("email" to "alice@example.com"),
setProperties = mapOf("name" to "Alice"),
)
ModelGood fit
SyncSingle operations, tests, ordinary blocking drivers
Virtual ThreadsBlocking drivers under higher concurrency
CoroutineKtor or coroutine-native service composition
API model benchmark chart comparing PageRank throughput and BFS latency across sync, virtual-thread, and coroutine models
In this smoke run, synchronous single BFS and coroutine task creation had the lowest measured cost. The workload and driver still determine the choice.

The ApiModelBenchmark smoke run used a shared TinkerGraph fixture, so it excludes network and external database I/O. It ran on GraalVM JDK 25.0.3 with one JMH fork, one warmup iteration, three measurement iterations, one second per iteration, and -prof gc.

ScenarioAPI modelMeanErrorAllocation
PageRank throughputSync138,943.484 ops/s±40,362.14628,451 B/op
PageRank throughputVirtual Thread40,283.460 ops/s±9,678.72029,456 B/op
PageRank throughputCoroutine Flow36,879.554 ops/s±85,084.78129,516 B/op
BFS depth=5Sync4.724 us/op±3.02221,990 B/op
BFS depth=5Virtual Thread18.668 us/op±8.22923,152 B/op
BFS depth=5Coroutine Flow20.244 us/op±11.26823,455 B/op
BFS 100-wayVirtual Thread240.903 us/op±167.5022,318,801 B/op
BFS 100-wayCoroutine async279.828 us/op±329.9422,367,754 B/op
100-way launch/createVirtual Thread51.042 us/op±173.74561,464 B/op
100-way launch/createCoroutine async5.916 us/op±3.12728,373 B/op

These are short smoke results with wide error intervals. They do not establish a release-grade or general performance ranking.

Terminal window
java -jar benchmark/graph-benchmark/build/benchmarks/main/jars/graph-benchmark-main-jmh-*-JMH.jar \
'.*ApiModelBenchmark.*' \
-wi 1 -i 3 -r 1s -w 1s -f 1 \
-prof gc \
-rf json \
-rff docs/benchmark/2026-05-21-api-model-jmh.json

So coroutines are not the “always faster” model. Use them when Kotlin composition, cancellation, and structured concurrency matter. Use virtual threads when a Spring MVC service needs to run blocking graph drivers at higher concurrency. Execution model selection is not a preference poll; it follows the request path and the driver behavior.

Part 3 covers graph-io, which imports and exports graph data as CSV, NDJSON, and GraphML, and explains how to read its benchmark results. OkIO is treated separately as the layer that composes buffering, compression, and asynchronous I/O rather than as a data format.

Comments

Leave a note or reaction with your GitHub account.