Fluxzero 2.0: deep dive
The overview explains why Fluxzero 2.0 separates relationships, atomic changes, persistence and loading. This chapter follows those choices into the implementation: which state an operation reads, what its commit protects, how related Models retain their identity, and which history a Graph can reconstruct.
Start with the short comparison below, or go straight to the Project/Task declarations and follow the worked examples. Later sections cover Graph responses, materialized search views, operational settings and migration.
The Java and Kotlin examples form a Project/Task domain. Configuration variants replace earlier declarations; they are not extra classes to register alongside them.
Coming from Fluxzero 1.x
Section titled “Coming from Fluxzero 1.x”loadEntity(taskId) already let a Member handle its own behavior. The important distinction is what happened after that lookup: the SDK still loaded and updated its containing Aggregate. An addressable entity was not an independent persistence boundary.
This historical comparison uses an embedded Task, before the independent Model declarations used in the rest of the chapter:
@Aggregaterecord Project(@EntityId String projectId, @Member List<Task> tasks) {}
@Withrecord Task(@EntityId String taskId, boolean completed) { @Apply Task apply(CompleteTask command) { return withCompleted(true); }}
record CompleteTask(String taskId) {}
@HandleCommandvoid complete(CompleteTask command) { Fluxzero.loadEntity(command.taskId(), Task.class).assertAndApply(command);}@Aggregatedata class Project(@EntityId val projectId: String, @field:Member val tasks: List<Task>)
data class Task(@EntityId val taskId: String, val completed: Boolean) { @Apply fun apply(command: CompleteTask) = copy(completed = true)}
data class CompleteTask(val taskId: String)
@HandleCommandfun complete(command: CompleteTask) { Fluxzero.loadEntity(command.taskId, Task::class.java).assertAndApply(command)}Task now owns the behavior for completing a Task. It still does not own its persistence: the lookup finds Project, reconstructs it, selects Task, and applies the replacement back through Project. Task’s events still live under that root, as do its snapshot representation, cached state and searchable document when those are enabled.
The API distinction behind the overview’s stream comparison is:
| Handle or declaration | State boundary | Relationship context |
|---|---|---|
Entity<T> for an embedded @Member | Its containing Aggregate or Model | The member’s position inside that stored value |
Entity<T> for an independent @Model | That Model’s own persisted state | Parent references do not embed parent state |
Graph<T> | A view over independently stored Models | Navigation and the selected read boundary |
The distinction in 2.0 is between an Entity<T> inside an Aggregate boundary and an independently persisted @Model, with Graph<T> supplying its relationship and read context. Existing @Apply, @AssertLegal, @InterceptApply and fixture patterns remain applicable. @Member remains available for state deliberately embedded in one root; it is no longer necessary merely because a Task appears inside a Project view. For independent Models, an embedded member ID alone does not discover its owning root: address that Model explicitly.
Adopting the new boundary is a data migration, not an annotation rename. The migration section defines replay/adoption, read cutover and write ownership. The overview shows the programming model in action; the rest of this chapter defines its contracts.
The examples below build on the overview. They start with the Model declarations, then follow relationships, reads and changes through the SDK.
Model the lifecycle, then choose storage
Section titled “Model the lifecycle, then choose storage”Models can be event-sourced and/or document-backed. Choose persistence per Model, after its domain boundary is clear.
Start with @Model. The default is event sourcing with caching, without a public search document or periodic
snapshots. Add those representations when a concrete read or replay requirement calls for them.
The following Project/Task declarations use explicit Model names and validation, and include the typed ID definitions. The Java and Kotlin tabs show equivalent alternatives, not classes to combine.
Snippets omit imports and use the normal SDK project setup. Java examples use Lombok @With for immutable updates; Kotlin examples use copy. Later variants explicitly replace earlier declarations.
@Model(name = "project")record Project(@EntityId ProjectId projectId, ProjectDetails details) {}
record ProjectDetails(@NotBlank String name) {}
@Model(name = "task")@Withrecord Task(@EntityId TaskId taskId, @Parent(pathInParent = "tasks") ProjectId projectId, TaskDetails details, boolean completed) {}
record TaskDetails(@NotBlank String title) {}
static class ProjectId extends Id<Project> { public ProjectId(String value) { super(value); }}
static class TaskId extends Id<Task> { public TaskId(String value) { super(value); }}@Model(name = "project")data class Project(@EntityId val projectId: ProjectId, val details: ProjectDetails)
data class ProjectDetails(@field:NotBlank val name: String)
@Model(name = "task")data class Task( @EntityId val taskId: TaskId, @Parent(pathInParent = "tasks") val projectId: ProjectId, val details: TaskDetails, val completed: Boolean)
data class TaskDetails(@field:NotBlank val title: String)class ProjectId(value: String) : Id<Project>(value)class TaskId(value: String) : Id<Task>(value)Descriptive business data belongs in cohesive value objects—even if the first version contains only a name.
Identity, relationships and simple status can remain on the Model. ProjectDetails is an ordinary value:
it needs neither @Model nor @Member, and should not become a bucket for unrelated state.
The default logical Model name is the simple class name: Project, regardless of its package. Here we explicitly choose project and task. An explicit name can stay unchanged when the class itself is renamed. Logical Model names are distinct from serialized Java type names and from individual Model IDs.
ProjectId("launch") has storage ID launch; TaskId("task-a") has storage ID task-a. These example ID classes add no prefix. IDs must be unique in the Model repository: the Java ID type alone does not create a separate storage namespace. Model discovery uses the SDK annotation processor; @RegisterType is not required for it.
Commands can own their behavior
Section titled “Commands can own their behavior”A command with an applicable Model @Apply can be handled automatically. You do not need a service method
whose only job is to load the right object and forward the call. In our example, creating a Project and
completing a Task can be expressed directly on their commands:
record CreateProject(@NotNull ProjectId projectId, @NotNull @Valid ProjectDetails details) { @Apply Project apply() { return new Project(projectId, details); }}
record CompleteTask(TaskId taskId) { @Apply Task apply(Task task) { return task.withCompleted(true); }}data class CreateProject( @field:NotNull val projectId: ProjectId, @field:NotNull @field:Valid val details: ProjectDetails) { @Apply fun apply() = Project(projectId, details)}
data class CompleteTask(val taskId: TaskId) { @Apply fun apply(task: Task) = task.copy(completed = true)}Publish the command as usual:
Fluxzero.sendCommandAndWait(new CompleteTask(taskId));Fluxzero.sendCommandAndWait<Any?>(CompleteTask(taskId))For this example, the SDK carries the operation through the following steps:
- Receive
CompleteTask. - Resolve the Task and any Models/Graphs requested by the operation.
- Run assertions and applies against the attempt’s read state.
- Check conflicts and atomically commit the Model changes.
- Publish the applied payload as an event; event handlers see that committed transition.
Here CompleteTask is both the command payload and, after a successful change, the event payload. You do
not need a second handler just to translate it into another message. The command expresses the request;
the published event records that it was applied. The resulting Task is state, not the event itself.
With the default IF_MODIFIED publication policy, completing an already-completed Task produces no new event.
The CreateProject factory is create-only. A required current-Model parameter, as in CompleteTask, expects
existing state; a nullable one explicitly allows an upsert. Returning null logically deletes the target.
Behavior may also live on the Model itself. Keep business rules with their natural owner; the SDK supports payload-side and Model-side handling rather than imposing a service layer for every action.
The normal command path uses the registered command’s applicable Model apply. Explicit handlers can invoke the same pipeline with Fluxzero.assertAndApply(command). See registration and command ownership after the feature examples for Spring discovery, consumer configuration and explicit-handler alternatives.
Inject Models from message properties
Section titled “Inject Models from message properties”A message already says which objects it concerns. Its handler can ask for those Models directly instead
of repeating the lookup. This works in command, event and query handlers, as well as Model assertions,
interceptors and applies. Request Task for its state or Graph<Task> for its surrounding relationships.
The SDK looks for a message property matching the Model’s @EntityId property, or otherwise a unique
typed Id<Task>. It can also follow stored parent relationships to supply a Project, grandparent or further
ancestor. This query needs only a Task ID:
record GetTask(TaskId taskId) implements Request<TaskSummary> { @HandleQuery TaskSummary handle(Task task, Project project) { return new TaskSummary(task.details().title(), project.details().name(), task.completed()); }}
record TaskSummary(String title, String projectName, boolean completed) {}data class GetTask(val taskId: TaskId) : Request<TaskSummary> { @HandleQuery fun handle(task: Task, project: Project) = TaskSummary(task.details.title, project.details.name, task.completed)}
data class TaskSummary(val title: String, val projectName: String, val completed: Boolean)The Task is addressed by taskId; the Project comes from its stored parent relationship. The Task parameter is not needed to enable Project injection: handle(Project project) would resolve the same parent from the query’s taskId. No sibling Tasks need to be loaded. Injecting a Model reads it; an apply must return a change before that Model becomes a write target.
When a message refers to two Models of the same type, @Association makes the intended binding explicit:
record CompareProjects(ProjectId leftId, ProjectId rightId) implements Request<List<String>> { @HandleQuery List<String> handle(@Association("leftId") Project left, @Association("rightId") Project right) { return List.of(left.details().name(), right.details().name()); }}data class CompareProjects(val leftId: ProjectId, val rightId: ProjectId) : Request<List<String>> { @HandleQuery fun handle( @Association("leftId") left: Project, @Association("rightId") right: Project ): List<String> = listOf(left.details.name, right.details.name)}An association also helps when the property has a different name and no typed ID to identify its target:
record GetProjectName(String reference) implements Request<String> { @HandleQuery String handle(@Association("reference") Project project) { return project.details().name(); }}data class GetProjectName(val reference: String) : Request<String> { @HandleQuery fun handle(@Association("reference") project: Project): String = project.details.name}Here reference = "launch" selects Project launch. The same parameter annotation works in message handlers, @AssertLegal, @Apply and @InterceptApply.
@Association can also select a relationship path. Given a message containing only a Task ID, @Association("tasks") Project project follows the Task’s @Parent(pathInParent = "tasks") relationship. This is useful when the child has several parents of the same type under different paths. Use the path that identifies the intended relation; do not rely on parameter order to disambiguate parents.
Which revision gets injected depends on the handler. A query reads current state; a Model event reads the state of that event; an assertion or apply reads its mutation attempt’s state. Read boundaries explains each with a concrete timeline.
One-to-one without one shared lifecycle
Section titled “One-to-one without one shared lifecycle”Sometimes a Model extends another one without becoming part of its persistence boundary. For example, add an operational-status companion to Project: one per Project, but independently updated and loaded.
@Modelrecord ProjectStatus( @EntityId(prefix = "status-") @Parent(pathInParent = "status") ProjectId projectId, boolean online) {}
record PutProjectStatus(ProjectId projectId, boolean online) { @Apply ProjectStatus apply(Project project, @Nullable ProjectStatus current) { return new ProjectStatus(projectId, online); }}@Modeldata class ProjectStatus( @EntityId(prefix = "status-") @Parent(pathInParent = "status") val projectId: ProjectId, val online: Boolean)
data class PutProjectStatus(val projectId: ProjectId, val online: Boolean) { @Apply fun apply(project: Project, @Nullable current: ProjectStatus?): ProjectStatus = ProjectStatus(projectId, online)}The same property supplies identity and the parent relationship. With the earlier ProjectId("launch"),
the Project’s storage ID is launch; its companion’s is status-launch. No second ID needs
to travel through the command. Use jakarta.annotation.Nullable here: it makes this an intentional upsert,
while the required Project parameter checks that the parent exists. A factory without a current-state
parameter is create-only and rejects a duplicate.
Load the companion with Fluxzero.loadModel(projectId, ProjectStatus.class); loading only projectId selects
the Project. The status Graph path is still a collection, with at most one member from this identity scheme.
Removing the companion leaves its parent intact; default parent-owned deletion removes it with the Project.
For example, an automation application can request that a lamp turn on, while a device integration reports whether the physical lamp is actually on. The reported state belongs to that integration and changes on its own schedule. It can be a companion Model in another application, even when the application owning the lamp does not know the companion’s Java class. The shared relationship still connects them; typed value reads require the local state contract, while metadata navigation can inspect unknown children.
The one-per-Project limit here follows from using the same Project ID plus a fixed status- prefix, so the identity
itself prevents a second companion. For a relationship whose child IDs do not provide that guarantee,
@Parent(maxChildren = 1) offers the equivalent membership check while keeping each child independently identified.
More than one hierarchy
Section titled “More than one hierarchy”The overview’s Task belongs to a Project and a Sprint. Both can discover the same Task, but that leaves a separate question: should deleting either parent also delete the Task?
For example, suppose Project owns its Tasks, while Sprint only groups them for planning. Replace the earlier Task declaration with this variant:
@Model(name = "task")@Withrecord Task(@EntityId TaskId taskId, @Parent(pathInParent = "tasks") ProjectId projectId, @Parent(pathInParent = "tasks", deleteOnParentDeletion = false) SprintId sprintId, TaskDetails details, boolean completed) {}
@Model(name = "sprint")record Sprint(@EntityId SprintId sprintId) {}
static class SprintId extends Id<Sprint> { public SprintId(String value) { super(value); }}@Model(name = "task")data class Task( @EntityId val taskId: TaskId, @Parent(pathInParent = "tasks") val projectId: ProjectId, @Parent(pathInParent = "tasks", deleteOnParentDeletion = false) val sprintId: SprintId, val details: TaskDetails, val completed: Boolean)
@Model(name = "sprint")data class Sprint(@EntityId val sprintId: SprintId)
class SprintId(value: String) : Id<Sprint>(value)Read the arrows from Task to each parent. Both relationships are navigable; only the Project relationship owns Task’s lifetime. The two deletion outcomes start from the same initial state:
| Operation | Project | Task | Sprint |
|---|---|---|---|
| Delete Sprint | Remains | Remains, with its Project relationship | Deleted |
| Delete Project | Deleted | Deleted by the ownership cascade | Remains, without this Task |
Deleting Sprint removes its active relationship to Task. It does not rewrite Task’s stored sprintId to null; a current Graph no longer exposes that parent. If Task must record a new planning assignment, make that an explicit change. With the default deleteOnParentDeletion = true on both edges instead, deleting either parent would delete Task, but would still leave the other parent alive.
The same declaration can express three common relationship checks. required = true requires a non-empty reference
to a parent that exists in the final commit state. maxChildren = 5 limits distinct Tasks in one parent/type/path
scope. preventParentDeletion = true blocks logical deletion while that relation survives and disables cascade on the
edge. These are SDK shorthands over the same parent and membership reads used by a Graph assertion, so concurrent
changes follow normal conflict and retry handling; they are not stored Runtime policies. Use the
Model persistence guide
for final-state, deletion and multi-writer boundaries.
Relationship discovery, cascade ownership and output placement are separate settings:
| Declaration on an ID field | Graph edge | Deleted with parent | Placement in composed output |
|---|---|---|---|
| Plain typed ID | No | No | None |
@Parent | Yes | Yes | No named child path |
@Parent(deleteOnParentDeletion = false) | Yes | No | No named child path |
@Parent(pathInParent = "tasks") | Yes | Yes | tasks |
@Parent(pathInParent = "tasks", deleteOnParentDeletion = false) | Yes | No | tasks |
One relationship can also contain several parents of the same kind. A typed Collection, List or Set registers
one edge per distinct parent ID, using the annotation’s policies for every edge. Adding or removing IDs changes those
edges atomically. A null or empty collection has no parents; null elements, raw or nested collections, arrays and maps
are rejected. Collection order does not define child ordering. Deleting any owning parent deletes the child, so shared,
independently retained state should use deleteOnParentDeletion = false. Parent-scoped identity still requires one
unambiguous parent scope.
To omit a branch only from ancestor search Graphs, declare
@Parent(pathInParent = "tasks", propagateSearch = false) (the same syntax works in Kotlin). This excludes the Task and
its subtree in NONE, ASYNC and AWAIT and stops ancestor Graph-document notifications for that branch. Its own
@Model(searchable = true) still enables independent Task search. Ordinary Graph navigation, event handling,
response placement and cascade deletion are unchanged. Existing stored relations require an explicit policy
migration before rebuilding projections; an annotation or value-only update alone does not migrate them.
Recursive Model types are allowed; cycles between actual Model identities are rejected. Multiple candidate parents of the same type require an unambiguous selection; use the association rules.
These are logical-deletion rules. Physical erasure has its own explicit scope, including retained relationship lineage. The remaining examples return to the original Project-only Task declaration.
Building with Models and Graphs
Section titled “Building with Models and Graphs”1. Business rules that span a Graph
Section titled “1. Business rules that span a Graph”Suppose our example application allows at most five Tasks per Project. Creating a Task must then check the Project and its existing Tasks:
record AddTask(@NotNull TaskId taskId, @NotNull ProjectId projectId, @NotNull @Valid TaskDetails details) { @AssertLegal void checkCapacity(Graph<Project> project) { if (project.children(Task.class).size() >= 5) { throw ProjectErrors.capacityReached; } }
@Apply Task apply(Project project) { return new Task(taskId, projectId, details, false); }}data class AddTask( @field:NotNull val taskId: TaskId, @field:NotNull val projectId: ProjectId, @field:NotNull @field:Valid val details: TaskDetails) { @AssertLegal fun checkCapacity(project: Graph<Project>) { if (project.children(Task::class.java).size >= 5) { throw ProjectErrors.capacityReached } }
@Apply fun apply(project: Project) = Task(taskId, projectId, details, false)}Five is this example’s business limit. ProjectErrors.capacityReached is an application-defined functional exception; the examples omit the error declarations. Use the same domain-error convention for the other refusals below.
For this simple count-only rule, the Task’s parent declaration could instead use
@Parent(pathInParent = "tasks", required = true, maxChildren = 5). The SDK shorthand performs the same parent and
membership reads and joins them to the ordinary conflict boundary. Keep the explicit assertion when the decision needs
the custom ProjectErrors message or depends on other Project or Task state.
Follow one operation from read to commit
Section titled “Follow one operation from read to commit”Suppose Project Launch has three Tasks. We want to add Task A and Task B together, using the AddTask rule above for each addition. Compose the two commands in their intended order:
record AddTwoTasks(ProjectId projectId, TaskId firstTaskId, TaskId secondTaskId) { @InterceptApply List<AddTask> expand() { return List.of( new AddTask(firstTaskId, projectId, new TaskDetails("Task A")), new AddTask(secondTaskId, projectId, new TaskDetails("Task B"))); }}data class AddTwoTasks( val projectId: ProjectId, val firstTaskId: TaskId, val secondTaskId: TaskId) { @InterceptApply fun expand(): List<AddTask> = listOf( AddTask(firstTaskId, projectId, TaskDetails("Task A")), AddTask(secondTaskId, projectId, TaskDetails("Task B")) )}Sending this registered command opens one Model operation. The list returned by @InterceptApply supplies ordered actions within it; it does not send two new commands. Calling Fluxzero.assertAndApply(new AddTwoTasks(...)) explicitly enters the same pipeline. Two separate sendCommand calls would be two operations, even inside one handler.
| Step | What this attempt sees | What has been stored? |
|---|---|---|
| Assert AddTask A | Three existing children: room for A | Nothing new |
| Apply AddTask A | Project value is injected; Task A and its parent relation are staged | Nothing new |
| Assert AddTask B | Four children, including staged Task A: room for B | Nothing new |
| Apply AddTask B | Task B and its parent relation are staged | Nothing new |
| Validate and commit | Check the consumed Project state and membership against concurrent changes | Both new Tasks and relationships commit together |
The first apply’s result is visible to the second action inside the operation. It is not visible to other operations until commit. Existing Task values are not needed: children(Task.class).size() inspects membership. childModels(Task.class) would also load their values and add those values to the decision’s read dependencies.
Now start with four existing Tasks. A passes and is staged; B sees five and fails its assertion. The entire operation is discarded, including A. Project still has its original four Tasks. Assertions run on the resulting AddTask actions, so they protect each addition; a rule placed only on the intercepted wrapper is not a substitute.
If another operation changes the consumed state before commit, the default conflict policy starts a fresh attempt. Both additions are evaluated again against that attempt’s state; no partially staged work from the previous attempt is committed. The next section follows that conflict path and its retry limit.
The operation protects the Models and relationships it writes, together with the reads its decision consumed. It does not include every reachable Model, unrelated work in the enclosing handler, or an outgoing HTTP request. External effects must stay outside retryable assertions and applies.
Concurrent decisions and the commit boundary
Section titled “Concurrent decisions and the commit boundary”Suppose a Project has four Tasks. Two commands each read four children and decide that adding one more is allowed. Both decisions were individually reasonable; committing both would violate the limit.
sequenceDiagram
participant A as Add Task A
participant R as Repository
participant B as Add Task B
A->>R: Read: 4 Tasks
B->>R: Read: 4 Tasks
A->>R: Commit based on 4
R-->>A: OK: 5 Tasks
B->>R: Commit based on 4
R-->>B: Conflict
B->>R: Retry: read
R-->>B: 5 Tasks
Note over B: Retry rejected: limit reached
The SDK includes the inspected membership in the commit boundary. The first addition succeeds. The second conflicts because the collection it read has changed. With the default RETRY, the SDK runs the operation again: it now sees five Tasks, and ProjectErrors.capacityReached rejects the addition. Empty collections are protected too; additions, removals and moves can all invalidate a collection read.
A read dependency does not need to be a write target. In this example, AddTask writes one new Task but bases that write on existing Project state:
| Part of this attempt | Read for the decision | Written by this operation |
|---|---|---|
| Project value | The Project injected into the apply | No |
| Project’s Task membership | Four existing children; the limit is five | A new relationship is added |
| Existing Task values | Not needed to count children | No |
| New Task | No existing value | Created |
Reading every existing Task value would add dependencies that a membership-only rule does not need.
Injected Models and inspected Graph values participate in that attempt’s read dependencies. Counting project.children(Task.class).size() protects membership without needing every Task’s value. Reading childModels(Task.class) also inspects the returned values. Synchronous loadGraph(...) calls inside the mutation join the same attempt. Merely constructing an unused Graph does not read its complete tree. Document search and arbitrary loadModel(...) calls do not provide this transactional read tracking.
| Conflict policy | What happens after a concurrent change? |
|---|---|
RETRY (default) | Rerun the operation, including assertions and applies, on a new attempt boundary. A now-invalid business action is rejected. |
FAIL | Return the conflict to the caller. |
ACCEPT | Keep the accepted decision and rebase the applies on newer state. Assertions are not rerun; assertion-only reads no longer protect the decision. |
The default budget is three retries after the initial attempt, at most four attempts. Configure fluxzero.model.maxConflictRetries (FLUXZERO_MODEL_MAX_CONFLICT_RETRIES) when a different bound is needed. Business rejections are not commit conflicts and do not consume repeated attempts hoping the rule will later pass. Keep retryable assertions and applies free of external effects: sending a payment request cannot be undone by rolling back a Model commit.
Shared rules, routing and queues
Section titled “Shared rules, routing and queues”Routing all Task commands by their Project can serialize decisions that depend on Project’s state. It can also queue a simple update to Task B behind work on Task A. Routing by Task instead removes that common execution key, but does not by itself keep a shared Project rule correct.
Routing and conflict protection solve different problems:
| Mechanism | What it controls | What it does not guarantee |
|---|---|---|
| Route commands by Project | Commands on that route are handled serially | Protection from writers outside that route |
| Route commands by Task | Independent Tasks can use different routes | Correctness of a rule that also reads Project membership |
| Track reads and validate the Model commit | Concurrent changes to consumed state invalidate the decision | Parallel execution; configure consumer threads and routing separately |
With the default retry policy, the SDK reruns assertions when the evidence used by a decision changes. The two competing additions above show when the retry rejects a command and how ACCEPT differs.
Cascading rules across descendants
Section titled “Cascading rules across descendants”A rule that belongs to a Model can protect changes throughout its known descendant Graph. Opt in on the owning rule; ordinary assertions do not propagate automatically:
@Modelrecord Project(@EntityId ProjectId projectId, boolean closed) { @AssertLegal(cascade = true) void assertOpen() { if (closed) { throw ProjectErrors.closed; } }}
@Modelrecord Task(@EntityId TaskId taskId, @Parent ProjectId projectId, TaskDetails details, boolean completed) {}@Modeldata class Project(@EntityId val projectId: ProjectId, val closed: Boolean) { @AssertLegal(cascade = true) fun assertOpen() { if (closed) throw ProjectErrors.closed }}
@Modeldata class Task( @EntityId val taskId: TaskId, @Parent val projectId: ProjectId, val details: TaskDetails, val completed: Boolean)Changing Task now checks its Project first. The same rule reaches deeper descendants and the Project’s own mutations.
Every locally known enabled parent route participates; a move checks both the old and the new route, and shared
ancestors run once per update phase. An immediate rule sees the logical state before that update.
afterHandler = true instead sees the complete final state of the atomic Model operation.
The ancestor values and relationships inspected by the rule join the operation’s read dependencies. RETRY reloads
and reevaluates them after a concurrent change. A guarded attempt configured with ACCEPT uses FAIL instead, because
rebasing without rerunning the rule could retain stale permission.
There are two deliberate escape hatches. @Parent(validateAncestors = false) stops inherited rules on that relation
and its subtree while retaining ordinary Graph navigation, search and deletion policies. For one mutation,
@Apply(ancestorValidation = AncestorValidation.DISABLED) skips inherited rules without skipping the target Model’s
own assertions. Cascading rules execute in the writing application, so every writer of the protected scope must deploy
the rule and know the relevant Model types.
2. Changing several Models atomically
Section titled “2. Changing several Models atomically”One command can return changes to several independent roots. Consider the ordering example: placing an
Order must reserve Inventory in the same operation, but neither object owns the other. The following types
use OrderId extends Id<Order> and InventoryId extends Id<Inventory>, following the earlier typed-ID pattern:
record PlaceOrder(@NotNull OrderId orderId, @NotNull InventoryId inventoryId, @Positive int quantity) { @AssertLegal void checkStock(Inventory inventory) { if (inventory.available() < quantity) { throw OrderErrors.insufficientStock; } }
@Apply Order createOrder() { return new Order(orderId, inventoryId, new OrderDetails(quantity)); }
@Apply Inventory reserve(Inventory inventory) { return inventory.withAvailable(inventory.available() - quantity); }}
@Modelrecord Order(@EntityId OrderId orderId, InventoryId inventoryId, OrderDetails details) {}
record OrderDetails(int quantity) {}
@Model@Withrecord Inventory(@EntityId InventoryId inventoryId, int available) {}data class PlaceOrder( @field:NotNull val orderId: OrderId, @field:NotNull val inventoryId: InventoryId, @field:Positive val quantity: Int) { @AssertLegal fun checkStock(inventory: Inventory) { if (inventory.available < quantity) { throw OrderErrors.insufficientStock } }
@Apply fun createOrder() = Order(orderId, inventoryId, OrderDetails(quantity))
@Apply fun reserve(inventory: Inventory) = inventory.copy(available = inventory.available - quantity)}
@Modeldata class Order( @EntityId val orderId: OrderId, val inventoryId: InventoryId, val details: OrderDetails)
data class OrderDetails(val quantity: Int)
@Modeldata class Inventory(@EntityId val inventoryId: InventoryId, val available: Int)There is no @Parent here. The ordinary Inventory reference on Order does not make Inventory its child.
Both applies participate in the same atomic commit. Insufficient stock, a duplicate Order or an unresolved
commit conflict cannot leave a newly stored Order without its reservation, or a reservation without its Order.
External payment or delivery remains a post-commit workflow.
The PlaceOrder event belongs to both Model streams and is published once globally. One business action
can therefore have one event while changing several independent pieces of state.
Atomic changes across independent roots
Section titled “Atomic changes across independent roots”The overview shows two independent Models changing together. The storage guarantee is narrower than “everything the handler does succeeds or fails together.”
A Model operation can change unrelated roots atomically. The guarantee covers its staged Model changes, not separately dispatched commands or external effects. Keep payment calls, outgoing HTTP and other irreversible actions outside retryable assertions/applies. Multi-Model operations show direct applies and composed payloads.
The Aggregate commit path also coordinated several persistence effects even for one root: relationship updates, event storage, snapshots and a direct search-document update. It did not put all those effects into one storage transaction. A failure between steps could leave some work stored. The Model commit covers its Model events, authoritative document state and relationship changes together; separately maintained Graph projections have their own completion policy.
The projection-completion contract specifies when the caller also waits for the composed search view.
Move a Task by changing its relationship
Section titled “Move a Task by changing its relationship”Returning to the project-management example, moving a Task changes its parent reference. Its identity, completion state and details stay the same. Since our example limits Projects to five Tasks, the move also checks the destination’s capacity:
record MoveTask(TaskId taskId, ProjectId destinationId) { @AssertLegal void checkCapacity(Task task, @Association("destinationId") Graph<Project> destination) { if (!task.projectId().equals(destinationId) && destination.childModels(Task.class).size() >= 5) { throw ProjectErrors.capacityReached; } }
@Apply Task apply(Task task) { return task.withProjectId(destinationId); }}data class MoveTask(val taskId: TaskId, val destinationId: ProjectId) { @AssertLegal fun checkCapacity(task: Task, @Association("destinationId") destination: Graph<Project>) { if (task.projectId != destinationId && destination.childModels(Task::class.java).size >= 5) { throw ProjectErrors.capacityReached } }
@Apply fun apply(task: Task) = task.copy(projectId = destinationId)}The assertion’s required destination Graph verifies that the new Project exists. Its inspected collection joins conflict checking. The Task’s children remain attached to the same Task ID, so there is no remove-and-copy operation for its comments or history.
Move the relationship, not the stored object tree
Section titled “Move the relationship, not the stored object tree”The overview’s move example uses a stable Task ID. This matters: changing a relationship and changing an identity are different operations.
Changing a parent reference on a stable-ID Model changes its relationship while retaining its stream. Descendants still reference the same child ID. Parent-scoped identity is a different case: changing the parent also changes that identity and cannot be treated as a stable-ID move. Clearing an optional parent detaches that relationship. The move example combines the new relation with a destination-capacity assertion.
| Identity choice | What changing the parent means |
|---|---|
| Stable Task ID, separate Project reference | Update the relationship. Task history and descendant references keep the same anchor. |
| Task identity derived from its Project | Change the Task’s identity too. Treat this as an explicit identity migration, not an ordinary move. |
| Stable Task ID, optional Project reference cleared | Detach that edge. Task remains the same Model. |
A destination-capacity rule also reads and protects the destination’s relevant membership. The move’s commit therefore covers more than just replacing an ID field.
An apply can update an ancestor
Section titled “An apply can update an ancestor”A Model-side apply may return a change to a resolved ancestor. For example, let completing a Task also update a Project’s lastCompletedTaskId. In this variant, Project has that additional field and both Java Models use Lombok @With:
record CompleteTask(TaskId taskId) { @Apply Task apply(Task task) { return task.withCompleted(true); }}
@Model(name = "task")@Withrecord Task(@EntityId TaskId taskId, @Parent(pathInParent = "tasks") ProjectId projectId, TaskDetails details, boolean completed) { @Apply Project rememberCompletion(CompleteTask command, Project project) { return project.withLastCompletedTaskId(taskId); }}
@Model(name = "project")@Withrecord Project(@EntityId ProjectId projectId, ProjectDetails details, TaskId lastCompletedTaskId) {}data class CompleteTask(val taskId: TaskId) { @Apply fun apply(task: Task) = task.copy(completed = true)}
@Model(name = "task")data class Task( @EntityId val taskId: TaskId, @Parent(pathInParent = "tasks") val projectId: ProjectId, val details: TaskDetails, val completed: Boolean) { @Apply fun rememberCompletion(command: CompleteTask, project: Project): Project = project.copy(lastCompletedTaskId = taskId)}
@Model(name = "project")data class Project(@EntityId val projectId: ProjectId, val details: ProjectDetails, val lastCompletedTaskId: TaskId?)The payload apply completes the Task. The Task’s Model-side apply then returns the updated Project selected through the Task’s parent relationship. Both replacements join one atomic operation. Merely injecting the Project would have made it a read dependency; returning the replacement is what changes it.
A variable number of changes
Section titled “A variable number of changes”The set of Models does not have to be known in the command’s Java signature. An apply can read the selected children and return their replacements. Here, one command completes every Task in the selected Project:
record CompleteAllTasks(ProjectId projectId) { @Apply List<Task> apply(Graph<Project> project) { return project.childModels(Task.class).stream() .map(task -> task.withCompleted(true)) .toList(); }}data class CompleteAllTasks(val projectId: ProjectId) { @Apply fun apply(project: Graph<Project>): List<Task> = project.childModels(Task::class.java).map { it.copy(completed = true) }}The inspected collection and each Task’s own revision travel into conflict checking. This is one atomic operation, not a loop of independently dispatched commands. Use Models from the tracked Graph in that evaluation, not detached search results that have lost their read boundary.
If each part already has its own domain command, compose those commands instead:
record CompleteTasks(List<TaskId> taskIds) { @InterceptApply List<CompleteTask> expand() { return taskIds.stream().map(CompleteTask::new).toList(); }}data class CompleteTasks(val taskIds: List<TaskId>) { @InterceptApply fun expand(): List<CompleteTask> = taskIds.map(::CompleteTask)}For CompleteTasks([a, b]), the interceptor produces CompleteTask(a) followed by CompleteTask(b). The SDK checks and applies the first, then checks and applies the second against the staged result of the first. Neither Task is committed halfway through. If the second action is rejected, the first change is discarded too.
Write each Task’s rules on CompleteTask or on the applicable Model-side assertions. The interceptor replaces CompleteTasks as the payload being applied, so assertions that exist only on that outer wrapper are not a substitute for validating the resulting actions. This composition is useful when an existing command already captures the required business rule.
3. Querying Model state and related data
Section titled “3. Querying Model state and related data”Use identity-based loads for domain state. A Graph reads Models and relationships lazily, under its selected state boundary; creating a Graph does not eagerly reconstruct the complete tree. Calling get() or reading child values performs the corresponding value reads. A deliberately current DOCUMENT-only root is captured eagerly so its head and value agree; its descendants remain lazy.
Graph<Project> project = Fluxzero.loadGraph(projectId);ProjectDetails details = project.get().details();List<Task> tasks = project.childModels(Task.class);List<Graph<Task>> taskNodes = project.children(Task.class);var placedChildren = project.children("tasks");var allDirectChildren = project.children();var namedTasks = project.namedChildren("task", false);val project: Graph<Project> = Fluxzero.loadGraph(projectId)val details = requireNotNull(project.get()).detailsval tasks: List<Task> = project.childModels(Task::class.java)val taskNodes = project.children(Task::class.java)val placedChildren = project.children("tasks")val allDirectChildren = project.children()val namedTasks = project.namedChildren("task", false)These are different selectors: class selects locally known assignable Models; a path selects the declared composition placement; no selector returns direct children; logical name selects metadata and false includes types unknown to this application. Use childModels(...) for values and children(...) for navigable Graph nodes. None of these identity-based operations requires document persistence, searchability or Graph materialization.
Small changes, large persistence boundaries
Section titled “Small changes, large persistence boundaries”For an event-sourced Project with embedded Tasks, a cache miss reconstructs the containing Project, including its other embedded state. As that state grows, even a request for one Task can pay the deserialization and memory cost of the larger root. Snapshots reduce the event replay needed to reconstruct the root, but still contain the combined state; caching avoids repeated reconstruction while retaining that larger state in memory.
The overview illustrates a cold load of 100,000 Project events versus 10 Task events. Those counts describe which history is involved, not a benchmark. Snapshots and caches change the amount of reconstruction; they do not turn an embedded Task into its own storage unit.
// Embedded design: locate the containing Aggregate and replay that root.Entity<Task> embedded = Fluxzero.loadEntity("task-a", Task.class);
// Independent Model design: load this Model's stream.Entity<Task> independent = Fluxzero.loadModel(taskId);// Embedded design: locate the containing Aggregate and replay that root.val embedded: Entity<Task> = Fluxzero.loadEntity("task-a", Task::class.java)
// Independent Model design: load this Model's stream.val independent: Entity<Task> = Fluxzero.loadModel(taskId)The calls above are alternatives: the first refers to the embedded Task, the second to the independent Task declared earlier. A Model operation that reads other Models must also load that state. Independence does not make a genuinely broad operation constant-cost.
| Mechanism | Embedded Aggregate | Independent Models |
|---|---|---|
| Event replay | Root history, including other members’ changes | History of each Model actually read |
| Snapshot | The combined root value | The configured Model’s own value |
| Cache | Root state retains its embedded object tree | Each cached Model retains its own state |
| Direct searchable document | @Aggregate(searchable = true) indexes the combined root value | Effective Model searchability indexes only that Model’s own state |
| Composed search document | Embedded state is already part of the root | Opt into Graph materialization when the whole view is useful |
Thus the same large boundary affects reads, heap use and document serialization/indexing. Choosing independent Models removes the need to pay those costs for every small child change. It also makes storing a large composed search view an explicit choice rather than a consequence of embedding.
With an independent Task Model, updating Task does not inherently load or update Project or sibling Tasks. Once an assertion inspects Project’s children, however, that membership becomes a read dependency. Independence of persistence does not exempt a genuinely shared rule from concurrency checks. Read dependency semantics distinguish metadata, membership and value reads.
Different persistence for different parts
Section titled “Different persistence for different parts”Persistence settings are per Model. EVENT_SOURCED supports reconstruction from retained events; DOCUMENT maintains current state. Selecting both keeps event history and adds document persistence. A DOCUMENT-only child does not inherit historical reconstruction from an event-sourced parent. See the storage matrix and historical read constraints.
For example, keep Task’s event-sourced history but store only the latest externally reported status in this companion:
@Model(persistence = ModelPersistence.DOCUMENT)record TaskStatus( @EntityId(prefix = "task-status-") @Parent(pathInParent = "status") TaskId taskId, boolean online) {}@Model(persistence = [ModelPersistence.DOCUMENT])data class TaskStatus( @EntityId(prefix = "task-status-") @Parent(pathInParent = "status") val taskId: TaskId, val online: Boolean)For Task task-a, the status has storage ID task-status-task-a. Its identity scheme allows one status per Task without merging their state. Load it with Fluxzero.loadModel(taskId, TaskStatus.class) (Kotlin: TaskStatus::class.java). The parent edge follows Task’s lifecycle; its status composition path is still a collection.
Updating status does not append to Task’s stream. Task history also does not provide old status values: a strict historical Graph that consumes an overwritten DOCUMENT-only value fails to reconstruct it; a fallback view can use its current value. Choose both persistence modes for the status when durable historical processing is required. The mixed-persistence contract explains that boundary.
Which documents are stored?
Section titled “Which documents are stored?”A node document contains one Model’s current state. DOCUMENT uses it for internal state; searchable
adds the indexes used by node, relationship and Graph queries. Those uses share one canonical representation.
A stored Graph document optionally contains the root and recursively composed children as an additional read projection.
Searchability and whole-Graph materialization are separate choices: a searchable Model does not imply that the complete
Graph is stored. By default, searchable Graphs are composed live from the indexed node documents.
| Configuration | Authoritative state | Node document | Whole Graph stored? |
|---|---|---|---|
@Model, with or without a parent path, outside any searchable ancestor scope | Events | None | No |
| DOCUMENT, no effective searchability | Current internal document | One unindexed internal document | No |
@Model(searchable = true) | Events | One indexed current representation per included node | No: live composition |
| DOCUMENT and effective searchability | Current canonical node document | The same document, indexed | No: live composition |
Searchable plus graphProjection.mode = ASYNC or AWAIT | Unchanged persistence policy | Same indexed nodes | Yes: an additional composed projection |
searchable defaults to false. Setting it to true starts a searchable scope that includes composed descendants by
default; SearchSettings(includeDescendants = false) limits that request to the node itself. A child’s local false
value does not veto an ancestor’s scope. A typed parent path specifies placement, never storage by itself. Per-node SearchSettings select
collection and time fields independently of who activates searchability. See the
query guide.
Search a Model or search a Graph?
Section titled “Search a Model or search a Graph?”search(Task.class) returns Task values. searchGraph(Project.class) returns composed Project Graphs. Neither reconstructs historical events to answer a search.
Enable searchable = true on Project to index its own state and the Tasks composed under its tasks path. The Tasks are then searchable directly or under a known parent ID, without changing their persistence:
List<Task> completed = Fluxzero.search(Task.class) .whereParent(projectId) .match(true, "completed") .fetchAll();val completed: List<Task> = Fluxzero.search(Task::class.java) .whereParent(projectId) .match(true, "completed") .fetchAll()Task remains event-sourced. Ordinary search(Task.class) reads the canonical indexed node and includes only Task’s own fields. The inherited search activation also permits queries without a parent selector; no second document setting is needed. Set searchable on Task itself when its searchability should not depend on a searchable ancestor contract.
To find Tasks by their Project’s name, Project needs an indexed source too. The searchable Project configuration supplies that source:
List<Task> launchTasks = Fluxzero.search(Task.class) .whereParent(Project.class, MatchConstraint.match("Launch", "details/name")) .fetchAll();val launchTasks: List<Task> = Fluxzero.search(Task::class.java) .whereParent(Project::class.java, MatchConstraint.match("Launch", "details/name")) .fetchAll()The predicate reads Project’s source and follows its relations to Tasks. The Task need not copy the Project name. whereAncestor(Project.class, ...) works the same way through more than one parent edge. No whole-Graph document is required for either query.
Now use searchGraph when the response should contain the Project and its composed Tasks:
List<Graph<Project>> projects = Fluxzero.searchGraph(Project.class) .match(true, "tasks/completed") .fetchAll();val projects: List<Graph<Project>> = Fluxzero.searchGraph(Project::class.java) .match(true, "tasks/completed") .fetchAll()With searchable Project and the default GraphProjectionMode.NONE, the SDK composes Graphs live from canonical
root and child documents. NONE does not mean that Graph search is disabled; it means that no complete Graph
document is materialized. Search activation is controlled separately by searchable. ASYNC and AWAIT
additionally maintain a stored whole-Graph projection. searchGraph(Project.class, true) explicitly chooses live
composition when materialization is enabled. A Project without effective searchability still supports
loadGraph(projectId), but cannot be a searchGraph root.
A matching Project includes its composed Tasks, not only the completed ones. whereDescendant(Task.class, MatchConstraint.match(true, "completed")) can instead narrow candidate roots using Task source documents before composition. Live composition costs work at query time; materialization pays that work after commits and can lag. Materialization and completion explains when to wait for the stored view.
A search projection is not a second way to edit domain state
Section titled “A search projection is not a second way to edit domain state”Model operations own the canonical node document. Ordinary manual indexing is not a supported way to change Model state: it bypasses commit consistency, history and relationship maintenance. Document handlers can persist guarded schema upcasts; independent business projections belong in separate read models. Returning a composed Graph never applies business changes to its nodes.
Search sees current documents or a possibly delayed materialized projection. It does not join a command’s transactional read dependencies and does not become historical inside an old event handler. For the five-Task invariant, keep the Graph read inside @AssertLegal; a search count cannot protect that decision at commit.
4. The state of that event, or the state of now
Section titled “4. The state of that event, or the state of now”Imagine Task A was completed at 10:00, reopened at 10:05, and its completion event is handled at 10:10.
| Read at 10:10 | State you receive |
|---|---|
Graph<Task> injected into the completion event handler | Completed, at the 10:00 commit boundary. Related event-sourced nodes are read at that same boundary. |
task.previous() in that handler | The state just before completion: Task A was open. |
task.current() or loadCurrentGraph(taskId) outside a mutation | A fresh current view: Task A is open after the 10:05 change. |
Task or Graph<Task> injected into a query | Current authoritative state, not the old event’s state. |
| Injected Models/Graphs inside an assertion or apply | The mutation attempt’s boundary plus its earlier staged changes; consumed reads participate in conflict detection. |
| Document or Graph search | Search-visible documents, independent of the event boundary. |
An ordinary query is not a transaction spanning arbitrary separate reads. For an invariant, perform the related reads inside a Model operation. current() inside that operation retains its attempt boundary; it does not let half the rule switch to newer state.
For the completion event:
@HandleEventvoid onTaskCompleted(CompleteTask event, Graph<Task> task) { Task then = task.get(); Graph<Task> previous = task.previous(); Task before = previous == null ? null : previous.get(); Graph<Task> now = task.current(); // Compare before/then for this transition; consult now for current-intent reconciliation.}@HandleEventfun onTaskCompleted(event: CompleteTask, task: Graph<Task>) { val then = task.get() val before = task.previous()?.get() val now: Graph<Task> = task.current() // Compare before/then for this transition; consult now for current-intent reconciliation.}The event-bound Graph stays historical even when later commands have already run. current() creates a
fresh view outside a mutation; it does not mutate the original Graph. Inside a Model mutation, current reads
share that attempt’s boundary instead of silently switching snapshots halfway through a decision.
Keep EVENT_SOURCED when you need previous() and historical reconstruction. DOCUMENT alone retains
current state, not arbitrary previous document versions. Adding DOCUMENT alongside event sourcing does
not remove the event history.
State indexes and mixed persistence
Section titled “State indexes and mixed persistence”A state index identifies a committed Model-state boundary. Storage assigns ordered state indexes when accepting a commit. Changes within one committed substep share a state index; a composed operation can contain several ordered substeps while still committing atomically. It is separate from a message’s event-log index. graph.stateIndex() selects the Graph’s read boundary; revisionStateIndex() identifies the selected Model revision, which may be older when only another node changed.
Read each row as one Model’s history. The vertical line selects boundary 30. Project’s latest revision at or before that line is 10, Note’s is 20 and Task’s is 30. Task revision 40 is already present in storage, but lies beyond this Graph’s selected boundary. All three reads therefore describe one historical boundary without requiring all three Models to have changed at the same instant.
State indexes encode time in the same way as message indexes. IndexUtils.timestampFromIndex(index) decodes the timestamp. For a time-based view, use graph.atStateIndex(IndexUtils.maxIndexFromMillis(instant.toEpochMilli())) to include the commits through that millisecond. This requires retained reconstructible history; it cannot undo physical erasure.
A mixed Graph can contain event-sourced Project/Task nodes and a DOCUMENT-only status node. Historical values use
retained event history where available. An overwritten DOCUMENT-only value normally uses the current value of the same ID. It becomes
null after current deletion and may come from a recreation. Historical absence and relationships remain pinned.
This also applies to previous(); fallback values do not represent exact before/after document versions.
Use graph.strict(true) for exact historical values and graph.strict(false) to return to ordinary reads.
Set fluxzero.model.graph.strict=true for a strict application default; this is independent of defaults versions. Assertions, applies and replay stay strict in either mode. Fallback
values are retained per view after reading, but separate nodes do not form an atomic current snapshot. See
Temporal Graphs for configuration, Java/Kotlin examples and Runtime compatibility.
5. React to the whole Graph, without enumerating its events
Section titled “5. React to the whole Graph, without enumerating its events”In the project-management example, the overview should change when a Task changes—not only when the Project itself receives an update. You should not have to maintain a list of every command or event that might affect that overview.
Use one unqualified Graph<Project> parameter for complete Graph-change delivery. You can add Message and
Metadata context parameters in any order. Adding an explicit payload parameter, an association-qualified Graph
or another Graph keeps the method on ordinary handler selection:
@HandleEventvoid projectChanged(Graph<Project> project, Message message, Metadata metadata) { Graph<Project> before = project.previous(); // React to this Graph transition, whether the root or a descendant changed.}@HandleEventfun projectChanged(metadata: Metadata, project: Graph<Project>, message: Message) { val before = project.previous() // React to this Graph transition, whether the root or a descendant changed.}The SDK discovers the affected roots from the durable Model commit. The event does not have to repeat the Project ID. If several Models below the same root change in one committed substep, that root is delivered once for that substep. This is not an exactly-once delivery guarantee; normal handler retry rules still apply.
The Graph shows the state at that change, even if the consumer is catching up later. previous() supplies
the preceding Graph when the inspected state has retained event-sourced history. Creation has no previous
Graph; deletion supplies an empty current Graph. Moving a child notifies both the old and new roots.
Cascaded logical deletion also reaches the deleted child’s own Graph handler; physical erasure does not
manufacture a domain event, and suppressed event publication does not manufacture notifications.
@HandleNotification supports the same Graph-change signature without regular consumer segmentation—useful,
for example, for sending a WebSocket update that refreshes a connected UI.
By contrast, onTaskCompleted(CompleteTask event, Graph<Task> task) remains a handler for that specific
payload; adding a Graph parameter does not broaden its subscription to every Graph change.
For a searchable Project, @HandleDocument with a leading Graph<Project> parameter instead observes logical document updates for that Project and its included descendants, in NONE, ASYNC and AWAIT:
@HandleDocumentvoid projectViewChanged(Graph<Project> project) { // Reconcile the current searchable Project Graph.}@HandleDocumentfun projectViewChanged(project: Graph<Project>) { // Reconcile the current searchable Project Graph.}This route can skip superseded intermediate versions. NONE retains durable update markers and composes current indexed nodes when handled; ASYNC/AWAIT read the stored composition. Neither supplies historical previous() values. An ancestor-only content change does not wake a child-root handler, and @GraphProperty does not add subscriptions. Use events for individual published transitions and documents to reconcile current search state. Returning the unchanged Graph migrates evolved canonical nodes in every mode, including NONE; affected projections follow durably. Use graphMigration = GraphMigrationTarget.PROJECTION for only a stored composition. See document handlers and schema evolution.
Watch the Graph evolve
Section titled “Watch the Graph evolve”For example, a Project already exists when Task A and Task B are added. Task A is then completed and Task B is deleted. An activity handler should see each transition with the surrounding Project as it was then, even if it runs tomorrow.
Select a step to follow the same graph through time:
These are snapshots, not four copies of your domain model. The same Model identities are reconstructed at different committed boundaries. Relationships belong to those boundaries too: if a task moves, its historical parent is the project it belonged to then.
This is useful for an activity timeline, an audit explanation, or understanding why a rule fired. It also lets a
handler detect a transition such as “all tasks have now completed” by comparing the before and after views. You do
not need an extra generic ProjectChanged event to discover every kind of child change.
Outside a complete graph-change handler, previous() walks the selected Model’s revisions. A Project revision
list is not a list of every update to all its tasks. Use complete graph-change handlers for each published tree
transition. For a convenient predicate-based lookup through the selected Task’s revisions:
Optional<Graph<Task>> lastOpen = Fluxzero.loadGraph(taskId) .playBackToCondition(g -> g.get() != null && !g.get().completed());val lastOpen = Fluxzero.loadGraph(taskId) .playBackToCondition { g -> g.get()?.completed == false }This returns the first matching retained revision while walking backward, including the starting revision. It does not enumerate every change to every child of a Project. Use atStateIndex(...) for a known committed or time-based boundary, or Graph-change handlers for each published tree transition.
See Temporal Graphs for navigation, before/after comparisons, and the history needed to make those views reliable.
6. Share relationships without sharing every application’s implementation
Section titled “6. Share relationships without sharing every application’s implementation”Consider two applications connected to the same Model repository. The project application owns Task commands. A notes application adds InternalNote Models beneath those Tasks, using the extension declaration below. The notes application has Task’s shared state/ID contract; the project application does not have InternalNote’s class.
Extend the domain without expanding the root
Section titled “Extend the domain without expanding the root”A child declaration supplies its relation to the parent; the parent does not need a collection field or knowledge of the child’s Java class. Applications sharing relationship metadata may still have different type catalogs. Unknown nodes are available to metadata navigation but cannot be read as locally unavailable classes. The next example separates metadata navigation from typed value reads.
A new note feature can attach its own Model to Task, without adding a notes collection or an apply handler to Task itself:
@Model(name = "internalNote")record InternalNote(@EntityId String noteId, @Parent(pathInParent = "notes") TaskId taskId, NoteDetails details) {}
record NoteDetails(String text) {}@Model(name = "internalNote")data class InternalNote( @EntityId val noteId: String, @Parent(pathInParent = "notes") val taskId: TaskId, val details: NoteDetails)
data class NoteDetails(val text: String)Each extension can define its own applies and assertions. Task’s Java/Kotlin class need not know those types. The same separation lets an integration update reported state while the core application keeps desired state; neither has to append every change to the other’s stream. Later content-filtering examples extend the InternalNote declaration with audience-specific behavior.
What each application can read and handle
Section titled “What each application can read and handle”| Capability | Project application | Notes application |
|---|---|---|
| Read Task values | Has Task’s class and replay contracts | Needs the shared Task class and the contracts for the chosen read path |
| Read InternalNote values | Cannot deserialize the unavailable class | Has InternalNote and its replay behavior |
| Discover Notes beneath Task | Can inspect child IDs and logical names | Can navigate typed Notes |
| Handle Task commands | Registers the Task command behavior | Does not register it merely to read Task |
| Handle Note commands | Does not register the extension’s writes | Registers the Note command behavior |
For example, the project application can count the Task’s notes without loading an unavailable Note class:
Graph<Task> task = Fluxzero.loadGraph(taskId);int noteCount = task.namedChildren("internalNote", false).size();val task: Graph<Task> = Fluxzero.loadGraph(taskId)val noteCount = task.namedChildren("internalNote", false).sizeinternalNote is the logical name explicitly declared above. Here false includes locally unknown types. Their Graph nodes expose identity and relationship metadata, and knownType() is empty when the class is unavailable. Calling get() on such a node fails explicitly. Class-based selection only includes locally known assignable types, so it is not a complete cross-application count. Sharing relationships does not make missing classes available or let the project application serialize those Note values automatically.
Register write behavior only in its owning application. Sharing a Model class for reads does not require sharing command handlers; broad Spring scanning can accidentally register those handlers in both applications. See discovery and application ownership for explicit registration, scan boundaries and automatic-handling overrides.
For a shared Model with a maintained current source document, loadCurrentModelState(...) offers a verified,
read-only state view without replaying writer-local events. The state contract must still be available locally.
Ordinary event-sourced loads continue to require the historical event contracts and replay behavior.
Neither API imports missing classes, and current-state reads do not join a mutation’s readset.
7. Delayed work can follow its owner’s lifetime
Section titled “7. Delayed work can follow its owner’s lifetime”Use the same relationship vocabulary for scheduled work. For example, a reminder can belong to the Task it concerns:
record RemindTask(@Parent TaskId taskId, Instant expectedDeadline) {}
Fluxzero.scheduleCommand(new RemindTask(taskId, deadline), new ScheduleId("reminder", taskId), deadline);data class RemindTask(@Parent val taskId: TaskId, val expectedDeadline: Instant)
Fluxzero.scheduleCommand(RemindTask(taskId, deadline), ScheduleId("reminder", taskId), deadline)Plan it after the Task has committed. Deleting that Task, including an @Apply returning null or a parent cascade, automatically
cancels its owned schedules without a bespoke cleanup consumer. Automatic cancellation emits
ScheduleAutoCancelled metrics. Ownership follows the committed lifetime, so deleting and recreating the
same ID does not bring an old schedule back to life.
Cancellation is asynchronous. Already delivered work cannot be recalled: keep stale-deadline and current-intent checks in the scheduled handler. Ownership is not automatic deadline synchronization; completion, pausing or moving a deadline still needs your scheduling policy.
For explicit ownership on a Schedule message, schedule.withParents(taskId) supplies the same lifecycle link
and overrides payload declarations. With multiple owning parents, deletion of any one cancels the schedule.
Schedules do not become Graph nodes, and pathInParent is not needed for this use of @Parent.
Derive one-shot deadlines from Model state
Section titled “Derive one-shot deadlines from Model state”Use @Deadline when the desired work is derived from the Model itself rather than scheduled by an external flow.
The pure method returns a Schedule, a payload when the annotation supplies cron or delay, or null to cancel:
@Modelrecord BookingHold(@EntityId HoldId holdId, Instant expiresAt, boolean confirmed) { @Deadline Schedule expiry() { return confirmed ? null : new Schedule(new ExpireBookingHold(holdId), expiresAt); }}@Modeldata class BookingHold( @EntityId val holdId: HoldId, val expiresAt: Instant, val confirmed: Boolean) { @Deadline fun expiry(): Schedule? = if (confirmed) null else Schedule(ExpireBookingHold(holdId), expiresAt)}The Model commit carries the corresponding create, replacement or cancellation together with the state and records
the original intent in protected Model metadata. Unrelated writes retain it. Once its original time has arrived, a
changed payload or later timestamp does not recreate the deadline; return null and later a payload to begin a new
cycle. Cron chooses one next occurrence rather than making the declaration periodic. Logical deletion cancels by
default, including deletion through a parent cascade.
A deadline method can inject a typed ancestor or Graph<T>. Changes to that context reevaluate affected owners in
the same Model operation, which can materially widen commit work for a large connected component. Prefer the narrowest
typed ancestor that supplies the required state. Graph.deadlines() exposes the recorded deadline categories and
times for the selected historical or current revision; it describes planned work, not proof of successful delivery.
Complete Model deadline contract
8. Small, explicit atomic state transformations
Section titled “8. Small, explicit atomic state transformations”Send a command for normal domain behavior, or use Fluxzero.assertAndApply(new CompleteTask(taskId)) from an existing handler to invoke the same assertions and applies locally. See automatic and explicit handling.
For a direct update of one existing Model, Graph also offers revision-checked operations. The two alternatives below mark the example Task
as completed: one uses the observed revision, the other can recalculate after a conflict.
Graph<Task> observed = Fluxzero.loadCurrentGraph(taskId);Task old = Objects.requireNonNull(observed.get(), "Task not found");boolean applied = observed.compareAndSet(old.withCompleted(true));val observed: Graph<Task> = Fluxzero.loadCurrentGraph(taskId)val old = requireNotNull(observed.get()) { "Task not found" }val applied: Boolean = observed.compareAndSet(old.copy(completed = true))Alternatively, compute the replacement from fresh state, with at most two retries:
Graph<Task> updated = Fluxzero.loadCurrentGraph(taskId).updateAndGet( current -> current.update(task -> task.withCompleted(true)), 2);val updated: Graph<Task> = Fluxzero.loadCurrentGraph(taskId).updateAndGet( { current -> current.update { task -> task.copy(completed = true) } }, 2)compareAndSet makes one attempt against the receiver’s revision. updateAndGet and getAndUpdate accept
a Graph transformation and an explicit maxRetries; the default is zero. A budget of two permits at most
three attempts. Results are returned after a successful durable commit.
These APIs do not invoke a domain command’s @Apply or @AssertLegal handlers. Check required invariants
inside the transformation, keep it free of external effects and do not nest it inside another Model mutation.
The recipes also cover consume-once
and explain why claiming state and invoking an external provider are separate operations.
9. Deleting Models and retaining the right history
Section titled “9. Deleting Models and retaining the right history”Logical deletion uses the same apply pipeline as other changes:
record DeleteTask(TaskId taskId) { @Apply Task apply(Task task) { return null; }}data class DeleteTask(val taskId: TaskId) { @Apply fun apply(task: Task): Task? = null}The Task disappears from current state. Owned descendants follow the parent-deletion policy, and owned schedules are cancelled asynchronously. Event-sourced history remains available for historical processing.
Short-lived work, long-lived history
Section titled “Short-lived work, long-lived history”An independent process Model has its own stream and can be selected for physical erasure independently of its long-lived parent. Merely finishing or logically deleting the process does not erase that stream. Snapshots affect reconstruction cost; they do not change history ownership or perform erasure.
| Action on an Approval Model | Current value | Retained history |
|---|---|---|
| Mark it completed | Still present, with completed state | Still retained |
| Logically delete it | Removed | Retained for reconstruction |
| Store a snapshot | Unchanged | Still retained; the snapshot speeds reconstruction |
| Execute a physical deletion plan | Selected Model data erased | Selected Model history erased |
These are different operations. Choose retention independently of whether an approval is still active in the domain.
Retain a child after its parent is deleted
Section titled “Retain a child after its parent is deleted”By default, deleting Project cascades to its Tasks. To retain Tasks independently in this variant, change the relation on Task:
@Parent(pathInParent = "tasks", deleteOnParentDeletion = false)ProjectId projectId@Parent(pathInParent = "tasks", deleteOnParentDeletion = false)val projectId: ProjectIdDeleting Project now removes the active relationship but leaves Task alive. The Task’s stored projectId field is not rewritten to null: it can still contain the deleted Project’s ID, while current Graph navigation no longer exposes that parent. A parent ID in the value is therefore not proof of a currently live parent relation. If the domain wants to clear or replace that field, model that as an explicit state change. The option does not block Project deletion; add preventParentDeletion = true for that common check or use an explicit assertion for a richer rule.
Delete one part without deleting the whole
Section titled “Delete one part without deleting the whole”Logical deletion removes current state and applies the parent-cascade policy. Physical erasure is an explicit repository operation over a selected set. It has separate scope, planning and shared-data constraints. The deletion section specifies surviving children, retained relationship lineage and plan execution.
For a concrete erasure request, a completed Approval Model can be selected independently of its Task:
ModelDeletionPlan plan = Fluxzero.get().modelRepository() .planDeletion(approvalId, ModelDeletionCascade.NONE);// Inspect the plan, then execute only after the selected scope is confirmed.Fluxzero.get().modelRepository().deleteModel(plan).join();val plan = Fluxzero.get().modelRepository() .planDeletion(approvalId, ModelDeletionCascade.NONE)// Inspect the plan, then execute only after the selected scope is confirmed.Fluxzero.get().modelRepository().deleteModel(plan).join()approvalId identifies the independent Approval, not its Task. NONE selects only that Model; DESCENDANTS selects a different, explicitly planned scope. Erasure does not remove globally published events or application-maintained copies. See the full deletion contract before implementing retention.
Inspect and execute a physical deletion plan
Section titled “Inspect and execute a physical deletion plan”Logical deletion retains event-sourced history. To erase a Project and selected descendants from Model storage, first obtain a non-mutating plan:
ModelDeletionPlan plan = Fluxzero.get().modelRepository() .planDeletion(projectId, ModelDeletionCascade.DESCENDANTS);int models = plan.getModelCount();int shared = plan.getExternallySharedModelCount();List<String> sample = plan.getSampleModelIds();long publishedEvents = plan.getPublishedEventCount();// Present these details for review before confirming this plan.val plan = Fluxzero.get().modelRepository() .planDeletion(projectId, ModelDeletionCascade.DESCENDANTS)val models = plan.modelCountval shared = plan.externallySharedModelCountval sample = plan.sampleModelIdsval publishedEvents = plan.publishedEventCount// Present these details for review before confirming this plan.After the application/operator confirms that exact plan, execute it:
Fluxzero.get().modelRepository().deleteModel(plan).join();Fluxzero.get().modelRepository().deleteModel(plan).join()sampleModelIds is a bounded sample, not the full selection; the plan’s fingerprint identifies the complete selected set. DESCENDANTS is an explicit physical-erasure scope, separate from deleteOnParentDeletion. It follows retained deleted-parent lineage after a logical cascade, including nested descendants and surviving children detached by parent deletion. That lineage is maintained automatically; there is no annotation to turn it on. Earlier ordinary moves or detachments are excluded. Review surviving/shared children before confirming a descendant plan, and use an exact-Model deletion when those children must remain.
Model erasure is a precise storage operation, not a blanket privacy-compliance guarantee. Globally published events, shared payloads still referenced by surviving Models, external copies and backups have separate retention boundaries. See the deletion contract when designing sensitive-state cleanup.
Making Graphs work across the whole application
Section titled “Making Graphs work across the whole application”An aggregate gives application code a related object tree. Independent Models must not mean rebuilding that convenience by hand at every API boundary. Graph support therefore extends through serialization, filtering, API schemas, searchable views, schema evolution and testing.
These capabilities complete the Model/Graph design: the application can work with related objects as a whole while each object keeps its own persistence and lifecycle. The following examples carry one Project Graph through those everyday application concerns.
Read views should not dictate write boundaries
Section titled “Read views should not dictate write boundaries”pathInParent determines where a related Model appears in a composed response. It does not move that Model into its parent’s stored value. For example, serializing a Project’s tasks and their notes reads those selected branches; completing one Task need not read them. See whole responses and selected branches.
loadGraph navigates authoritative Models and relationships. searchGraph reads a composed document representation, live or materialized. A JSON response is another view of those nodes. These paths have different consistency and completion contracts; they are not interchangeable implementations of one transactional read. Queries and search specify the required documents and visibility.
Serialize the Graph as a composed response
Section titled “Serialize the Graph as a composed response”Independent storage does not mean a client must assemble independent responses. A Graph can provide the composed response without a hand-written root DTO declaring every independently added child type.
The serializer is replaceable; JSON is not a requirement of the Model/Graph programming model. The examples
here use the bundled JacksonSerializer. Its Graph support produces the root’s state with children nested
at their @Parent(pathInParent = "...") paths—not a wrapper full of SDK bookkeeping.
A web endpoint can return the Graph directly:
@HandleGet("/projects/{projectId}")Graph<Project> getProject(@PathParam("projectId") String projectId) { return Fluxzero.loadGraph(new ProjectId(projectId));}@HandleGet("/projects/{projectId}")fun getProject(@PathParam("projectId") projectId: String): Graph<Project> = Fluxzero.loadGraph(ProjectId(projectId))The method works with a typed Graph. The HTTP caller receives composed JSON, for example:
{ "projectId": "launch", "details": { "name": "Launch" }, "tasks": [ { "taskId": "task-a", "projectId": "launch", "details": { "title": "Ship 2.0" }, "completed": false } ]}Relations without a composition path
remain navigable but do not invent a JSON property. Returning the plain Project value does not automatically
attach its children.
Serialization visits the included values, so lazy loading is not a reason to return an unlimited domain tree. Choose the response view explicitly when a screen needs only part of it:
@HandleGet("/projects/{projectId}/tasks")Graph<Project> getProjectTasks(@PathParam("projectId") String projectId) { return Fluxzero.loadGraph(new ProjectId(projectId)).selectPaths("tasks");}@HandleGet("/projects/{projectId}/tasks")fun getProjectTasks(@PathParam("projectId") projectId: String): Graph<Project> = Fluxzero.loadGraph(ProjectId(projectId)).selectPaths("tasks")Use selectPaths("tasks/notes") instead when the endpoint also needs notes. These are immutable views; they do not change stored Models. A selected nested path includes its ancestors. Response scoping requires neither a stored Graph projection nor DOCUMENT persistence.
Local Graph results and transported query results
Section titled “Local Graph results and transported query results”For a query callable across applications, declare the response shape explicitly:
record GetProject(ProjectId projectId) implements Request<JsonNode> { @HandleQuery Graph<Project> handle() { return Fluxzero.loadGraph(projectId); }}data class GetProject(val projectId: ProjectId) : Request<JsonNode> { @HandleQuery fun handle(): Graph<Project> = Fluxzero.loadGraph(projectId)}The Request<JsonNode> contract makes the caller receive JSON both locally and through tracking. The SDK converts the returned Graph before returning the local result or publishing the transported result. A local call does not bypass this declared JSON contract.
When an in-process caller actually needs Graph navigation, use a self-handling query with a Graph response contract:
record GetProjectGraph(ProjectId projectId) implements Request<Graph<Project>> { @HandleQuery Graph<Project> handle() { return Fluxzero.loadGraph(projectId); }}data class GetProjectGraph(val projectId: ProjectId) : Request<Graph<Project>> { @HandleQuery fun handle(): Graph<Project> = Fluxzero.loadGraph(projectId)}A self-handling query is handled locally by default; no extra @LocalHandler or @LocalOnly annotations are needed for this example. The response contract is the relevant choice: Request<Graph<Project>> preserves the Graph locally, while Request<JsonNode> requests the JSON conversion shown above. For a query deliberately handled through tracking, promise a JSON/DTO response; serialized Graph JSON is a response tree, not a transportable live repository view.
Derived values without duplicated state
Section titled “Derived values without duplicated state”Graph context can also supply fields that should not be stored on every child. For example, extend Task with:
@GraphPropertyString projectName(Graph<Project> project) { Project parent = project.get(); return parent == null ? null : parent.details().name();}@GraphPropertyfun projectName(project: Graph<Project>): String? = project.get()?.details?.nameEach serialized Task now includes "projectName": "Launch", derived from the parent at the same Graph
boundary. Renaming the Project does not require rewriting its Tasks. Moving a Task changes the ancestor used
for the next current view; historical views retain their historical context.
In the Home example domain, the same pattern can store Space.primaryLightId once and derive Device.primary,
rather than storing both as independent truth. @GraphProperty runs for Graph serialization, not for a raw Model value. It can
appear in a materialized view, but is not a second authoritative state field. It does not enforce selection
integrity or clear stale IDs: express those rules in assertions and atomic changes. The
preference recipe
shows selection, moving and deletion together.
Filter Graph content for its audience
Section titled “Filter Graph content for its audience”The same domain Graph can serve different audiences without different persisted copies. @FilterContent
can retain, transform or omit values while preparing a response, with access to the user and Graph context.
Suppose Tasks have internal notes:
@Model(name = "internalNote")record InternalNote(@EntityId String noteId, @Parent(pathInParent = "notes") TaskId taskId, NoteDetails details) { @FilterContent InternalNote visibleTo(User user) { return user != null && user.hasRole("team") ? this : null; }}
record NoteDetails(String text) {}@Model(name = "internalNote")data class InternalNote( @EntityId val noteId: String, @Parent(pathInParent = "notes") val taskId: TaskId, val details: NoteDetails) { @FilterContent fun visibleTo(user: User?): InternalNote? = if (user != null && user.hasRole("team")) this else null}
data class NoteDetails(val text: String)Opt the query handler into content filtering:
record GetProject(ProjectId projectId) implements Request<JsonNode> { @HandleQuery @FilterContent Graph<Project> handle() { return Fluxzero.loadGraph(projectId); }}data class GetProject(val projectId: ProjectId) : Request<JsonNode> { @HandleQuery @FilterContent fun handle(): Graph<Project> = Fluxzero.loadGraph(projectId)}A team member receives the notes beneath each Task. Another authorized reader receives the Project and
Tasks without those note values. Filtering happens before response serialization, so the rules can still
use typed Graphs and ancestors. A filter may also return a transformed copy instead of null; explicit
filterNodes(...) and filterBranches(...) provide immutable view operations when a response needs them.
This is output filtering, not permission to execute commands, encryption of stored data or physical erasure.
Keep request authorization explicit. Defining a filter on a Model does not automatically opt every handler
into filtering, and a fresh .current() view does not inherit an earlier view’s presentation filters.
OpenAPI that understands the composed response
Section titled “OpenAPI that understands the composed response”In our example, the Project class no longer declares its Task collection. Its API documentation should not lose that collection either. The OpenAPI support understands composed Model Graphs, including nested relationship paths.
Add a composed response schema to the same small endpoint:
@ApiDocclass ProjectApi { @ApiDocResponse(status = 200, modelGraph = Project.class) @HandleGet("/projects/{projectId}") Graph<Project> get(@PathParam("projectId") String projectId) { return Fluxzero.loadGraph(new ProjectId(projectId)); }}@ApiDocclass ProjectApi { @ApiDocResponse(status = 200, modelGraph = Project::class) @HandleGet("/projects/{projectId}") fun get(@PathParam("projectId") projectId: String): Graph<Project> = Fluxzero.loadGraph(ProjectId(projectId))}modelGraph explicitly selects the composed schema. Returning Graph<Project> handles runtime serialization but does not by itself select this OpenAPI schema. Authentication and serving the generated OpenAPI document are configured separately.
Document the relationship at its declaration on Task:
@Parent(pathInParent = "tasks", apiDoc = @ApiDoc(description = "Tasks in this project"))ProjectId projectId@Parent(pathInParent = "tasks", apiDoc = ApiDoc(description = "Tasks in this project"))val projectId: ProjectIdThe generated 200 response describes Project fields and a tasks array of Task objects, rather than an
opaque Graph or an empty JSON object. Documented nested paths add their own composed schemas. Annotation
processing writes META-INF/fluxzero/openapi.json. Use @ApiDocInfo(serveOpenApi = true) in the application’s API configuration to serve that document.
For a deliberately scoped response, @ApiDocResponse(status = 200, modelGraph = Project.class, modelGraphPaths = {"tasks"})
describes that selection. Pair it with the actual selectPaths("tasks") response: documentation annotations
do not filter runtime data. Similarly, excluding a relationship from the schema is not access control.
The schema describes the possible response shape, not which notes a particular user’s filter will retain.
Configure endpoint authorization and the OpenAPI security declaration for your application separately.
Automatically materialize the complete Graph
Section titled “Automatically materialize the complete Graph”The storage table distinguishes a composed Graph document from per-Model sources.
Search activation and Graph materialization are two independent settings. searchable defaults to false; setting it
to true enables indexed node sources and live Graph search. graphProjection.mode separately decides whether
Fluxzero also stores the complete composed Graph. Its default is NONE: no complete Graph document is stored, while
Graph search still works by composing the indexed nodes live. NONE therefore means not materialized, not not
searchable. This section shows how the optional stored Graph is maintained and when command completion waits for it. Enable the projection on Project:
@Model( searchable = true, name = "project", graphProjection = @GraphProjection(mode = GraphProjectionMode.ASYNC))record Project(@EntityId ProjectId projectId, ProjectDetails details) {}@Model( searchable = true, name = "project", graphProjection = GraphProjection(mode = GraphProjectionMode.ASYNC))data class Project(@EntityId val projectId: ProjectId, val details: ProjectDetails)Keep the existing @Parent(pathInParent = "tasks") on Task and @Parent(pathInParent = "notes") on
InternalNote. Fluxzero maintains the resulting document:
{ "projectId": "launch", "tasks": [ { "taskId": "task-a", "notes": [{ "noteId": "note-1", "details": { "text": "Review the copy" } }] } ]}This abbreviated document shows placement paths; ordinary Model fields are omitted. Task and InternalNote still have their own stored state. The nested copy is maintained for search.
Changes to composed descendants update the affected root view. Moving a Task updates the old and new Project views, including its placed descendants. Deletion removes the corresponding composed state. The Models remain independent; the combined document is a read projection, not a new aggregate or second write owner.
Query the stored hierarchy directly:
Fluxzero.searchGraph(Project.class) .match(true, "tasks/completed") .fetchAll();Fluxzero.searchGraph(Project::class.java) .match(true, "tasks/completed") .fetchAll()This returns matching Project Graphs, not merely the matching Task fragments. DOCUMENT on Project is not
required: searchability maintains the canonical node documents independently of the persistence choice, and materialization adds the composed projection. “Complete”
means the declared composition paths recursively; a pathless relation is still navigable but does not acquire
a made-up position in the search document.
The ASYNC mode shown above can lag and combine successive changes. NONE remains the default: it stores no composed
Graph, but search still works by composing the indexed nodes live. If the caller specifically needs a stored Graph and
must search that updated materialization immediately after a command, select AWAIT:
@Model( searchable = true, name = "project", graphProjection = @GraphProjection(mode = GraphProjectionMode.AWAIT))record Project(@EntityId ProjectId projectId, ProjectDetails details) {}@Model( searchable = true, name = "project", graphProjection = GraphProjection(mode = GraphProjectionMode.AWAIT))data class Project(@EntityId val projectId: ProjectId, val details: ProjectDetails)The result waits for affected projections to reach that command’s committed boundary—not for future changes
or arbitrary event handlers. Prefer an operation-level override when only selected commands need to wait.
The projection can be tracked through @HandleDocument and evolved using the
node-aware schema migration described next.
Evolving stored Graphs with @HandleDocument
Section titled “Evolving stored Graphs with @HandleDocument”Splitting a domain into independent Models should not leave you with a giant, fragile schema when you bring them back together for search. A materialized Graph remembers the type and schema revision of every Model inside it. Its root can evolve without forcing every child to the same revision, and a child can evolve without a new schema version for every Graph in which it appears.
If you use upcasters in 1.x, this builds on the mechanism you already know. The new part is applying that mechanism throughout a composed Graph—and persisting the evolved view with a Graph-in, Graph-out handler.
Each Model evolves independently
Section titled “Each Model evolves independently”Suppose an older Project stored name directly, and you move it into ProjectDetails. Its tasks have not
changed. The stored Graph can evolve like this; these are schema revisions, not business-event versions:
These are serialization schema revisions, not the state indexes that select historical Model state.
Raise Project’s @Revision to 1 and register an ordinary upcaster for its old serialized type.
For a Project class in com.example, the value-preserving transformation is:
class ProjectSchema { @Upcast(type = "com.example.Project", revision = 0) JsonNode moveName(ObjectNode json) { JsonNode name = json.remove("name"); if (name == null || !name.isTextual()) { throw new IllegalArgumentException("Revision 0 requires a textual name"); } json.putObject("details").set("name", name); return json; }}class ProjectSchema { @Upcast(type = "com.example.Project", revision = 0) fun moveName(json: ObjectNode): JsonNode { val name = json.remove("name") require(name != null && name.isTextual) { "Revision 0 requires a textual name" } json.putObject("details").set<JsonNode>("name", name) return json }}The original name survives. The same registered caster handles Project state when encountered as a node inside a materialized Graph; there is no separate whole-Graph upcaster. Task nodes keep their own revisions and caster chains. See Model migration tests for caster registration and reconstruction tests.
Graph in, evolved Graph out
Section titled “Graph in, evolved Graph out”Read-time upcasting gives application code the new shape, but it does not change the paths already indexed
for search. A predicate on details/name cannot match a stored document that still has only name.
Register the caster and this consumer for NONE, ASYNC or AWAIT:
@Consumer(name = "project-graphs-schema-1", minIndex = 0)class EvolveProjectGraphs { @HandleDocument Graph<Project> evolve(Graph<Project> graph) { return graph; }}@Consumer(name = "project-graphs-schema-1", minIndex = 0)class EvolveProjectGraphs { @HandleDocument fun evolve(graph: Graph<Project>): Graph<Project> = graph}The bare annotation infers the Graph subscription from Graph<Project>. It works for NONE as well as ASYNC/AWAIT.
NONE hydrates current canonical nodes and does not retain previous Graphs.
The handler returns the Graph it received. The SDK identifies evolved nodes, then upcasts each verified current
canonical source and checks its full head/proof before storing it. It does not copy potentially stale Graph business
values. Contention is re-read with bounded retries; a persistent race fails handling. Already-current sources are
no-ops. Raise @Revision for a schema change; JSON changes alone do not trigger migration.
Node storage completes before the handler completes. Affected stored Graphs or NONE markers follow durably, without restarting scans for unrelated roots. AWAIT continues to wait for ordinary Model commits, not schema handlers.
The consumer starts at the beginning of the document stream to process existing projections as well as new ones. It sees retained current documents, not every intermediate version. After both the consumer and affected projections catch up, the new Graph supports the new query path:
Fluxzero.searchGraph(Project.class) .match("Launch", "details/name") .fetchAll();Fluxzero.searchGraph(Project::class.java) .match("Launch", "details/name") .fetchAll()The handler must preserve the root identity, read boundary, nodes and their placement. A delayed rewrite cannot replace a newer Graph projection. Deleted roots are not brought back. Model event histories and relationships are not rewritten, and no domain command is replayed merely to update this search view. Moving a task or renaming a project is still a business operation, not a schema migration.
Choose which stored representation to evolve
Section titled “Choose which stored representation to evolve”Graph evolution does not silently rewrite every other representation of its Models. The selectors make that choice explicit:
| Handler | Representation it evolves | What that enables |
|---|---|---|
@HandleDocument with Graph<Project> | Evolved verified current nodes in every mode, then affected projections | Updated node, live Graph and stored Graph paths |
@HandleDocument(graphMigration = GraphMigrationTarget.PROJECTION) with Graph<Project> | Only the handled ASYNC/AWAIT composition; observational at NONE | A deliberate derived-only migration |
@HandleDocument(source = DocumentSource.MODEL_STATE) | The verified internal current Model source | Updated source paths for live composition and related-content searches |
@HandleDocument with Project | The same canonical source, requiring effective searchability | Updated paths for node and live Graph queries |
For a maintained internal source, the schema-only handler is similarly small:
@Consumer(name = "project-sources-schema-1", minIndex = 0)class EvolveProjectSources { @HandleDocument(source = DocumentSource.MODEL_STATE) Project evolve(Project project) { return project; }}@Consumer(name = "project-sources-schema-1", minIndex = 0)class EvolveProjectSources { @HandleDocument(source = DocumentSource.MODEL_STATE) fun evolve(project: Project): Project = project}Here the returned value must equal the upcast state: changing business data or returning null is rejected.
The rewrite is guarded against concurrent Model changes and deletion, without advancing Model history.
Ordinary public-document handlers retain their existing revision-aware behavior; they do not acquire this
Model-source guard.
Migrate internal sources before rebuilding Graph projections, wait for catch-up before switching search predicates, and keep the Graph consumer active through the rollout so subsequent projections are covered. Qualify this through application queries against retained old data: the original names must survive, the new search paths must work, and historical queries must still answer correctly. The migration guide covers that rollout and type-renaming cases in detail.
Test the application, including its history
Section titled “Test the application, including its history”Test through the application’s inputs and observable outputs: send commands, ask queries, and verify responses, published events or outgoing work. The test should describe behavior, not inspect the application’s internal Model state. The new modeling route uses the existing given/when/then style:
TestFixture.create() .givenCommands(new CreateProject(projectId, new ProjectDetails("Launch")), new AddTask(taskId, projectId, new TaskDetails("Ship 2.0")), new CompleteTask(taskId)) .whenQuery(new GetTask(taskId)) .expectResult(new TaskSummary("Ship 2.0", "Launch", true)) .expectNoErrors();TestFixture.create() .givenCommands(CreateProject(projectId, ProjectDetails("Launch")), AddTask(taskId, projectId, TaskDetails("Ship 2.0")), CompleteTask(taskId)) .whenQuery(GetTask(taskId)) .expectResult(TaskSummary("Ship 2.0", "Launch", true)) .expectNoErrors()The query exercises Model injection and returns the application’s response. The test does not reach into the repository to inspect a cached Task. The same approach proves that moving a Task changes its visible Project while preserving its own contents:
TestFixture.create() .givenCommands(new CreateProject(sourceId, new ProjectDetails("Launch")), new CreateProject(destinationId, new ProjectDetails("Next release")), new AddTask(taskId, sourceId, new TaskDetails("Ship 2.0")), new MoveTask(taskId, destinationId)) .whenQuery(new GetTask(taskId)) .expectResult(new TaskSummary("Ship 2.0", "Next release", false)) .expectNoErrors();TestFixture.create() .givenCommands(CreateProject(sourceId, ProjectDetails("Launch")), CreateProject(destinationId, ProjectDetails("Next release")), AddTask(taskId, sourceId, TaskDetails("Ship 2.0")), MoveTask(taskId, destinationId)) .whenQuery(GetTask(taskId)) .expectResult(TaskSummary("Ship 2.0", "Next release", false)) .expectNoErrors()Use TestFixture.createAsync() to exercise asynchronous handling too. givenModelEvents(...) supplies
Model history for reconstruction tests; register application upcasters explicitly in a manually configured
fixture. Retained-storage migration tests go further by checking data written by an older SDK, not merely
old JSON passed through today’s command handler.
Scheduling assertions distinguish work scheduled during the test action from all active schedules.
Use expectOnlyActiveScheduledCommands(...) when proving that old work was removed.
Registration and operational contracts
Section titled “Registration and operational contracts”Automatic command handling and existing handlers
Section titled “Automatic command handling and existing handlers”In an Aggregate application, the command typically enters an explicit handler that loads the root:
@HandleCommandvoid handle(CompleteTask command) { Fluxzero.loadAggregate(command.taskId()).assertAndApply(command);}@HandleCommandfun handle(command: CompleteTask) { Fluxzero.loadAggregate(command.taskId).assertAndApply(command)}That is the old shape for a Task stored as an Aggregate root. With the Model version above, register the Model and its command behavior and send CompleteTask directly. Its applicable @Apply provides the command handler; remove the forwarding method. The normal handling path still validates input, runs domain assertions and commits the resulting changes.
Discovery and application ownership
Section titled “Discovery and application ownership”With the Fluxzero Spring configuration, component scanning discovers @Model classes, self-applying command classes with Model-returning @Apply methods, and @InterceptApply payloads within the application’s scan packages. They become tracked prototypes: sending a command from the same Spring application does not make it a local call. A reachable Model apply is required for automatic command handling. An annotation-processor discovery index makes Model types findable for loading; it does not register their command handlers in every application.
Outside Spring, register the owning types explicitly, for example:
fluxzero.registerHandlers(Project.class, Task.class, CreateProject.class, CompleteTask.class);fluxzero.registerHandlers(Project::class.java, Task::class.java, CreateProject::class.java, CompleteTask::class.java)Choose one application as the command owner. If a shared module puts the same command/Model behavior inside the scan packages of two applications with different consumers, both can consume the command. A read-only application should load the shared state contract without registering its writes. Narrow its scan/registration scope or configure fluxzero.model.automaticHandling=DISABLED (FLUXZERO_MODEL_AUTOMATIC_HANDLING). The builder alternative is configureAutomaticModelHandling(AutomaticModelHandling.DISABLED); @Model and @Apply also allow explicit overrides. An explicit apply setting wins over the Model setting, then the application default; therefore an explicitly enabled apply needs its own ownership review.
Consumer rules still apply
Section titled “Consumer rules still apply”Automatic commands use normal tracked consumers. Put @Consumer on the command type or its package to give it a stable group and configure threads, retries and segmentation. For example, add @Consumer(name = "project-commands") to the CompleteTask declaration above. A consumer on a separate Model class does not automatically give every self-applying command that consumer.
Replicas of the same logical command owner share a consumer position and divide its segments. Distinct consumers each receive their own delivery; assigning two different names does not prevent duplicate command handling. Without an explicit consumer, the configured unconfigured-handler policy applies: legacy application grouping, per-handler grouping or per-package grouping. See message tracking for the versioned defaults and overrides. @Consumer configures tracking; it is not a Spring component-registration annotation.
Calling the pipeline from your own handler
Section titled “Calling the pipeline from your own handler”Keep an explicit handler when it has real work to coordinate. Invoke the Model pipeline with Fluxzero.assertAndApply(command) rather than manually loading and updating each Model. Disable the automatic entry point for that command when the explicit handler owns it. The same command can still contain its applies:
record CompleteTask(TaskId taskId) { @Apply(automaticHandling = AutomaticModelHandling.DISABLED) Task apply(Task task) { return task.withCompleted(true); }}
@HandleCommandvoid handle(CompleteTask command) { Fluxzero.assertAndApply(command); // Other orchestration belongs here, outside retryable applies.}data class CompleteTask(val taskId: TaskId) { @Apply(automaticHandling = AutomaticModelHandling.DISABLED) fun apply(task: Task) = task.copy(completed = true)}
@HandleCommandfun handle(command: CompleteTask) { Fluxzero.assertAndApply(command) // Other orchestration belongs here, outside retryable applies.}This example shows the explicit alternative, not an extra handler to add beside the automatic one.
| API | What it does |
|---|---|
sendCommandAndWait(command) | Dispatches through normal command routing and waits for the result. Separately dispatched commands remain separate operations. |
Fluxzero.assertAndApply(command) | Runs assertions and applies locally through the Model pipeline; related changes share the operation’s commit. |
Fluxzero.assertLegal(command) | Checks legality only. It applies no changes and commits nothing; later execution must still validate its own state. |
An example: home automation
Section titled “An example: home automation”The Home example exercises these distinctions with recursive Spaces, independent Device observations and integration links. This additional example places a Model with two parents in its surrounding hierarchies. Distinguish persisted relationships from ordinary references or application selections. A scene referencing a device does not make the scene the device’s lifecycle owner.
One Model can belong in two views
Section titled “One Model can belong in two views”HomeAssistantDevice has two parents: its Device and its integration connection. Deleting either owner can delete the link without deleting the other parent. The link remains one independently persisted Model when reached through either Graph. Combining desired device changes in a Model operation commits those intentions atomically; physical device delivery is outside that storage guarantee.
Supporting capabilities in 2.0
Section titled “Supporting capabilities in 2.0”- Flexible identity. Parent-scoped identities, one-to-one companions, aliases and
Graph.functionalId()let storage identity and a domain-facing identifier serve their different purposes. - Stable Model names. The default is the simple class name, without the package.
@Model(name = "task")supplies an explicit logical name that can survive a class rename. Serialized state/event type changes still need their own compatible mapping or upcaster. Renaming a durable Model identity is a migration, not a refactor. - Polymorphic IDs. Properties such as
@Parent(types = {Project.class, Folder.class}) Id<?> parentIdretain the concrete type. Model IDs use their logical name; other polymorphic IDs use@class. Concrete ID properties remain scalar. Ambiguous historical scalar values need an explicit migration, not guessing. - Optional routing inference. Simple, unambiguous single-Model commands and events can use the canonical
Model ID when no explicit route is declared. Enable
fluxzero.model.automaticRouting=true, or opt into defaults version2026.09.10or later. Explicit routing declarations keep precedence; multi-target commands do not receive an arbitrary route. - Java 25 foundation. The SDK requires Java 25+ and uses explicit workers for its asynchronous work. This is not a claim that every JDK or dependency operation runs on virtual threads.
- Compression and historical reads. ZSTD is the default for documents and WebSocket transport. Historical LZ4 documents remain readable through the bounds-checked Java codec. Explicit LZ4 remains available; its encoding cost differs from the former native implementation.
- No-change event policy. Models default to
EventPublication.IF_MODIFIED: an apply that leaves state unchanged does not normally emit another Model event. ChooseALWAYSwhen that event is itself a meaningful notification. This is distinct from command-delivery deduplication.
Unconfigured handlers can also be grouped by exact package and message type, giving a domain package a shared consumer without annotating every handler:
fluxzero.tracking.unconfiguredHandlerConsumerMode=perPackageDefaults version 2026.07.27 or later selects this mode when there is no explicit override. Existing explicit
consumers still win; applications without a defaults-version opt-in retain their previous shared-application
default. Consumer boundaries determine ordering and failure isolation, so review this choice during an upgrade.
The improvements already delivered on the 1.x line remain part of 2.0 as well. The defining change here is the Model/Graph programming model, not a claim that every surrounding SDK feature is newly introduced.
Upgrading from 1.x
Section titled “Upgrading from 1.x”Upgrade the SDK; migrate your domain at your own pace
Section titled “Upgrade the SDK; migrate your domain at your own pace”Existing Aggregate applications remain supported. Moving to SDK 2.0 does not automatically convert their data into Models, and it does not require an immediate domain rewrite. New Model-based features and retained aggregate-based features can coexist while you choose a migration boundary.
This is a major-version upgrade, not a blanket source- or binary-compatibility promise. Rebuild application modules and custom SDK integrations, and qualify the behavior your application relies on.
- Move builds and deployments to Java 25 or newer. This includes Java and Kotlin JVM targets, test jobs, tools embedding SDK artifacts and application images. Preview features are not required.
- Align SDK artifacts at
2.0.0. Use the Fluxzero BOM where appropriate, including test-server/proxy dependencies rather than mixing SDK majors. The installation guide covers Maven/Gradle and Fluxzero Packages. - Update explicit extension points. A custom
Usermust implement the stableid()contract;getName()defaults to that ID but is not its replacement. Check custom search/document-store implementations against the typedSearch<R>API, and rebuild code that implements SDK interfaces. - Keep existing identities, serializers and upcasters in place. An SDK upgrade is not a reason to rename persisted event types or discard historical handlers. Historical LZ4 documents remain readable without a compression-only bulk rewrite.
- Choose defaults deliberately. Existing versioned-default opt-ins and dedicated property overrides still
matter. Model conflict handling defaults to RETRY for both creation and update; the Java baseline and ZSTD
defaults are 2.0 changes, not settings activated by
fluxzero.defaults.version. - Run application acceptance and reconstruction tests. Include authentication, retries, scheduling, stored history, search and custom integrations—not just compilation or newly created data.
For new Models, enable SDK annotation processing in each contract module (Kotlin: kapt). Preserve all generated Model indexes when assembling a shaded JAR; one module’s index must not overwrite another’s. Rebuild shared contract libraries so discovery works from a cold start.
Aggregate is a migration API
Section titled “Aggregate is a migration API”@Aggregate is the legacy/migration API and is annotated with @Deprecated(forRemoval = true).
It remains supported in 2.x and is scheduled for removal in SDK 3.0. Plan an explicit migration to Models
before upgrading to 3.0.
Java and Kotlin callers receive deprecation warnings, which can fail builds configured to treat warnings as errors.
Keeping this API available in 2.x lets existing stored applications continue to work while their owners plan
a data migration. Use independent @Model types for new domain state: each Model keeps
its history as relationships evolve, and an operation can still commit changes across several Models atomically.
Annotate every aggregate root. In SDK 2.x, all aggregate roots must declare or inherit @Aggregate,
including through an interface or superclass. Add it to previously unannotated roots in Java and Kotlin.
loadAggregate(...) and repository loads reject known unannotated root types; untyped loads validate the root
as soon as its type becomes known, including during creation or replay. A bare aggregate-value parameter also
requires the marker on its declared type for implicit handler injection. Ordinary members, independent @Model
types and side-effect-free asEntity(...) wrappers do not need @Aggregate. A typed loadEntity(...) lookup for a missing member
returns an empty entity with its requested ID and type, so isPresent() is false and loadEntityValue(...)
returns null. Create or change members through their owning root; an empty lookup does not make a member
an independent aggregate. Existing members can be looked up by functional ID and expected type in the same handler
before their owner commits, including @EntityId affixes. Removing a loaded member with update(value -> null)
updates its owner through the existing commit policy and may return null; retain the root handle for subsequent work.
These operations do not add event history to direct update calls.
@Member remains supported and is not deprecated. Its role is deliberately embedded state whose creation,
changes, history, retention and deletion all belong to its owner, including nested handlers in @Stateful objects.
Plain details values need no such annotation. Prefer independent Models joined by @Parent for recognizable
child lifecycles; a collection field alone is not a reason to choose members.
Applications that used prerelease member handling should reconstruct affected Model state from events if snapshots were produced while member handlers were skipped. Correct replay does not retroactively repair those snapshots. Likewise, a schema upcaster cannot backfill storage representations absent from a prerelease’s stored format; qualify that storage transition separately before switching writers.
Migrate an aggregate deliberately—not by renaming its annotation
Section titled “Migrate an aggregate deliberately—not by renaming its annotation”An aggregate’s embedded children, stream, snapshots and documents do not become independent Models by
changing @Aggregate to @Model. Decide what should become independent, which values remain embedded,
how identities map and which event contracts reconstruct the replacement state.
The migration can be organized into three steps:
1. Build the Models in a separate application
Section titled “1. Build the Models in a separate application”For the project-management example, put the replacement @Model Project and Task classes in a new
application. Keep the old aggregate application running as the business-write owner. The separate app
also lets you reuse a class name without trying to load the old Aggregate and its replacement Model in
the same classloader.
Teach the replacement Models how existing published events affect them. Suppose the old Project aggregate
published TaskAdded, with the contract shown below. The replacement Task can reconstruct itself from that
same event using a Model-side replay apply:
// The existing event contract, reused in the new app with its original serialized identity.record TaskAdded(TaskId taskId, ProjectId projectId, String title) {}
@Model(name = "task")@Withrecord Task(@EntityId TaskId taskId, @Parent(pathInParent = "tasks") ProjectId projectId, TaskDetails details, boolean completed) { @Apply static Task from(TaskAdded event) { return new Task(event.taskId(), event.projectId(), new TaskDetails(event.title()), false); }}// The existing event contract, reused in the new app with its original serialized identity.data class TaskAdded(val taskId: TaskId, val projectId: ProjectId, val title: String)
@Model(name = "task")data class Task( @EntityId val taskId: TaskId, @Parent(pathInParent = "tasks") val projectId: ProjectId, val details: TaskDetails, val completed: Boolean) { companion object { @JvmStatic @Apply fun from(event: TaskAdded) = Task(event.taskId, event.projectId, TaskDetails(event.title), false) }}The old event added an embedded Task; the new apply creates an independently stored Task with the same identity and Project relationship. You write this mapping; the migration runner supplies the original events and checkpoints progress. Include Project reconstruction and every subsequent update, move and deletion too. Preserve legacy serialized types and required upcasters rather than inventing new event identities for replay. The earlier command examples alone are not a mapping for different legacy event types.
PublishedEventModelMigration
then supplies the replay runner. In the new application’s migration entry point:
PublishedEventModelMigration migration = PublishedEventModelMigration.builder() .name("projects-to-models-v1") .client(migrationClient) .serializer(serializerWithLegacyUpcasters) .modelTypes(Project.class, Task.class) .build();
migration.run(args);val migration = PublishedEventModelMigration.builder() .name("projects-to-models-v1") .client(migrationClient) .serializer(serializerWithLegacyUpcasters) .modelTypes(Project::class.java, Task::class.java) .build()
migration.run(*args)Here migrationClient is a dedicated client connected to the same environment as the old app, and
serializerWithLegacyUpcasters is configured to read its published events. Include every replacement Model
in the catalog; .modelsInPackage("com.example.projects") is an alternative to listing them individually.
Keep the migration name stable across restarts.
Run this entry point without arguments to replay and keep following events. The runner owns an isolated migration context: it does not start ordinary application handlers or automatic Model command handling. Replay has durable progress, is idempotent and does not republish the historical events. It executes replay applies, not command assertions or interceptors. Compare the reconstructed values, relationships and query results with the old application before moving traffic.
2. Move handlers to the new application
Section titled “2. Move handlers to the new application”Move queries and read-only listeners first, changing aggregate lookups to the new Model/Graph reads. For listeners that still receive legacy events, configure the new application’s repository before starting those handlers:
Fluxzero.get().modelRepository() .followPublishedEventMigration("projects-to-models-v1");Fluxzero.get().modelRepository() .followPublishedEventMigration("projects-to-models-v1")This connects event-bound Model/Graph reads to the migration’s event mapping, with bounded waiting when replay is behind. It does not start the migration itself. Preserve consumer progress and avoid duplicate side effects while transferring listeners. Moved read-only listeners must not write back to legacy Aggregates.
Move command handlers and other business-write paths last. Prepare them in the new app, but keep them
inactive until the ownership switch. Pause the old app’s writes, drain in-flight operations, record the
final published event index and let replay reach it. Stop the replay runner, then start the same migration
entry point with adopt <cutover-event-index>. Equivalently, build a fresh runner with the same configuration
and invoke:
migration.run("adopt", Long.toString(cutoverEventIndex));migration.run("adopt", cutoverEventIndex.toString())cutoverEventIndex is that recorded final boundary, not an arbitrary timestamp. Adoption verifies durable
catch-up and adopts staged documents where applicable; it is resumable. Verify the resulting Models, Graphs
and queries, then enable the new application’s write handlers while the old ones stay disabled. There must
never be two independent business-write owners for the migrated state.
3. Switch off the old application
Section titled “3. Switch off the old application”Once the new app owns the migrated queries, listeners, commands and scheduled work, stop the old app and retire the migration runner. Check that no remaining route or worker depends on the old handlers. Keep the historical event contracts and data needed for reconstruction; switching off the app is not an instruction to erase its history.
Migration boundaries to keep explicit
Section titled “Migration boundaries to keep explicit”The route consumes the published event log. Legacy STORE_ONLY events are not recovered by that backfill.
Document-backed adoption verifies staged state before adopting it. Compare actual values and relationships,
not only counts, and validate application search behavior as well as replay.
Once ordinary Model writes begin, recovery is forward-only: there is no generic switch back to the former aggregate representation. Plan that cutover explicitly. A simple maintenance-window migration may be a better fit for a small application; the API does not make every project require a live migration.
Finally, distinguish reading old data from changing stored search data. An upcaster can move
name into details.name while preserving its value, but it does not automatically rewrite existing query
paths. Test searches before and after the appropriate reindexing, and retain event sourcing when historical
comparisons are part of the application contract.
The Model and Graph API at a glance
Section titled “The Model and Graph API at a glance”Start with @Model, an @EntityId and the relationships the domain actually needs. The settings below are
choices to make deliberately, not a checklist of annotations every Model should carry. The examples above
show the normal route; this overview makes the available controls discoverable in one place.
Different boundaries for different jobs
Section titled “Different boundaries for different jobs”Do not infer lifecycle ownership from JSON nesting, or mutation atomicity from a shared parent. Use independent Models for independently changing state and ordinary values for data wholly owned by a Model. @Member remains supported for deliberate embedding; @Aggregate remains the legacy/migration API.
Choose the following boundaries independently, then configure the places where they must interact.
| Boundary | Determined by | Does not by itself imply |
|---|---|---|
| Identity and retained history | The independent Model and its storage ID | The same lifecycle as its parent |
| Atomic mutation | The operation’s writes and inspected read dependencies | Every reachable Graph node participates |
| Navigable relationship | A declared @Parent edge | A composed JSON property |
| Composed output | pathInParent and the selected Graph view | A stored whole-Graph document |
| Stored search view | Explicit searchable, per-node SearchSettings and optional Graph projection settings | Historical or transactional reads |
| Logical cascade | Parent ownership and deleteOnParentDeletion | Physical erasure of retained history |
Annotations and their roles
Section titled “Annotations and their roles”| Annotation | Role in the 2.0 model |
|---|---|
@Model | An independent state, persistence and lifecycle boundary. |
@Parent | A directed relationship to another Model. pathInParent opts the edge into composition, propagateSearch gates inherited search, validateAncestors gates inherited assertions, and deleteOnParentDeletion controls lifecycle ownership. required, maxChildren, and preventParentDeletion are SDK validation shorthands. It also declares ownership for scheduled payloads. |
@GraphProperty | A serialized value derived from the current node or its typed ancestor Graphs. Its optional value sets the JSON property name. |
@SearchSettings | Per-node search collection, temporal paths and descendant scope, nested in @Model(searchSettings = ...). |
@GraphProjection | Settings for the complete materialized view, nested in @Model(graphProjection = ...). |
@GraphPathOverride | A projection-local replacement of a relationship path; does not change the domain relation. |
@EntityId, @Alias | Primary and alternative identity, including prefixes and scoped identity. |
@Apply, @InterceptApply, @AssertLegal | Apply, compose and validate domain operations using Models and Graphs. |
@Association | Disambiguate an injected reference when ordinary property/typed-ID binding is insufficient. |
@Deadline | Derive one-shot scheduled work from committed Model state and context. |
@HandleEvent, @HandleNotification, @HandleDocument | Observe event-bound Graph changes or maintained document views. |
@FilterContent, @ApiDocResponse | Control audience-facing content and describe composed API responses. |
@Member | Retain intentionally embedded state that shares its owner’s lifecycle; it is not another independent Model. |
Some of these annotations are familiar from 1.x. Their participation in the Model/Graph pipeline is new; you do not need a parallel validation or messaging vocabulary.
Upgrading existing 2.x search configurations
Section titled “Upgrading existing 2.x search configurations”This contract requires coordinated Runtime and writer upgrades. Existing applications should set searchable = true only on Models that independently activate search and replace the removed DocumentProjection, materializeGraph, HandleDocument.modelGraph and HandleDocument.modelState settings. Keep canonical collection identities stable; preserve DOCUMENT-only state and its head proofs. Missing node documents need a customer-controlled Graph.reindex() backfill from current authoritative state; use a fixed consumer cutoff after draining old writers and materializations. The operation preserves Model history and refreshes one node at a time. AWAIT completion also waits for affected stored Graph projections, including ancestors, so queries after reindex() see the refresh. Existing indexes need reindexing, and registered Graph definitions must be updated explicitly. Stopping an old projector in one process does not remove its durable Runtime definition.
Follow the complete upgrade procedure before resuming writers or exposing the changed queries. Reading the old stored definition format is supported; concurrent old/new Model writers are not.
All Model settings
Section titled “All Model settings”These are the settings on @Model. DEFAULT policy values inherit wider configuration; they are not an
additional behavior alongside the resolved policy.
| Setting | Default | Choose it for |
|---|---|---|
name | Simple class name | A stable logical identity, e.g. name = "project", independent of Java packaging. |
persistence | {EVENT_SOURCED} | Event-sourced state, DOCUMENT-only current state, or both. History needs event sourcing. |
conflictPolicy | DEFAULT → application default RETRY | RETRY, FAIL or deliberately retained decisions with ACCEPT. |
automaticHandling | DEFAULT | Inherit, enable or disable automatic command handling for eligible Model applies. |
commitPolicy | DEFAULT → ASYNC_AFTER_HANDLER_AWAIT_AFTER_BATCH | When commits start and are awaited. Batch coordination does not combine independent commands into one transaction. |
eventPublication | IF_MODIFIED | Emit only when modified, ALWAYS, or NEVER where the persistence contract allows it. |
publicationStrategy | DEFAULT | Where produced events are stored/published. Event-sourced changes must retain reconstructing events. |
ignoreUnknownEvents | false | Explicit replay compatibility policy; ignoring an event is not generally a safe solution to a missing contract. |
snapshotPeriod | 0 | Opt into periodic snapshots after measuring replay cost. Zero disables periodic snapshots. |
maxSnapshotCount | 1 | How many snapshots to retain: negative means unlimited, zero keeps one. Does not limit event history. |
cached | true | Shared application caching of the latest Model state. Commit-local state is a separate concern. |
cachingDepth | 1 | Older versions retained in cache: 0 keeps only current; -1 retains all available cached versions. This is not durable history retention. |
checkpointPeriod | 100 | Intermediate replay checkpoints within a reconstruction session, not extra stored document versions. |
searchable | false | Set to true to request indexed canonical node documents; the scope includes descendants by default. |
searchSettings | @SearchSettings | Configure this node’s collection, temporal fields and descendant scope, including when searchability is inherited. |
graphProjection | @GraphProjection(mode = NONE) | Independent from searchability. NONE stores no whole Graph and composes searchable nodes live; ASYNC/AWAIT additionally materialize the complete Graph. |
Changing persistence or durable identities on existing data deserves a migration plan. Enabling a cache, snapshot or projection does not redefine which object owns a domain lifecycle.
Parent relationships and identity settings
Section titled “Parent relationships and identity settings”@Parent setting | Default | Meaning |
|---|---|---|
value | Unspecified | Explicit parent class for an untyped reference, e.g. @Parent(Project.class) String projectId; typed IDs normally supply it. |
types | Empty | Allowed Model types for a polymorphic Id<?>; mutually exclusive with value. |
pathInParent | Empty | Optional slash-separated composition path, e.g. "tasks". Its final segment is a collection, even for a one-to-one companion. |
deleteOnParentDeletion | true | Owning cascade. false keeps a normal non-owning Graph relation; it does not block parent deletion. |
required | false | Require at least one referenced parent and require every referenced parent to exist in the final Model commit. |
maxChildren | -1 | Maximum distinct children of this logical Model type and path per parent. A nonnegative value enables the SDK shorthand. |
preventParentDeletion | false | Reject logical deletion while this child relation survives; this edge no longer cascades. |
validateAncestors | true | Allow cascading @AssertLegal rules to follow this route. false stops only inherited validation on this edge and subtree. |
apiDoc | @ApiDoc | Describe the composed relationship in Graph-aware OpenAPI; not a runtime content filter. |
A plain typed ID is only a reference until @Parent declares a Graph edge. A null parent reference detaches
that edge. A Model may have multiple parents, including parents of its own type for recursive structures;
concrete cycles are rejected. Each edge has its own ownership policy.
required, maxChildren, and preventParentDeletion are SDK shorthands built on ordinary Model and relationship
reads. Concurrent changes therefore use the same conflict and retry behavior as an equivalent Graph assertion; no
constraint policy is installed in the Runtime. Keep every enforcing writer on the supporting SDK and package its
generated Model index. Use an explicit domain assertion for richer decisions or custom failures.
@EntityId has prefix = "", postfix = "" and parentScoped = false. Affixes affect repository identity,
not the stored functional value. Parent-scoped IDs combine the functional ID with one non-null parent: moving
such a Model changes its repository identity. Use a stable global identity when moves must preserve it.
@Alias has optional prefix and postfix too; its current aliases are replaced atomically with state and
must be unique across independent Models. An alias is not another Model or another lifecycle.
Search and Graph projection settings
Section titled “Search and Graph projection settings”| Configuration | Settings and defaults |
|---|---|
@SearchSettings | includeDescendants = true; blank collection derives an internal type-isolated name; optional timestampPath and endPath select node document time fields. |
@GraphProjection | mode = NONE by default: no complete Graph is stored and searchable nodes are composed live. ASYNC materializes a complete Graph without normally waiting; AWAIT materializes it and waits for affected updates. Blank collection derives the Graph collection; pathOverrides = {} leaves canonical paths unchanged. |
@GraphPathOverride | Required path and projectionPath: replace a canonical path in this Graph search view, both live and stored. |
For example, keep the relationship named tasks while placing it differently in one stored view:
@Model( searchable = true, name = "project", graphProjection = @GraphProjection(mode = GraphProjectionMode.ASYNC, collection = "project-views", pathOverrides = { @GraphPathOverride(path = "tasks", projectionPath = "work/tasks") }))record Project(@EntityId ProjectId projectId, ProjectDetails details) {}@Model( searchable = true, name = "project", graphProjection = GraphProjection(mode = GraphProjectionMode.ASYNC, collection = "project-views", pathOverrides = [ GraphPathOverride(path = "tasks", projectionPath = "work/tasks") ]))data class Project(@EntityId val projectId: ProjectId, val details: ProjectDetails)Searchability is not an authorization boundary. A Model with no effective search activation cannot use typed node or relationship search; identity-based Model/Graph loading remains available. A local searchable = false does not veto a searchable ancestor’s composed scope. Low-level collection access remains separate. Do not use a search setting as protection for secrets.
Working with the Graph interface
Section titled “Working with the Graph interface”Graph<T> combines a Model value, identity, relationship context and a read boundary. It is not a mutable
container that must be saved wholesale. ModelRepository owns loading and commits; the Fluxzero facade
provides the common entry points. Id<T> carries identity plus the referenced Model type.
| Need | Main Graph methods |
|---|---|
| Inspect the root value | get(), optional(), isPresent(), isEmpty(), map(...), orElse(...) |
| Inspect identity/type | id(), functionalId(), modelName(), type(), knownType(), aliases() |
| Navigate upward | parent(), parents(), parent(Type.class), ancestor(Type.class) and value-returning parentModel / ancestorModel |
| Navigate downward | children(...), descendants(...), childModels(...), descendantModels(...); scope by class and/or path |
| Address locally unknown types | namedChildren(...), namedDescendants(...) and name/path overloads with explicit knownOnly selection |
| Find or traverse nodes | find(...), stream(), root(), relationshipPath(); GraphLookupPolicy controls ambiguous typed lookups |
| Shape a response | selectPaths(...), filterNodes(...), filterBranches(...), withContext(...) / context(...) |
| Inspect time and change | stateIndex(), revisionStateIndex(), sequenceNumber(), timestamp(), previous(), revisions(), hasChanged(...), previousValue(...) |
| Choose a different view | current(), atStateIndex(...), playBackToEvent(...), playBackToCondition(...) |
| Stage a change | apply(...), update(...), delete(), then the appropriate commit path |
| Validate and execute | assertLegal(...), assertAndApply(...), commit() |
| Perform an independent revision-checked update | compareAndSet(...), updateAndGet(...), getAndUpdate(...) |
The distinction between staging, executing an asserted domain operation and direct atomic replacement
matters: update(...) is not an automatic business assertion, and the CAS helpers do not run a command’s
apply pipeline. Likewise, current() gives a current view without mutating the historical Graph you held.
During a Model mutation, Graph reads use its shared attempt boundary rather than mixing unrelated snapshots.
Historical values require retained event-sourced history; caching does not give DOCUMENT-only Models a history.
The Compose and update related Models and
loading guide cover these operations
and their full overloads. The policy types—ModelPersistence, ModelConflictPolicy, ModelCommitPolicy,
AutomaticModelHandling, GraphProjectionCompletion and GraphLookupPolicy—make these separate choices
explicit instead of treating “a Model” as one fixed bundle of storage, consistency and query behavior.
Where to go next
Section titled “Where to go next”- Design Model state: lifecycle boundaries, details values and intentionally embedded members.
- Choose Model and Graph queries: configuration, query examples and consistency in one place.
- Compose and update related Models: companions, derived preferences, atomic operations and orchestration.
- Test Model migrations: value-preserving upcasts, reconstruction and retained storage.
- Bind schedules to their owners.
- Derive one-shot work from Model state.
© 2026 Fluxzero