Skip to content

Model relations and embedded members

Use separate Models for related state with independent Model lifecycles. @Parent registers relations for Graph navigation, composition and parent-deletion policies.

For example, an order line belongs to an order and has a non-owning relation to a product:

@Model
record Order(
@EntityId OrderId orderId,
String customerName) {
}
@Model
record LineItem(
@EntityId LineItemId lineItemId,
@Parent(pathInParent = "lines") OrderId orderId,
@Parent(deleteOnParentDeletion = false) ProductId productId,
int quantity) {
}

Here LineItemId, OrderId and ProductId are typed Id<T> values for their respective Models. The same line belongs to its order and has a non-owning Graph relation to its product. Deleting the order cascades to the line; deleting the product does not. Both relations support typed Graph navigation. The product edge has no pathInParent, so it is not automatically included in a product’s composed document. Leave off @Parent on productId instead when only the reference value is needed, without Graph navigation.

IntentModeling
Store an ID without registering a Graph relationA plain typed ID, without @Parent
Register a Graph relation with cascade deletion@Parent (the default deleteOnParentDeletion = true)
Register a Graph relation without cascade deletion@Parent(deleteOnParentDeletion = false)

A typed ID alone does not create a Graph edge. Adding @Parent makes the relation available to Graph navigation; pathInParent separately chooses whether to include it at a named document/serialization path.

deleteOnParentDeletion = false does not prevent the referenced Model from being deleted. Add preventParentDeletion = true when a surviving child must reject logical deletion. The SDK evaluates that shorthand through ordinary conflict-checked Graph reads; use an explicit assertion for a richer domain decision. @Parent is not an unrestricted foreign-key annotation: concrete cycles between Model IDs are rejected, including cycles containing non-owning edges. Read the complete Parent shorthand contract.

LineItem has its own lifecycle and history. Updating it does not load or rewrite the order. The named parent path lets Fluxzero include it in the order’s composed Graph.

The relation may point to the same Model type, enabling recursive Graphs such as folders:

@Model
record Folder(
@EntityId FolderId folderId,
@Parent(pathInParent = "folders") FolderId parentFolderId,
FolderDetails details) {
}

This is a relation between independently stored Folder Models, not an embedded recursive value. Fluxzero permits an arbitrarily deep concrete tree and rejects a write atomically if it would introduce a cycle.

The relation is ordinary model state:

record MoveLine(
LineItemId lineItemId,
OrderId newOrderId) {
@Apply
LineItem apply(LineItem line) {
return new LineItem(
line.lineItemId(),
newOrderId,
line.productId(),
line.quantity());
}
}

Changing only orderId moves the line and its descendants. Returning a model with a null parent ID detaches it. Neither the old nor new parent needs to be loaded.

The runtime stores old and new relation boundaries with the model commit’s monotone stateIndex. Historical graph loads therefore see the relation that belonged to the selected model state, not whichever parent happens to be current today.

With Id<Parent>, Fluxzero infers the parent type:

@Parent(pathInParent = "lines")
OrderId orderId

For a string or another untyped ID, declare the type when graph features need it:

@Parent(value = Order.class, pathInParent = "lines")
String orderId

For one polymorphic typed relation, enumerate the statically allowed parent types. The concrete typed ID selects one of them at runtime:

@Parent(
types = {Project.class, Folder.class},
pathInParent = "items")
Id<?> containerId

Use separate properties when the relations have different domain roles, paths or deletion ownership.

Concrete properties such as ProjectId remain scalar strings. A property declared as Id<?> (Kotlin Id<*>), Id<Project> or an abstract ID class needs a discriminator. For a Model ID the standard serializer uses {"name":"project","id":"owner-a"}, where name is its logical Model name, including the configured Model-name prefix. The Model must declare that concrete ID class as its @EntityId; the string is the functional ID, so the ID constructor still applies its normal prefix/case rules. @Parent(types=...) supplies both the candidates and the read allowlist. Without that annotation, a declared Model type argument or the generated Model index supplies the mapping.

For non-Model IDs the shape is {"@class":"example.ExternalId","id":"owner-a"}. Only compatible concrete Id subclasses can be reconstructed, subject to the mapper’s polymorphic type validator. Renaming such a class requires an upcaster for persisted payloads. Explicit Jackson type annotations/custom serializers retain their own contract. Discriminated values use the concrete ID’s custom deserializer when configured, retaining the enclosing property context. This is not a general authorization mechanism for untrusted input.

Unknown names/classes, conflicting discriminators and ambiguous old scalar values are rejected rather than guessed. Previously stored concrete scalars remain readable. Upgrade readers before writing the new polymorphic form; older readers do not understand it. Use an upcaster with domain knowledge for ambiguous old polymorphic scalars. Standalone IDs retain their scalar form (Fluxzero’s serialized message envelope already identifies their concrete class); a raw standalone JSON string cannot itself carry an erased Id<?> type.

Model identity is still only id.toString(). The model type is reconstruction and graph metadata; it is never concatenated into the persisted key.

pathInParent is an explicit durable placement contract. It is intentionally not derived from a Java class name:

@Parent(pathInParent = "shipping/lines")
OrderId orderId

When pathInParent is omitted, Fluxzero still stores and traverses the relation, but it does not automatically stitch that edge into a search or serialized graph document. Typed Graph traversal and parent-deletion lifecycle handling still follow it. This keeps package and class renames from silently changing public document structure.

When you need to find whole order Graphs, enable @Model(searchable = true) on Order; optionally store the composed result with GraphProjection ASYNC/AWAIT:

Graph<Order> order = Fluxzero.searchGraph(Order.class)
.fetchFirstOrNull();

If Order configures a graph projection, the default search reads that materialized view. Without one, it stitches the graph live. Pass true as the second argument to force live stitching. Constraints, sorting, field selection and pagination apply to the complete graph view in either route. Use an explicit terminal result type such as fetch(1, ObjectNode.class) only at a raw JSON, document or transport boundary.

Or reconstructed as typed models:

Graph<Order> graph = Fluxzero.loadGraph(orderId);
List<LineItem> lines = graph.childModels("lines", LineItem.class);

The root is available immediately. Relationships are loaded lazily on first traversal, with all returned nodes and edges pinned to one stateIndex.

Assertions, interceptors and applies load only the ancestors they declare:

record ChangeLineQuantity(
LineItemId lineItemId,
int quantity) {
@AssertLegal
void assertOrderOpen(
LineItem line,
Order order,
Customer customer) {
if (order.closed()) {
throw new IllegalStateException("Order is closed");
}
}
@Apply
LineItem apply(LineItem line) {
return line.withQuantity(quantity);
}
}

The command needs only the direct lineItemId. Fluxzero follows the temporal parent graph for Order and Customer. Siblings and descendants remain unloaded.

When more than one compatible ancestor edge exists, qualify it by path:

void assertBillingOrder(
LineItem line,
@Association("billingLines") Order order) {
}

For the searches below, enable searchable on the result types and the related types whose contents are filtered, or include them in a searchable ancestor’s composed scope. Ancestor-ID filtering needs only searchable result nodes and relationships; the ancestor itself need not be searchable. Persistence is a separate choice, and identity-based Graph navigation needs no search activation. See the query guide for the storage requirements.

Current relation-backed search can match children using parent or grandparent documents:

List<LineItem> lines = Fluxzero.search(LineItem.class)
.whereAncestor(
Customer.class,
2,
2,
MatchConstraint.match(
"Acme", true, "name"))
.fetchAll();

The reverse is also possible:

List<Order> orders = Fluxzero.search(Order.class)
.whereDescendant(
LineItem.class,
1,
1,
MatchConstraint.match(
productId.toString(),
true,
"productId"))
.fetchAll();

These current searches use relational joins between direct model documents and current edges. They do not require an asynchronously materialized root document.

For repeated whole-root queries, opt in to a durable graph projection:

@Model(
searchable = true,
graphProjection = @GraphProjection(mode = GraphProjectionMode.ASYNC,
collection = "order-graphs")
)
record Order(@EntityId OrderId orderId, String customerName) {
}

The runtime consumes durable projection signals, coalesces affected roots, composes each complete graph and writes it with a (configurationVersion, stateIndex) fence. A move updates both the old and new root. Adding DOCUMENT to the persistence set is a separate choice that enables direct Order search by default; it is not required for this whole-Graph projection.

Projection completion is configurable:

  • ASYNC updates the projection durably in the background;
  • AWAIT delays the command result until every affected projection has reached the commit boundary;
  • DEFAULT inherits consumer, apply, model and application configuration and ultimately uses ASYNC.

Prefer an operation-level AWAIT override when only some commands query the complete root immediately afterwards. LocalClient, the test server and synchronous TestFixture executions honor the same contract and materialize the root before releasing an awaited command result.

Each node in a materialized graph retains its own serialized Model type and @Revision. Ordinary serializer upcasters therefore evolve the root and descendants independently and lazily; there is no Graph-wide revision or Graph upcaster. Read-time upcasting gives application values the new shape but does not update stored search paths. Returning the unchanged complete Graph from @HandleDocument migrates evolved verified current nodes, including at NONE, and refreshes affected projections durably. Business values, history and relationships remain unchanged. Use graphMigration = GraphMigrationTarget.PROJECTION to migrate only a stored composition. See document migrations for concurrency and completion.

@Member remains supported and is not deprecated. Use it inside a Model when an entity deliberately shares its owner’s creation, changes, history, retention and deletion. Plain details values need no @Member annotation. For example, these invoice notes are identified embedded entities whose complete lifecycle belongs to the invoice:

@Model
record Invoice(
@EntityId InvoiceId invoiceId,
@Member List<InvoiceNote> notes) {
}

The notes share the invoice’s persistence boundary, including its stream, snapshots and any stored root document. An update to a note therefore loads and updates its owning Model. Choose this because the lifecycle belongs to the invoice; searchability, update frequency and convenient whole-document storage do not determine that boundary.

If a note needs its own history, retention, deletion or changing relationships, model it separately with @Parent. Changing an existing member into a Model requires a deliberate data migration; adding @Model alone does not transfer its history. Members also remain supported in stateful handlers.

A logical delete returns null from an apply and retains model history. By default it recursively deletes children whose relevant @Parent keeps deleteOnParentDeletion = true. This ownership rule follows pathless relations and shared-DAG descendants too. Choose false for non-owning relations, such as the product relation above, or for independently retained children such as audit entries:

@Parent(pathInParent = "auditEntries", deleteOnParentDeletion = false)
OrderId orderId

false leaves the child alive; it does not block deletion of the order or product. Add preventParentDeletion = true for the SDK-provided relationship check, or write an assertion for a richer invariant. With multiple parents, each edge has its own policy: a non-owning product relation does not stop the owning order relation from cascading deletion to the line.

A child moved to another parent in the same atomic commit survives. An intermediate delete followed by recreation in that commit does not activate the cascade.

Explicit hard deletion removes the selected model’s stream, cache, snapshots and direct document.

Descendant hard deletion is never guessed:

ModelDeletionPlan plan = Fluxzero.get()
.modelRepository()
.planDeletion(orderId, ModelDeletionCascade.DESCENDANTS);
Fluxzero.get().modelRepository().deleteModel(plan).join();

Detached descendants retain enough former-parent information for lifecycle and GDPR cleanup even after their parent has been deleted. Globally published audit events are outside the model-stream hard-delete boundary.


© 2026 Fluxzero