687 lines
21 KiB
Markdown
687 lines
21 KiB
Markdown
# 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
|
|
<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:
|
|
|
|
```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<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
|
|
|
|
```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<T>` and annotating with `@ClothoRepository`.
|
|
The Spring Boot starter registers it as a bean automatically.
|
|
|
|
```java
|
|
@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:
|
|
|
|
```java
|
|
@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`:
|
|
|
|
```java
|
|
@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`:
|
|
|
|
```java
|
|
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:
|
|
|
|
```java
|
|
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"))
|
|
);
|
|
}
|
|
```
|
|
|
|
```java
|
|
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()`:
|
|
|
|
```java
|
|
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.
|
|
|
|
```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<Person> 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<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:
|
|
|
|
```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.
|