Skip to content

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.

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:

@Aggregate
record Project(@EntityId String projectId, @Member List<Task> tasks) {}
@With
record Task(@EntityId String taskId, boolean completed) {
@Apply
Task apply(CompleteTask command) {
return withCompleted(true);
}
}
record CompleteTask(String taskId) {}
@HandleCommand
void complete(CompleteTask command) {
Fluxzero.loadEntity(command.taskId(), Task.class).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 declarationState boundaryRelationship context
Entity<T> for an embedded @MemberIts containing Aggregate or ModelThe member’s position inside that stored value
Entity<T> for an independent @ModelThat Model’s own persisted stateParent references do not embed parent state
Graph<T>A view over independently stored ModelsNavigation 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.

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")
@With
record 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); }
}

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.

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);
}
}

Publish the command as usual:

Fluxzero.sendCommandAndWait(new CompleteTask(taskId));

For this example, the SDK carries the operation through the following steps:

  1. Receive CompleteTask.
  2. Resolve the Task and any Models/Graphs requested by the operation.
  3. Run assertions and applies against the attempt’s read state.
  4. Check conflicts and atomically commit the Model changes.
  5. 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.

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) {}

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());
}
}

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();
}
}

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.

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.

@Model
record 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);
}
}

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.

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")
@With
record 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); }
}
Task belongs to Project and Sprint, but only Project owns its lifetime Delete Sprint Delete Project Project Sprint Task A Task Aremains Projectremains Task Adeleted Sprintremains Owns lifetimeNavigation only
Dotted arrows point to parents. Each deletion scenario starts with both parents present. Deleting a parent never deletes the other parent through Task.

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:

OperationProjectTaskSprint
Delete SprintRemainsRemains, with its Project relationshipDeleted
Delete ProjectDeletedDeleted by the ownership cascadeRemains, 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 fieldGraph edgeDeleted with parentPlacement in composed output
Plain typed IDNoNoNone
@ParentYesYesNo named child path
@Parent(deleteOnParentDeletion = false)YesNoNo named child path
@Parent(pathInParent = "tasks")YesYestasks
@Parent(pathInParent = "tasks", deleteOnParentDeletion = false)YesNotasks

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.

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);
}
}

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.

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")));
}
}

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.

StepWhat this attempt seesWhat has been stored?
Assert AddTask AThree existing children: room for ANothing new
Apply AddTask AProject value is injected; Task A and its parent relation are stagedNothing new
Assert AddTask BFour children, including staged Task A: room for BNothing new
Apply AddTask BTask B and its parent relation are stagedNothing new
Validate and commitCheck the consumed Project state and membership against concurrent changesBoth 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.

A Model operation stages both actions, validates consumed reads and commits once, or retries from a fresh boundary Read + assert AStage Task A Read + assert BSees staged A Validateconsumed reads Commit A + Btogether Fresh attemptRead state again OKConflict → RETRYUntil commit, A and B are staged inside this operation only.
A rejected assertion discards the attempt. A commit conflict can retry the entire operation; there is no commit between A and B. External effects are outside this retry loop.

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 attemptRead for the decisionWritten by this operation
Project valueThe Project injected into the applyNo
Project’s Task membershipFour existing children; the limit is fiveA new relationship is added
Existing Task valuesNot needed to count childrenNo
New TaskNo existing valueCreated

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 policyWhat 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.
FAILReturn the conflict to the caller.
ACCEPTKeep 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.

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:

MechanismWhat it controlsWhat it does not guarantee
Route commands by ProjectCommands on that route are handled seriallyProtection from writers outside that route
Route commands by TaskIndependent Tasks can use different routesCorrectness of a rule that also reads Project membership
Track reads and validate the Model commitConcurrent changes to consumed state invalidate the decisionParallel 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.

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:

@Model
record Project(@EntityId ProjectId projectId, boolean closed) {
@AssertLegal(cascade = true)
void assertOpen() {
if (closed) {
throw ProjectErrors.closed;
}
}
}
@Model
record Task(@EntityId TaskId taskId, @Parent ProjectId projectId,
TaskDetails details, boolean completed) {}

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.

Cascading assertion reference

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);
}
}
@Model
record Order(@EntityId OrderId orderId, InventoryId inventoryId, OrderDetails details) {}
record OrderDetails(int quantity) {}
@Model
@With
record Inventory(@EntityId InventoryId inventoryId, int available) {}

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.

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.

Conflict validation precedes an atomic Model commit; Graph projection follows it One atomic storage commit Stage Model changesTrack consumed reads Validate conflicts Model events Authoritative state Relationships Update Graph projections
Persisted Model events, authoritative state and relationship changes commit together according to each Model’s persistence settings. Graph projections are maintained separately. Outgoing HTTP and payments are outside this boundary.

The projection-completion contract specifies when the caller also waits for the composed search view.

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);
}
}

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 choiceWhat changing the parent means
Stable Task ID, separate Project referenceUpdate the relationship. Task history and descendant references keep the same anchor.
Task identity derived from its ProjectChange the Task’s identity too. Treat this as an explicit identity migration, not an ordinary move.
Stable Task ID, optional Project reference clearedDetach 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.

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")
@With
record 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")
@With
record Project(@EntityId ProjectId projectId, ProjectDetails details,
TaskId lastCompletedTaskId) {}

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.

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();
}
}

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();
}
}

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.

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);

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);

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.

MechanismEmbedded AggregateIndependent Models
Event replayRoot history, including other members’ changesHistory of each Model actually read
SnapshotThe combined root valueThe configured Model’s own value
CacheRoot state retains its embedded object treeEach cached Model retains its own state
Direct searchable document@Aggregate(searchable = true) indexes the combined root valueEffective Model searchability indexes only that Model’s own state
Composed search documentEmbedded state is already part of the rootOpt 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.

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) {}

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.

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.

ConfigurationAuthoritative stateNode documentWhole Graph stored?
@Model, with or without a parent path, outside any searchable ancestor scopeEventsNoneNo
DOCUMENT, no effective searchabilityCurrent internal documentOne unindexed internal documentNo
@Model(searchable = true)EventsOne indexed current representation per included nodeNo: live composition
DOCUMENT and effective searchabilityCurrent canonical node documentThe same document, indexedNo: live composition
Searchable plus graphProjection.mode = ASYNC or AWAITUnchanged persistence policySame indexed nodesYes: 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(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();

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();

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();

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:10State you receive
Graph<Task> injected into the completion event handlerCompleted, at the 10:00 commit boundary. Related event-sourced nodes are read at that same boundary.
task.previous() in that handlerThe state just before completion: Task A was open.
task.current() or loadCurrentGraph(taskId) outside a mutationA fresh current view: Task A is open after the 10:05 change.
Task or Graph<Task> injected into a queryCurrent authoritative state, not the old event’s state.
Injected Models/Graphs inside an assertion or applyThe mutation attempt’s boundary plus its earlier staged changes; consumed reads participate in conflict detection.
Document or Graph searchSearch-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:

@HandleEvent
void 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.
}

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.

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.

One vertical read boundary selects the latest available revision on each Model timeline revision 10 revision 20 revision 30 revision 40 Read boundary: 3010203040ProjectNoteTask
At boundary 30, Project uses revision 10, Note uses 20 and Task uses 30. Task revision 40 exists in storage but is outside this view. Indexes are illustrative.

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:

@HandleEvent
void 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.
}

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:

@HandleDocument
void projectViewChanged(Graph<Project> 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.

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:

Graph transition 1: create Task A ProjectWebsite launch Task A · Write copyopen No Task B yet
Task A is created below an existing Project. Task A has no previous value; the Project’s previous Graph exists and has no Tasks.

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());

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.

The project application owns Task commands; the notes application owns the InternalNote extension Project application Notes application TaskOwns Task commands InternalNoteOwns Note commands Note IDs + logical namesNo InternalNote class Shared Task read contractNo Task command handlers Same Model repository · different registered behavior
The child-to-parent arrow is a shared relationship. Each application registers its own write behavior; discovering a child does not require its value 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) {}

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.

CapabilityProject applicationNotes application
Read Task valuesHas Task’s class and replay contractsNeeds the shared Task class and the contracts for the chosen read path
Read InternalNote valuesCannot deserialize the unavailable classHas InternalNote and its replay behavior
Discover Notes beneath TaskCan inspect child IDs and logical namesCan navigate typed Notes
Handle Task commandsRegisters the Task command behaviorDoes not register it merely to read Task
Handle Note commandsDoes not register the extension’s writesRegisters 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();

internalNote 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);

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:

@Model
record BookingHold(@EntityId HoldId holdId, Instant expiresAt, boolean confirmed) {
@Deadline
Schedule expiry() {
return confirmed ? null : new Schedule(new 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));

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);

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;
}
}

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.

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 ModelCurrent valueRetained history
Mark it completedStill present, with completed stateStill retained
Logically delete itRemovedRetained for reconstruction
Store a snapshotUnchangedStill retained; the snapshot speeds reconstruction
Execute a physical deletion planSelected Model data erasedSelected 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

Deleting 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:

A physical deletion plan with cascade NONE selects only Approval Outside this deletion plan planDeletion(approvalId, NONE) ApprovalSelected for erasure Task · Project · Notes
Selection follows the deletion plan. A relationship to Task does not make Task, Project or sibling Notes part of this erasure.
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();

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.

After the application/operator confirms that exact plan, execute it:

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));
}

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");
}

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);
}
}

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);
}
}

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.

Graph context can also supply fields that should not be stored on every child. For example, extend Task with:

@GraphProperty
String projectName(Graph<Project> project) {
Project parent = project.get();
return parent == null ? null : parent.details().name();
}

Each 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.

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) {}

Opt the query handler into content filtering:

record GetProject(ProjectId projectId) implements Request<JsonNode> {
@HandleQuery
@FilterContent
Graph<Project> handle() {
return 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:

@ApiDoc
class ProjectApi {
@ApiDocResponse(status = 200, modelGraph = Project.class)
@HandleGet("/projects/{projectId}")
Graph<Project> get(@PathParam("projectId") String projectId) {
return Fluxzero.loadGraph(new 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

The 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) {}

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();

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) {}
Default command completion and AWAIT differ in when the projection must be ready Model commit Caller completes Projection catches up Model commit Projection ready Caller completes Default · projection can still be catching upAWAIT · projection has reached this commit
By default, completion does not wait for the projection. AWAIT delays successful completion until affected projections reach this command’s committed boundary. It does not wait for arbitrary event handlers.

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.

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:

Project changes schema while its Task nodes keep their own revisions Stored schema After upcasting Project · schema revision 0name: "Launch" Project · schema revision 1details.name: "Launch" Task A · schema revision 3 Task A · schema revision 3 Task B · schema revision 3 Task B · schema revision 3
Project’s upcaster moves name into details.name. Both Task nodes remain at schema revision 3; evolving the root does not force a new child schema.

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;
}
}

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.

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;
}
}

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();

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:

HandlerRepresentation it evolvesWhat that enables
@HandleDocument with Graph<Project>Evolved verified current nodes in every mode, then affected projectionsUpdated node, live Graph and stored Graph paths
@HandleDocument(graphMigration = GraphMigrationTarget.PROJECTION) with Graph<Project>Only the handled ASYNC/AWAIT composition; observational at NONEA deliberate derived-only migration
@HandleDocument(source = DocumentSource.MODEL_STATE)The verified internal current Model sourceUpdated source paths for live composition and related-content searches
@HandleDocument with ProjectThe same canonical source, requiring effective searchabilityUpdated 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;
}
}

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();

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();

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.

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:

@HandleCommand
void handle(CompleteTask command) {
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.

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);

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.

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);
}
}
@HandleCommand
void handle(CompleteTask command) {
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.

APIWhat 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.

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.

An integration link joins two hierarchies through child-to-parent relationships Home Space Home Assistantconnection Device Device status Integration link
Dotted arrows point from child to parent. The Integration link has one identity and two parent references. Its Device and connection remain independent Models.

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.

  • 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<?> parentId retain 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 version 2026.09.10 or 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. Choose ALWAYS when 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=perPackage

Defaults 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.

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.

  1. 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.
  2. 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.
  3. Update explicit extension points. A custom User must implement the stable id() contract; getName() defaults to that ID but is not its replacement. Check custom search/document-store implementations against the typed Search<R> API, and rebuild code that implements SDK interfaces.
  4. 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.
  5. 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.
  6. 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 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:

Migration separates reconstruction, handler cutover and retirement 1 · Reconstruct 2 · Move handlers 3 · Retire Define ModelsReplay old events Move readersSwitch writers Stop old appNew app owns flow Old app writes One write owner New app writes
The old application remains the business-write owner during reconstruction. Switch writers once, after qualifying the new state and read paths; then retire the old application.

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")
@With
record 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 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);

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.

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");

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));

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.

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.

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.

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.

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.

BoundaryDetermined byDoes not by itself imply
Identity and retained historyThe independent Model and its storage IDThe same lifecycle as its parent
Atomic mutationThe operation’s writes and inspected read dependenciesEvery reachable Graph node participates
Navigable relationshipA declared @Parent edgeA composed JSON property
Composed outputpathInParent and the selected Graph viewA stored whole-Graph document
Stored search viewExplicit searchable, per-node SearchSettings and optional Graph projection settingsHistorical or transactional reads
Logical cascadeParent ownership and deleteOnParentDeletionPhysical erasure of retained history
AnnotationRole in the 2.0 model
@ModelAn independent state, persistence and lifecycle boundary.
@ParentA 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.
@GraphPropertyA serialized value derived from the current node or its typed ancestor Graphs. Its optional value sets the JSON property name.
@SearchSettingsPer-node search collection, temporal paths and descendant scope, nested in @Model(searchSettings = ...).
@GraphProjectionSettings for the complete materialized view, nested in @Model(graphProjection = ...).
@GraphPathOverrideA projection-local replacement of a relationship path; does not change the domain relation.
@EntityId, @AliasPrimary and alternative identity, including prefixes and scoped identity.
@Apply, @InterceptApply, @AssertLegalApply, compose and validate domain operations using Models and Graphs.
@AssociationDisambiguate an injected reference when ordinary property/typed-ID binding is insufficient.
@DeadlineDerive one-shot scheduled work from committed Model state and context.
@HandleEvent, @HandleNotification, @HandleDocumentObserve event-bound Graph changes or maintained document views.
@FilterContent, @ApiDocResponseControl audience-facing content and describe composed API responses.
@MemberRetain 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.

These are the settings on @Model. DEFAULT policy values inherit wider configuration; they are not an additional behavior alongside the resolved policy.

SettingDefaultChoose it for
nameSimple class nameA 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.
conflictPolicyDEFAULT → application default RETRYRETRY, FAIL or deliberately retained decisions with ACCEPT.
automaticHandlingDEFAULTInherit, enable or disable automatic command handling for eligible Model applies.
commitPolicyDEFAULT → ASYNC_AFTER_HANDLER_AWAIT_AFTER_BATCHWhen commits start and are awaited. Batch coordination does not combine independent commands into one transaction.
eventPublicationIF_MODIFIEDEmit only when modified, ALWAYS, or NEVER where the persistence contract allows it.
publicationStrategyDEFAULTWhere produced events are stored/published. Event-sourced changes must retain reconstructing events.
ignoreUnknownEventsfalseExplicit replay compatibility policy; ignoring an event is not generally a safe solution to a missing contract.
snapshotPeriod0Opt into periodic snapshots after measuring replay cost. Zero disables periodic snapshots.
maxSnapshotCount1How many snapshots to retain: negative means unlimited, zero keeps one. Does not limit event history.
cachedtrueShared application caching of the latest Model state. Commit-local state is a separate concern.
cachingDepth1Older versions retained in cache: 0 keeps only current; -1 retains all available cached versions. This is not durable history retention.
checkpointPeriod100Intermediate replay checkpoints within a reconstruction session, not extra stored document versions.
searchablefalseSet to true to request indexed canonical node documents; the scope includes descendants by default.
searchSettings@SearchSettingsConfigure 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 settingDefaultMeaning
valueUnspecifiedExplicit parent class for an untyped reference, e.g. @Parent(Project.class) String projectId; typed IDs normally supply it.
typesEmptyAllowed Model types for a polymorphic Id<?>; mutually exclusive with value.
pathInParentEmptyOptional slash-separated composition path, e.g. "tasks". Its final segment is a collection, even for a one-to-one companion.
deleteOnParentDeletiontrueOwning cascade. false keeps a normal non-owning Graph relation; it does not block parent deletion.
requiredfalseRequire at least one referenced parent and require every referenced parent to exist in the final Model commit.
maxChildren-1Maximum distinct children of this logical Model type and path per parent. A nonnegative value enables the SDK shorthand.
preventParentDeletionfalseReject logical deletion while this child relation survives; this edge no longer cascades.
validateAncestorstrueAllow cascading @AssertLegal rules to follow this route. false stops only inherited validation on this edge and subtree.
apiDoc@ApiDocDescribe 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.

ConfigurationSettings and defaults
@SearchSettingsincludeDescendants = true; blank collection derives an internal type-isolated name; optional timestampPath and endPath select node document time fields.
@GraphProjectionmode = 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.
@GraphPathOverrideRequired 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) {}

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.

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.

NeedMain Graph methods
Inspect the root valueget(), optional(), isPresent(), isEmpty(), map(...), orElse(...)
Inspect identity/typeid(), functionalId(), modelName(), type(), knownType(), aliases()
Navigate upwardparent(), parents(), parent(Type.class), ancestor(Type.class) and value-returning parentModel / ancestorModel
Navigate downwardchildren(...), descendants(...), childModels(...), descendantModels(...); scope by class and/or path
Address locally unknown typesnamedChildren(...), namedDescendants(...) and name/path overloads with explicit knownOnly selection
Find or traverse nodesfind(...), stream(), root(), relationshipPath(); GraphLookupPolicy controls ambiguous typed lookups
Shape a responseselectPaths(...), filterNodes(...), filterBranches(...), withContext(...) / context(...)
Inspect time and changestateIndex(), revisionStateIndex(), sequenceNumber(), timestamp(), previous(), revisions(), hasChanged(...), previousValue(...)
Choose a different viewcurrent(), atStateIndex(...), playBackToEvent(...), playBackToCondition(...)
Stage a changeapply(...), update(...), delete(), then the appropriate commit path
Validate and executeassertLegal(...), assertAndApply(...), commit()
Perform an independent revision-checked updatecompareAndSet(...), 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.


© 2026 Fluxzero