21 KiB
Getting Started with Clotho
Clotho is a Java OGM (Object-Graph Mapper) for ArcadeDB built on Apache TinkerPop/Gremlin. A "Clotho object" is not a single vertex — it is a subgraph: a root vertex plus a declared set of edges and adjacent vertices that are loaded atomically in a single Gremlin traversal.
Prerequisites
| Requirement | Version |
|---|---|
| Java | 21 |
| Maven | 3.9+ |
| ArcadeDB | any recent release |
| Spring Boot | 3.x (optional — Clotho also works standalone) |
Clotho is schema-first: it maps to a schema you have already created in ArcadeDB. It does not generate DDL.
1. Maven dependency
<dependency>
<groupId>com.binarygolem.clotho</groupId>
<artifactId>clotho-spring-boot-starter</artifactId>
<version>0.1.0-SNAPSHOT</version>
</dependency>
Use clotho-core instead if you are not using Spring Boot.
2. ArcadeDB schema
Create the required types in ArcadeDB before starting your application. Use the ArcadeDB console, HTTP API, or any migration tool:
-- Vertices
CREATE VERTEX TYPE Person;
CREATE PROPERTY Person.name STRING;
CREATE PROPERTY Person.email STRING;
CREATE PROPERTY Person.age INTEGER;
CREATE VERTEX TYPE Company;
CREATE PROPERTY Company.name STRING;
CREATE PROPERTY Company.industry STRING;
-- Edge
CREATE EDGE TYPE WORKS_AT;
CREATE PROPERTY WORKS_AT.role STRING;
CREATE PROPERTY WORKS_AT.startYear INTEGER;
3. Spring Boot configuration
# application.properties
clotho.gremlin.url=ws://localhost:8182/gremlin
clotho.gremlin.username=root
clotho.gremlin.password=playwithdata
clotho.gremlin.traversal-source=g
# Packages to scan for @VertexType, @EdgeType, @ClothoRepository
clotho.packages=com.example.myapp
# Whether unknown graph labels cause an exception (STRICT) or map to a generic type (IGNORE_AND_MAP_TO_GENERIC)
clotho.unknown-type-behavior=STRICT
# Default traversal direction for @Include fields that have no @Direction annotation
clotho.default-direction=OUT
4. Define entity classes
Vertex type
@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;
// Eagerly loaded when Person is fetched — single Gremlin project() query
@Include
@Via("WORKS_AT")
private @Nullable List<WorksAt> jobs;
// Manual setters call notifyFieldChanged() so Clotho tracks which fields changed.
// Do NOT use Lombok @Setter on @Property, @Include, @OutVertex, @InVertex, or @Version 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");
}
}
Edge type
@EdgeType("WORKS_AT")
@Getter
public class WorksAt extends ClothoEdge {
@Property("role")
private @Nullable String role;
@Property("startYear")
private @Nullable Integer startYear;
@OutVertex
private @Nullable Person person; // populated when edge is loaded via @Include
@InVertex
private @Nullable Company company; // populated when edge is loaded via @Include
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");
}
}
5. Using the session
ClothoSession is the primary unit of work. Open one per logical operation, then close it.
Sessions are not thread-safe — use one per thread or virtual thread.
@Service
@RequiredArgsConstructor
public class PersonService {
private final ClothoSessionFactory sessionFactory;
/** Create a person with an employment edge. */
public void createPersonWithJob(String name, String role, String companyName) {
try (ClothoSession session = sessionFactory.openSession()) {
Company company = new Company();
company.setName(companyName);
company.setIndustry("Technology");
WorksAt job = new WorksAt();
job.setRole(role);
job.setStartYear(2024);
job.setCompany(company); // @InVertex — resolved during edge creation
Person person = new Person();
person.setName(name);
person.setJobs(List.of(job));
session.save(person);
// cascade: person → job → company (all created in order)
System.out.println("Created: " + person.getId());
}
}
/** Load a person — jobs are populated atomically with the vertex. */
public Person findById(ARID id) {
try (ClothoSession session = sessionFactory.openSession()) {
return session.load(Person.class, id);
// person.getJobs() is already populated — no extra round-trip
}
}
/** Update — only the fields you changed are written. */
public void promoteEmployee(ARID personId, String newRole) {
try (ClothoSession session = sessionFactory.openSession()) {
Person person = session.load(Person.class, personId);
if (person.getJobs() != null && !person.getJobs().isEmpty()) {
person.getJobs().get(0).setRole(newRole);
session.save(person);
// only role is written; no other properties touched
}
}
}
/** Auto-save — dirty MANAGED entities are saved when the session closes. */
public void updateEmailAutoSave(ARID personId, String newEmail) {
try (ClothoSession session = sessionFactory.openSession()) {
Person person = session.load(Person.class, personId);
person.setEmail(newEmail);
// no explicit save() — session.close() persists dirty entities
}
}
/** Delete a person — ArcadeDB also removes all incident edges. */
public void deletePerson(ARID id) {
try (ClothoSession session = sessionFactory.openSession()) {
Person person = session.load(Person.class, id);
session.delete(person);
}
}
}
6. Session lifecycle
What a session is
A ClothoSession is a unit of work. It wraps a graph connection, keeps an identity map
(same ARID → same Java instance within a session), tracks dirty fields, and auto-saves
managed entities when it closes. Sessions are not thread-safe — never share one across
threads.
Short-lived sessions (recommended default)
Open a session with try-with-resources. The session closes — and auto-saves any dirty entities — at the end of the block:
try (ClothoSession session = sessionFactory.openSession()) {
Person alice = session.load(Person.class, id);
alice.setEmail("new@example.com");
} // auto-saves alice here
This is the right pattern for web request handlers, background jobs, and any operation with a clear start and end.
Long-lived sessions
For a simple single-threaded process (a CLI tool, a startup task, a scheduled job that runs on one thread), you can keep one session open for as long as you need:
ClothoSession session = sessionFactory.openSession();
// ... do many things ...
session.close(); // or call in a shutdown hook
The trade-off is that the identity map grows indefinitely — every entity loaded stays
in memory until the session closes. For long-running processes that load many entities,
call session.clearCache() periodically to release them without closing the session:
// After a large batch operation — drop all cached instances from memory.
// The next load() for any of those IDs will re-fetch from the graph.
session.clearCache();
clearCache() does NOT flush dirty fields first. Call session.save(entity) explicitly
for anything you want to keep before clearing.
Do you ever need more than one session?
Multiple threads: yes, one per thread.
Sessions bind to the current thread via a ThreadLocal. Start a new session on each
thread (or virtual thread) and close it when the thread's work is done.
// With virtual threads (Java 21+):
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
executor.submit(() -> {
try (ClothoSession session = sessionFactory.openSession()) {
// ... work on this virtual thread ...
}
});
}
Multiple isolated operations on the same thread: open them sequentially.
Each openSession() call replaces the previous thread-local binding, so open only one
session at a time on any given thread. Close the first before opening the second:
// First operation
try (ClothoSession s1 = sessionFactory.openSession()) {
session.load(Person.class, id);
}
// Second operation — clean identity map, independent unit of work
try (ClothoSession s2 = sessionFactory.openSession()) {
session.load(Person.class, id); // fresh load, not the cached s1 instance
}
Spring Boot — session-per-request:
The clotho-spring-boot-starter provides a ClothoSessionFactory bean. Wire it into
your @Service classes and open a session per method (as shown in section 5), or use a
request-scoped session bean if you want one session for the lifetime of an HTTP request:
@Bean
@RequestScope
public ClothoSession requestSession(ClothoSessionFactory factory) {
return factory.openSession();
}
Declare the bean as @RequestScope and Spring will open it on the first injection and
call close() (triggering auto-save) at the end of the request.
Transactional sessions
By default, each Gremlin traversal is auto-committed immediately by ArcadeDB. If a multi-step save fails midway, the graph is left in a partial state. Use a transactional session to wrap all operations in a single atomic transaction:
try (ClothoSession session = sessionFactory.openTransactionalSession()) {
session.save(alice);
session.save(acme);
} // ← tx.commit() fires here; any exception triggers tx.rollback()
With the Spring Boot starter you can use @Transactional instead of managing the
session manually. ClothoTransactionManager is registered automatically:
@Service
@RequiredArgsConstructor
public class PersonService {
private final ClothoSessionFactory sessionFactory;
@Transactional
public void promote(ARID personId) {
try (ClothoSession session = sessionFactory.openSession()) { // borrows tx session
Person p = session.load(Person.class, personId);
p.setRole("SENIOR");
} // session close is a no-op; Spring tx manager commits on method return
}
}
Inside a @Transactional method, openSession() detects the existing transaction and
borrows its session rather than opening a new one. The transaction is committed (or rolled
back) when the outermost @Transactional boundary exits.
See docs/TRANSACTIONS.md for a complete guide including nested
transactions, multi-manager setups, and the interaction with @Version optimistic locking.
Summary
| Scenario | Pattern |
|---|---|
| Web request / discrete operation | try (ClothoSession s = factory.openSession()) |
| Atomic multi-step write | try (ClothoSession s = factory.openTransactionalSession()) |
Spring @Transactional |
Annotate service method; use openSession() inside |
| Single-threaded background process | Open once; call clearCache() after big batches |
| Multi-threaded / virtual threads | One session per thread, opened and closed on that thread |
| Spring HTTP request scope | @Bean @RequestScope ClothoSession |
7. Repository pattern
Define a repository by extending ClothoRepository<T> and annotating with @ClothoRepository.
The Spring Boot starter registers it as a bean automatically.
@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> findByAgeGreaterThan(int minAge) {
return execute(allEntities(), t -> t.has("age", P.gt(minAge)));
}
public List<Person> findEmployed() {
// people who have at least one outgoing WORKS_AT edge
return execute(allEntities(), t -> t.where(__.outE("WORKS_AT")));
}
public long countByIndustry(String industry) {
// raw result — no Clotho mapping
return this.<Long>executeRaw(
g().V().has("Company", "industry", industry).count()
).get(0);
}
}
Inject the repository in your service:
@Service
@RequiredArgsConstructor
public class PersonService {
private final PersonRepository personRepository;
public List<Person> listSeniorEngineers() {
return personRepository.findByAgeGreaterThan(35);
}
public List<Person> listAll() {
return personRepository.findAll(); // inherited
}
public List<Person> listPage(long page, long pageSize) {
return personRepository.findWithPagination(page * pageSize, pageSize); // inherited
}
}
8. Ad-hoc traversals from a loaded entity
Repository methods can traverse any edge in the graph — there is no requirement that the
edge be declared as @Include on the entity class. Start from the loaded entity's ID and
write a Gremlin traversal as usual.
Example: find co-workers — people who share a company via WORKS_AT, but where
WORKS_AT is not declared as @Include on Person:
@ClothoRepository
public class PersonRepository extends ClothoRepository<Person> {
// ...
public List<Person> findCoworkers(Person person) {
return execute(
g().V(person.getId().asString()).as("self")
.out("WORKS_AT") // Person → Company
.in("WORKS_AT") // Company → all Persons at that company
.where(P.neq("self")) // exclude the starting person
);
}
}
Call it with any already-loaded Person:
try (ClothoSession session = sessionFactory.openSession()) {
Person alice = session.load(Person.class, aliceId);
List<Person> coworkers = personRepository.findCoworkers(alice);
// returned Person objects have their @Include fields populated (jobs, etc.)
}
Key point: execute() maps every resulting vertex to its most-specific registered
Clotho type and calls populateAll() on the result list, so the returned objects are fully
hydrated (all eager @Include fields present) — regardless of how the traversal was built.
The object boundary and the query traversal are completely independent concerns.
Tracked results — execute with a session
By default, execute() returns detached entities: they are not registered in any
session and mutations are silently ignored. Pass a ClothoSession to get back MANAGED
entities instead — dirty-field tracking fires, and the session auto-saves any changes when
it closes:
public List<Person> findCoworkers(Person person, ClothoSession session) {
return execute(session,
g().V(person.getId().asString()).as("self")
.out("WORKS_AT").in("WORKS_AT")
.where(P.neq("self"))
);
}
try (ClothoSession session = sessionFactory.openSession()) {
Person alice = session.load(Person.class, aliceId);
List<Person> coworkers = personRepository.findCoworkers(alice, session);
// Mutations are tracked — no explicit save() needed
coworkers.get(0).setEmail("new@example.com");
} // ← session.close() auto-saves the dirty co-worker
session.register(vertex) is the underlying mechanism — it recursively marks the vertex
and all entities reachable through its @Include boundary as MANAGED and adds them to the
session's identity map. You can call it directly if you need finer control.
For raw scalar results (counts, aggregates), use executeRaw():
public long countCoworkers(Person person) {
return this.<Long>executeRaw(
g().V(person.getId().asString())
.out("WORKS_AT").in("WORKS_AT")
.where(P.neq("self"))
.count()
).get(0);
}
9. Object boundary with @Include
@Include @Via("EDGE_LABEL") declares that a field is part of this object's boundary.
All @Include fields on a vertex are loaded in a single atomic project() traversal —
no N+1 queries, no risk of seeing inconsistent state between fields.
@VertexType("Department")
@Getter
public class Department extends ClothoVertex {
@Property("name") private @Nullable String name;
// EAGER (default): loaded with the vertex, always present
@Include
@Via("HAS_MANAGER")
private @Nullable Person manager;
// LAZY: skipped on initial load; call session.loadField() when needed
@Include(fetchType = FetchType.LAZY)
@Via("HAS_MEMBER")
private @Nullable List<Person> members;
}
Load a lazy field on demand:
try (ClothoSession session = sessionFactory.openSession()) {
Department dept = session.load(Department.class, deptId);
// dept.getMembers() is null here — field was declared LAZY
session.loadField(dept, "members");
// dept.getMembers() is now populated
log.info("Members: {}", dept.getMembers().size());
}
10. Optimistic locking with @Version
ArcadeDB maintains a @version counter on every record that increments on each write.
Clotho reads it on load and includes it in update traversals, so concurrent writes are detected.
@VertexType("Order")
@Getter
public class Order extends ClothoVertex {
@Property("status") private @Nullable String status;
@Version // maps to ArcadeDB's built-in @version property
private @Nullable Long version;
public void setStatus(@Nullable String status) {
this.status = status;
notifyFieldChanged("status");
}
}
If two sessions load the same Order and both try to save a change, the second save throws
OptimisticLockException:
try (ClothoSession session = sessionFactory.openSession()) {
Order order = session.load(Order.class, orderId);
order.setStatus("SHIPPED");
session.save(order);
} catch (OptimisticLockException e) {
log.warn("Concurrent update detected for {} at version {}",
e.getId(), e.getExpectedVersion());
// retry or report conflict
}
11. Subgraph queries
Use executeSubgraph() when a traversal returns multiple entity types in a single result row.
@SubgraphType
public class EmployeeProfile {
Person person; // field name matches .as("person") in the Gremlin query
@Alias("employer")
Company company; // @Alias maps the field to a different Gremlin alias
}
@ClothoRepository
public class EmployeeRepo extends ClothoRepository<Person> {
public EmployeeRepo(ClothoSessionFactory factory) { super(factory); }
public List<EmployeeProfile> findProfiles() {
return executeSubgraph(EmployeeProfile.class,
g().V().hasLabel("Person").as("person")
.out("WORKS_AT").in("WORKS_AT").as("company") // traverse to Company
.select("person", "company"));
}
}
12. Type inheritance
Define a class hierarchy in Java and tell Clotho which ArcadeDB parent types to wire:
@VertexType(value = "Employee", additionalParents = {"Person"})
public class Employee extends Person {
@Property("employeeNumber") private @Nullable String employeeNumber;
// ...
}
When Clotho loads a vertex labeled Employee, it maps it to Employee.class.
When it loads a vertex labeled Person (no Employee subtype), it maps it to Person.class.
This "most specific type wins" rule is enforced by the TypeRegistry.
Reference docs
| Document | Contents |
|---|---|
docs/TYPE_SYSTEM.md |
Entity hierarchy, inheritance, label resolution |
docs/OBJECT_BOUNDARY.md |
@Include/@Via, multi-hop, edge-only inclusion, cycle detection |
docs/ANNOTATIONS.md |
Complete annotation reference |
docs/QUERY_MODEL.md |
Repository pattern, executeSubgraph, result mapping |
docs/SPRING_INTEGRATION.md |
Autoconfigure, properties reference |
docs/ARCHITECTURE.md |
Module breakdown, session lifecycle, data flow |
The clotho-dev-app module contains a runnable FeatureDemo that exercises all the patterns
described here against a live ArcadeDB instance.