21 KiB
Clotho
A Java OGM (Object-Graph Mapper) for ArcadeDB and OrientDB, built on Apache TinkerPop/Gremlin.
Clotho is not a 1:1 node-to-object mapper. A "Clotho object" is a subgraph — a root vertex plus a declared set of edges and adjacent vertices spanning any number of hops. Complex graph traversals and analytics remain the developer's responsibility via raw Gremlin. Clotho handles bounded object loading and persistence.
Requirements
- Java 21+
- Maven 3.9+
- ArcadeDB (recommended) or OrientDB
Database Support
| Database | Support level |
|---|---|
| ArcadeDB | Full — all features including transactions, @Version OCC, and batch auto-save |
| OrientDB | Supported — transactions are degraded (per-request semantics only); @Version OCC still works |
Clotho connects over the Gremlin WebSocket endpoint (DriverRemoteConnection). No JDBC or native driver is needed.
Installation
Spring Boot (recommended)
<dependency>
<groupId>com.binarygolem.clotho</groupId>
<artifactId>clotho-spring-boot-starter</artifactId>
<version>0.1.0-SNAPSHOT</version>
</dependency>
No @Enable* annotation is needed. Autoconfiguration activates automatically when the starter is on the classpath.
Without Spring (core only)
<dependency>
<groupId>com.binarygolem.clotho</groupId>
<artifactId>clotho-core</artifactId>
<version>0.1.0-SNAPSHOT</version>
</dependency>
Configuration
ArcadeDB (application.properties)
# Required — Gremlin WebSocket endpoint
clotho.gremlin.url=ws://localhost:2480/gremlin
# Credentials
clotho.gremlin.username=root
clotho.gremlin.password=playwithdata
# Traversal source name as configured on ArcadeDB
clotho.gremlin.traversal-source=g
# Packages to scan for @VertexType, @EdgeType, and @ClothoRepository classes
clotho.packages=com.example.myapp.model,com.example.myapp.repository
OrientDB (application.properties)
clotho.gremlin.url=ws://localhost:8182/gremlin
clotho.gremlin.username=root
clotho.gremlin.password=secret
clotho.gremlin.traversal-source=g
clotho.packages=com.example.myapp.model,com.example.myapp.repository
OrientDB limitation: when OrientDB is configured with
orient-transactional=false, Clotho detects this at startup and skipsg.tx()calls.openTransactionalSession()and Spring@Transactionalstill compile and run without errors, but they provide no rollback guarantee.@VersionOCC works regardless.
Full configuration reference
| Property | Default | Description |
|---|---|---|
clotho.gremlin.url |
ws://localhost:8182/gremlin |
WebSocket URL for the Gremlin endpoint |
clotho.gremlin.username |
"" |
Database username |
clotho.gremlin.password |
"" |
Database password |
clotho.gremlin.traversal-source |
g |
Traversal source name on the server |
clotho.packages |
(none) | Comma-separated base packages to scan — required |
clotho.default-direction |
OUT |
Default edge direction for @Via fields (OUT, IN, BOTH) |
clotho.unknown-type-behavior |
STRICT |
Behaviour when a graph label has no registered Java class: STRICT (throw), NEAREST_ANCESTOR, GENERIC |
Core Concepts
Schema-first
Clotho does not generate DDL. Your ArcadeDB or OrientDB schema (vertex types, edge types, properties) must exist before the application starts. Use your database's schema management tooling to create and evolve the schema.
Object boundary
Clotho loads a bounded subgraph per object. Fields annotated with @Include and @Via declare which adjacent edges and vertices belong to an object's boundary. Everything within the boundary is fetched in a single Gremlin project() query — no N+1 problem.
Defining Entities
Vertex type
Vertex classes must extend ClothoVertex and carry @VertexType with the ArcadeDB label.
@VertexType("Person")
@Getter
public class Person extends ClothoVertex {
@Property("name")
private @Nullable String name;
@Property("email")
private @Nullable String email;
@Property("age")
private @Nullable Integer age;
// Edges and adjacent vertices that belong to this object's boundary
@Include
@Via("WORKS_AT")
private @Nullable List<WorksAt> jobs;
// Setters MUST call notifyFieldChanged — do NOT use Lombok @Setter on these fields
public void setName(@Nullable String name) {
this.name = name;
notifyFieldChanged("name");
}
public void setEmail(@Nullable String email) {
this.email = email;
notifyFieldChanged("email");
}
public void setAge(@Nullable Integer age) {
this.age = age;
notifyFieldChanged("age");
}
public void setJobs(@Nullable List<WorksAt> jobs) {
this.jobs = jobs;
notifyFieldChanged("jobs");
}
}
Dirty tracking rule: Any field annotated with
@Property,@Include,@OutVertex,@InVertex, or@Versionon aClothoVertexorClothoEdgesubclass must have a manual setter that callsnotifyFieldChanged("fieldName"). Lombok@Settersilently bypasses dirty tracking and must not be used on those fields. Lombok is fine forid,outVertexId, andinVertexIdbecause those are managed by the framework.
Edge type
Edge classes must extend ClothoEdge and carry @EdgeType with the edge label.
@EdgeType("WORKS_AT")
@Getter
public class WorksAt extends ClothoEdge {
public static final String LABEL = "WORKS_AT";
@Property("role")
private @Nullable String role;
@Property("startYear")
private @Nullable Integer startYear;
@OutVertex
private @Nullable Person person; // source vertex
@InVertex
private @Nullable Company company; // target vertex
public void setRole(@Nullable String role) {
this.role = role;
notifyFieldChanged("role");
}
public void setStartYear(@Nullable Integer startYear) {
this.startYear = startYear;
notifyFieldChanged("startYear");
}
public void setCompany(@Nullable Company company) {
this.company = company;
notifyFieldChanged("company");
}
}
Vertex with no included fields
@VertexType("Company")
@Getter
public class Company extends ClothoVertex {
@Property("name")
private @Nullable String name;
@Property("industry")
private @Nullable String industry;
public void setName(@Nullable String name) {
this.name = name;
notifyFieldChanged("name");
}
public void setIndustry(@Nullable String industry) {
this.industry = industry;
notifyFieldChanged("industry");
}
}
Session Usage
ClothoSession is the unit of work. Always use it in a try-with-resources block.
Create
try (ClothoSession session = sessionFactory.openSession()) {
Company acme = new Company();
acme.setName("Acme Corp");
acme.setIndustry("Technology");
WorksAt job = new WorksAt();
job.setRole("Senior Engineer");
job.setStartYear(2020);
job.setCompany(acme);
Person alice = new Person();
alice.setName("Alice Smith");
alice.setEmail("alice@example.com");
alice.setJobs(List.of(job));
session.save(alice); // cascades: alice → job → acme
System.out.println(alice.getId()); // ARID like "#10:0"
}
Load
try (ClothoSession session = sessionFactory.openSession()) {
Person alice = session.load(Person.class, ARID.of("#10:0"));
// alice.getJobs() is already populated — no extra round-trip needed
System.out.println(alice.getName());
}
Update (auto-save on close)
try (ClothoSession session = sessionFactory.openSession()) {
Person alice = session.load(Person.class, id);
alice.setEmail("updated@example.com"); // dirty tracking fires
// No explicit save() needed — session.close() auto-saves all dirty MANAGED entities
}
You can also call session.save(entity) explicitly at any point.
Delete
try (ClothoSession session = sessionFactory.openSession()) {
Person alice = session.load(Person.class, id);
session.delete(alice);
}
No automatic cascade delete: removing an entity from an
@Includecollection does not delete it from the graph. Callsession.delete()explicitly.
Transactions (ArcadeDB)
try (ClothoSession session = sessionFactory.openTransactionalSession()) {
Company newCo = new Company();
newCo.setName("NewCo");
Person bob = new Person();
bob.setName("Bob");
session.save(newCo);
session.save(bob);
} // commits on close; RuntimeException triggers rollback
Repositories
Extend ClothoRepository<T> and annotate with @ClothoRepository. Spring picks it up automatically when it is in a scanned package.
@ClothoRepository
public class PersonRepository extends ClothoRepository<Person> {
public PersonRepository(ClothoSessionFactory factory) {
super(factory);
}
// Built-in: findAll(), findById(), count(), save(), delete(), findWithPagination()
public List<Person> findByName(String name) {
return execute(g().V().has("Person", "name", name));
}
public List<Person> findOlderThan(int minAge) {
return execute(allEntities(), t -> t.has("age", P.gt(minAge)));
}
public List<Person> findEmployed() {
return execute(allEntities(), t -> t.where(__.outE(WorksAt.LABEL)));
}
// Pass a session to get MANAGED entities — mutations auto-save on session close
public List<Person> findCoworkers(Person person, ClothoSession session) {
return execute(session,
g().V(person.getId().asString()).as("self")
.out(WorksAt.LABEL).in(WorksAt.LABEL)
.where(P.neq("self"))
);
}
public long countByIndustryConn(String industry) {
return (long) executeRaw(
g().V().has("Company", "industry", industry)
.in(WorksAt.LABEL).count()
).get(0);
}
}
Built-in repository operations
| Method | Description |
|---|---|
findAll() |
All entities of this type with boundaries populated |
findAll(filter) |
Filtered variant |
findById(ARID) |
Returns null when not found |
count() / count(filter) |
Element count |
findWithPagination(offset, limit) |
Paginated fetch |
save(T) |
Persist entity |
delete(T) / delete(ARID) |
Remove entity |
Spring @Transactional
When using the Spring Boot starter, ClothoTransactionManager is registered automatically. Annotate service methods with Spring's @Transactional as usual.
@Service
@RequiredArgsConstructor
public class HiringService {
private final ClothoSessionFactory sessionFactory;
@Transactional
public void hire(ARID personId, ARID companyId, String role) {
try (ClothoSession session = sessionFactory.openSession()) { // borrows the tx session
Person person = session.load(Person.class, personId);
Company company = session.load(Company.class, companyId);
WorksAt job = new WorksAt();
job.setRole(role);
job.setStartYear(LocalDate.now().getYear());
job.setCompany(company);
person.setJobs(List.of(job));
// session.close() is a no-op when borrowing — Spring commits on method return
}
}
}
OrientDB:
@Transactionalcompiles and runs without errors on OrientDB, but provides no rollback guarantee inorient-transactional=falsemode.
Optimistic Locking (@Version)
Add @Version to a Long field on any entity to enable optimistic concurrency control (OCC). Clotho stores the version as a regular graph property (defaulting to _version), checks it before every write, and increments it on success. A concurrent modification throws OptimisticLockException before any data is written.
@VertexType("Order")
@Getter
public class Order extends ClothoVertex {
@Property("status")
private @Nullable String status;
@Version // stored as "_version" property; override with @Version("myProp")
private @Nullable Long version;
public void setStatus(@Nullable String status) {
this.status = status;
notifyFieldChanged("status");
}
}
int attempts = 0;
while (attempts++ < 3) {
try (ClothoSession session = sessionFactory.openSession()) {
Order order = session.load(Order.class, orderId);
order.setStatus("SHIPPED");
session.save(order); // version pre-flight runs here; auto-save on close also works
break;
} catch (OptimisticLockException e) {
if (attempts == 3) throw e;
// reload fresh state and retry
}
}
Both ArcadeDB and OrientDB support
@Version— it uses a plain graph property, not any database-internal version mechanism.
Custom Version-Checked Updates
session.save() and auto-save handle OCC for field mutations on registered entities. For arbitrary Gremlin write traversals — multi-hop rewrites, conditional graph surgery, updates spanning unregistered types — use session.executeUpdate().
The contract is always the same: read first (so Clotho has the baseline version), declare which elements the traversal touches, then write.
With registered entities
try (ClothoSession session = sessionFactory.openSession()) {
Order order = session.load(Order.class, orderId);
Account acct = session.load(Account.class, accountId);
// Arbitrary traversal — Clotho pre-flights versions before it fires
session.executeUpdate(
g().V(orderId.asString())
.property("status", "SHIPPED")
.sideEffect(__.inV("PLACED_BY").property("orderCount", newCount)),
order, acct // both @Version-checked; if either is stale → OptimisticLockException
);
}
After a successful call, version fields are incremented in both the graph and in memory, and dirty fields are cleared so the session's auto-save on close does not re-apply the same writes.
With unregistered types (VersionGuard)
For graph elements not mapped to a Clotho entity class — GenericVertex, GenericEdge, or any vertex/edge from an unregistered label — use VersionGuard. No entity class declaration is needed.
ARID id = ARID.of("#10:0");
// Read the version from the graph
long ver = (long) executeRaw(g().V(id.asString()).values("_version")).get(0);
// Write with OCC — stale version → OptimisticLockException before the traversal fires
session.executeUpdate(
g().V(id.asString()).property("status", "archived"),
VersionGuard.onVertex(id, ver)
);
// Edge guard
session.executeUpdate(
g().E(edgeId.asString()).property("weight", 0.5),
VersionGuard.onEdge(edgeId, edgeVersion)
);
// Custom version property name (when not using the default "_version")
VersionGuard.onVertex(id, "myVersionProp", 5L)
// Mix entity guards and VersionGuards in one call
session.executeUpdate(
complexTraversal,
registeredOrder, // ClothoEntity — @Version checked
VersionGuard.onVertex(unregisteredId, ver) // raw vertex — VersionGuard checked
);
Race condition behaviour
The version pre-flight is atomic: if any guarded element is stale, OptimisticLockException is thrown before the traversal runs. This means either all guards pass and the traversal executes, or nothing is written.
// Concurrent writers both read version 0
// Writer A succeeds → graph version becomes 1
// Writer B (still holding version 0) → OptimisticLockException, traversal never ran
The retry pattern is the same regardless of whether you use entities or VersionGuard:
int attempts = 0;
while (attempts++ < 3) {
try (ClothoSession session = sessionFactory.openSession()) {
Order order = session.load(Order.class, orderId);
session.executeUpdate(
g().V(orderId.asString()).property("status", "SHIPPED"),
order
);
break;
} catch (OptimisticLockException e) {
if (attempts == 3) throw e;
}
}
executeRaw is for reads only
Use executeRaw() for traversals that return scalars, counts, or property values — things that produce no entity. Never use it to write to versioned elements; it bypasses all version checking entirely.
Lazy Loading
By default, all @Include fields are loaded eagerly when their parent vertex is loaded. Declare fetchType = FetchType.LAZY to skip a field during the initial load. Populate it on demand with session.loadField().
@VertexType("Department")
@Getter
public class Department extends ClothoVertex {
@Property("name")
private @Nullable String name;
@Include(fetchType = FetchType.LAZY)
@Via("MEMBER_OF")
@Direction(TraversalDirection.IN)
private @Nullable List<Person> members; // null until explicitly loaded
// setter...
}
try (ClothoSession session = sessionFactory.openSession()) {
Department dept = session.load(Department.class, deptId);
// dept.getMembers() is null here
session.loadField(dept, "members");
// now populated
System.out.println(dept.getMembers().size());
}
Subgraph Queries
A @SubgraphType is a composite Java object assembled from multiple vertices and edges via a Gremlin select() traversal. It does not extend ClothoVertex or ClothoEdge, and it has no ID of its own.
@SubgraphType
public class EmploymentSummary {
Person person; // field name matches Gremlin as("person")
@Alias("employer") Company company; // Gremlin alias "employer", field name "company"
WorksAt job;
}
@ClothoRepository
public class EmploymentRepository extends ClothoRepository<Person> {
public EmploymentRepository(ClothoSessionFactory factory) {
super(factory);
}
public List<EmploymentSummary> findAll() {
return executeSubgraph(EmploymentSummary.class,
g().V().hasLabel("Person").as("person")
.outE(WorksAt.LABEL).as("job")
.inV().as("employer")
.select("person", "job", "employer")
);
}
}
Inheritance
Single-parent
Simply extend the parent Clotho class.
@VertexType("Manager")
public class Manager extends Employee { ... }
Diamond (multi-parent) inheritance
ArcadeDB supports multi-parent inheritance that Java cannot express. Use additionalParents to declare extra ArcadeDB parent labels.
@VertexType(value = "SeniorManager", additionalParents = {"Auditor"})
public class SeniorManager extends Manager { ... }
Divergent Java/ArcadeDB hierarchy
When the ArcadeDB parent label differs from the Java parent class label, use parentLabel.
@VertexType(value = "ContractEmployee", parentLabel = "Employee")
public class ContractEmployee extends Person { ... }
Without Spring
Construct ClothoSessionFactory manually using its builder.
Cluster cluster = Cluster.build()
.addContactPoint("localhost")
.port(2480)
.credentials("root", "playwithdata")
.create();
DriverRemoteConnection connection = DriverRemoteConnection.using(cluster, "g");
GraphTraversalSource g = AnonymousTraversalSource.traversal().withRemote(connection);
TypeRegistry registry = TypeRegistry.scan("com.example.myapp.model");
ClothoSessionFactory sessionFactory = ClothoSessionFactory.builder()
.g(g)
.registry(registry)
.unknownTypeBehavior(UnknownTypeBehavior.STRICT)
.build();
// Use sessionFactory ...
// Shutdown
sessionFactory.close();
connection.close();
cluster.close();
Important Limitations
- No DDL generation. Clotho is schema-first. Create and migrate your database schema externally.
- Not thread-safe. Sessions bind to the current thread via
ThreadLocal. Use one session per thread (or per virtual thread). - No transparent proxies. Lazy loading requires an explicit
session.loadField()call. There is no bytecode enhancement. - No automatic cascade delete. Removing an entity from an
@Includecollection does not delete it from the graph. Callsession.delete()explicitly. - Manual setters required.
@Property,@Include,@OutVertex,@InVertex, and@Versionfields must use hand-written setters that callnotifyFieldChanged(). Lombok@Settermust not be used on those fields. - OrientDB transactions. In
orient-transactional=falsemode,openTransactionalSession()and@Transactionalwork at per-request granularity only. No multi-request rollback is available.
Further Reading
| Document | Contents |
|---|---|
docs/TYPE_SYSTEM.md |
Entity hierarchy, annotation reference, inheritance, result mapping |
docs/OBJECT_BOUNDARY.md |
@Include/@Via, multi-hop, edge-only inclusion, cycle detection |
docs/ANNOTATIONS.md |
Complete annotation reference with all attributes |
docs/QUERY_MODEL.md |
Repository pattern, @SubgraphType assembly, return type rules |
docs/SPRING_INTEGRATION.md |
Autoconfigure, @Transactional, full application.properties reference |
docs/TRANSACTIONS.md |
Transactional sessions, Spring @Transactional, @Version interaction, ArcadeDB/OrientDB limitations |
docs/ARCHITECTURE.md |
Module breakdown, data flow, session identity map, package structure |