# 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
```xml
com.binarygolem.clotho
clotho-spring-boot-starter
0.1.0-SNAPSHOT
```
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:
```sql
-- 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
```properties
# 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
```java
@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 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 jobs) {
this.jobs = jobs;
notifyFieldChanged("jobs");
}
}
```
### Edge type
```java
@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.
```java
@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:
```java
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:
```java
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:
```java
// 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.
```java
// 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:
```java
// 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:
```java
@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:
```java
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:
```java
@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](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` and annotating with `@ClothoRepository`.
The Spring Boot starter registers it as a bean automatically.
```java
@ClothoRepository
public class PersonRepository extends ClothoRepository {
public PersonRepository(ClothoSessionFactory factory) {
super(factory);
}
// Built-in: findAll(), findById(), count(), save(), delete(), findWithPagination()
public List findByName(String name) {
return execute(g().V().has("Person", "name", name));
}
public List findByAgeGreaterThan(int minAge) {
return execute(allEntities(), t -> t.has("age", P.gt(minAge)));
}
public List 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.executeRaw(
g().V().has("Company", "industry", industry).count()
).get(0);
}
}
```
Inject the repository in your service:
```java
@Service
@RequiredArgsConstructor
public class PersonService {
private final PersonRepository personRepository;
public List listSeniorEngineers() {
return personRepository.findByAgeGreaterThan(35);
}
public List listAll() {
return personRepository.findAll(); // inherited
}
public List 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`:
```java
@ClothoRepository
public class PersonRepository extends ClothoRepository {
// ...
public List 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`:
```java
try (ClothoSession session = sessionFactory.openSession()) {
Person alice = session.load(Person.class, aliceId);
List 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:
```java
public List 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"))
);
}
```
```java
try (ClothoSession session = sessionFactory.openSession()) {
Person alice = session.load(Person.class, aliceId);
List 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()`:
```java
public long countCoworkers(Person person) {
return this.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.
```java
@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 members;
}
```
Load a lazy field on demand:
```java
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.
```java
@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`:
```java
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.
```java
@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
}
```
```java
@ClothoRepository
public class EmployeeRepo extends ClothoRepository {
public EmployeeRepo(ClothoSessionFactory factory) { super(factory); }
public List 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:
```java
@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.