clotho/docs/GETTING_STARTED.md
2026-05-15 00:09:01 +02:00

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.

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.