Skip to content

Updating models

Fluxzero handles an action with target Model identities by loading the current state, running its @AssertLegal and @Apply methods, and committing the returned state. One action can update several Models atomically. The action does not contain persistence code.

For a complete business-rule example, see Validate and update Models atomically.

An update that has enough information to identify its target models can be handled without a custom command handler:

public record RenameProduct(ProductId productId, @NotBlank String name) {
@Apply
Product apply(Product product) {
return product.withDetails(product.details().withName(name));
}
}

Here ProductId extends Id<Product>, and Product has a ProductDetails value. Java’s withDetails and withName come from Lombok @With; Kotlin uses copy. See Model state for complete declarations. @NotBlank validates the new input; the copy preserves the rest of the Product’s state.

Publishing RenameProduct as a command is enough. Fluxzero determines the target from ProductId, loads only that product, runs the assertions and applies, and commits the returned model.

No forwarding command handler or marker interface is required. This is the ordinary update path; the sections below add creation, deletion, cross-Model rules and explicit control only when the action needs them.

Expected business refusals must be FunctionalException subclasses, not IllegalStateException, IllegalArgumentException or Kotlin require/check. Use IllegalCommandException for an invalid domain action, UnauthorizedException for an authorization refusal, and Bean Validation for malformed input. These distinguish expected refusals from technical failures.

Automatic handling can also be declared on the model:

@Model
record Product(@EntityId ProductId productId, ProductDetails details) {
@Apply
Product rename(RenameProduct command) {
return new Product(productId, details.withName(command.name()));
}
}

Register the model class as a handler when receiver methods are used. Payload-side applies are discovered from the registered command payload.

An explicit command handler can enter the same pipeline exactly once:

@HandleCommand
String handle(RenameProduct command) {
Fluxzero.assertAndApply(command);
return "renamed";
}

Use this when the command must return a custom result or when automatic handling has been disabled. Metadata can be attached without changing the update:

Fluxzero.assertAndApply(command, Metadata.of("source", "admin"));

An apply without a current value can create a model:

record CreateProduct(@NotNull ProductId productId, @NotNull @Valid ProductDetails details) {
@Apply
Product apply() {
return new Product(productId, details);
}
}

A create-only factory requires absence; a non-null current-Model parameter requires presence. Violations reject the whole action with the functional already-exists/not-found error, rather than silently succeeding. Use a nullable current-state parameter for an intentional upsert. disableCompatibilityCheck = true suppresses rejection but does not turn a factory into an overwrite. The existing fluxzero.assert.apply-compatibility property and its .exception.already-exists / .exception.not-found overrides also apply to Models. Already accepted historical events retain their replay behavior.

Returning null from a model apply logically deletes the target:

record RemoveProduct(ProductId productId) {
@Apply
Product apply(Product product) {
return null;
}
}

The delete update is still an event according to the model’s publication settings. The current model and direct search document disappear, while the model stream remains available for audit and reconstruction. Returning void is not allowed for model applies because it does not identify a resulting write.

Use the explicit ModelRepository.deleteModel(...) APIs for physical deletion. Cascading descendant deletion requires an explicit, confirmed deletion plan.

Any directly referenced model can be injected into assertions, interceptors and applies:

record PlaceOrder(
OrderId orderId,
InventoryId inventoryId,
int quantity) {
@AssertLegal
void assertAvailable(Inventory inventory) {
if (inventory.available() < quantity) {
throw new IllegalCommandException("Insufficient inventory");
}
}
@Apply
Order create(Inventory inventory) {
return new Order(orderId, inventory.inventoryId(), quantity);
}
}

Inventory is not required to be family of Order. It is loaded because the payload carries an InventoryId. A direct dependency is read-only unless an apply returns that model.

When two properties refer to the same model type, qualify each parameter:

@Apply
Account debit(@Association("sourceId") Account source) {
return source.debit(amount);
}
@Apply
Account credit(@Association("destinationId") Account destination) {
return destination.credit(amount);
}

A command can identify a customer by email when it does not have the internal customer ID. With fluxzero.model.aliasTargets=true, a command property named email selects the Model’s @Alias email:

@Model
record Customer(@EntityId String customerId, @Alias String email, String name) {}
record RenameCustomerByEmail(String email, String name) {
@Apply
Customer apply(Customer customer) {
return new Customer(customer.customerId(), customer.email(), name);
}
}

Enable this after both SDK and Runtime support identity selection. It is also enabled by fluxzero.defaults.version=2026.10.18 or later; an explicit fluxzero.model.aliasTargets=false retains compatibility behavior. SDK and Runtime may be upgraded in either order while the feature remains disabled. An enabled feature against an older Runtime fails clearly before sending the unsupported operation.

The property name supplies the meaning: customerId addresses a primary ID and email addresses that alias, even when the same string is a primary ID of another Model. Generic loadModel and loadGraph calls keep primary-ID precedence. An explicit Graph, typed ID or @Association binding remains more specific. A null primary-ID field is invalid; it does not fall back to an email field.

Several supplied alias properties must agree on one owner. Collection-valued selectors are also a conjunction for one Model. A value on another alias property does not count as a match. The result rules are:

SelectionApply result
Every selector finds the same ownerMay update that owner, change its aliases or delete it.
Some selectors find one owner, others are absentUpdates that owner and must satisfy every selector in the final value.
Every selector is absentMay create a Model with a valid primary ID and matching alias properties. It cannot overwrite another existing primary Model.
Selectors find different ownersRejected as ambiguous before Apply.

Null or blank alias values and empty collections do not select a Model; at least one effective alias value is required. A present null primary-ID property still fails without falling back to aliases.

Creation still needs a factory or a nullable Model parameter; normal create/update compatibility checks apply. An explicit primary ID plus alias fields uses the ID for selection, so a command can change an email normally.

The commit validates the observed alias mapping atomically with the Model revision. If ownership changes, RETRY repeats the entire decision, including assertions. ACCEPT cannot apply its old decision to a different owner and reports a conflict instead. Upgrade all SDK readers of these Models before enabling the new targeting contract. New readers understand canonical bindings even with compatibility defaults; older SDKs do not. Stored events retain canonical selection bindings, so replay does not consult today’s alias mapping for the original choice.

If no direct payload property identifies an injected model, Fluxzero follows stored ancestor relations at the action’s pinned state boundary. Injection is a read; returning that ancestor from a singular Model-returning @Apply also makes it a write target, without requiring its ID to be duplicated in the command:

@AssertLegal
void assertOwner(
LineItem line,
Order order,
Customer customer) {
}

Parents, grandparents and further ancestors are loaded only when explicitly requested. Use @Association("billingLines") when multiple ancestor edges of the same type are reachable; the qualifier then matches the @Parent(pathInParent = "...") edge.

A direct write ID has precedence. A command may remove a child and return an updated parent from another apply in the same atomic commit; ancestor selection uses the pre-apply graph, so removing the child does not lose its parent. Ambiguous ancestors must be qualified rather than selected arbitrarily.

A nullable read-only value parameter also permits a null identifying property (for example an optional enclosing folder ID). A non-null ID pointing to no Model and a null ID both inject null there. This does not permit null write identities: a create/update target must still have a valid ID. Non-nullable parameters require an existing value.

One update may return changes for several independent models. Fluxzero:

  1. resolves and batch-loads every direct target and requested ancestor;
  2. runs all assertions and interceptors;
  3. stages every returned model and relation delta;
  4. submits one model-commit package to the runtime;
  5. publishes the original domain event once globally while linking it to every target stream.

If evaluation fails, no target is committed. Conflict handling applies to the complete action:

  • ACCEPT stores the original event and rebases derived documents, snapshots and relations on current model state;
  • FAIL rolls the runtime action back and returns a conflict;
  • RETRY rolls it back and performs a bounded complete reevaluation against freshly loaded models;
  • DEFAULT inherits the application policy, otherwise RETRY, for updates and creations alike, independently of fluxzero.defaults.version. Retry does not bypass the normal create-only compatibility check.

RETRY and FAIL validate all Models resolved during evaluation, including assertion and interceptor dependencies. ACCEPT validates only dependencies used during apply plus written targets; assertion-/interceptor-only changes do not require a rebase because that policy preserves the original decision. Rebase skips assertions and interceptors. Roots used to select an apply ancestor remain dependencies, even if an assertion originally loaded those roots or the ancestor is nullable and currently missing. Rebase reloads those selection roots without rerunning assertions. Conflict-free eligible commits use the same cached-head/atomic-boundary Runtime optimization for all policies.

A concurrent claim of the same new model identity fails under implicit defaults, including the new RETRY default, and is never silently ACCEPT-rebased into an overwrite. An explicit RETRY instead reevaluates the complete action at the conflict boundary: create-only assertions still reject the existing identity, while deliberate upserts may continue from its freshly reconstructed value.

Configure a default through FluxzeroBuilder or fluxzero.model.conflictPolicy, and override it on @Model or an individual @Apply.

Set fluxzero.model.automaticRouting=true or opt into defaults version 2026.09.10 to route simple single-apply commands by their canonical Model target ID. The target must be statically unambiguous: collection/dynamic returns, multiple applies and Model interception disable inference. ID extraction uses the same typed-ID affixes and parent scope as Model loading; it executes no apply and performs no repository read. Single-Model events use their actual committed target as fallback, including targets selected after interception. Multi-Model events do not pick a first ID.

Explicit segments and routing annotations always take precedence. A field or metadata/property explicitly designated by @RoutingKey suppresses fallback even if its value is absent. fluxzero.model.automaticRouting=false disables both fallbacks. Explicit routing and local dispatch retain their own behavior. Routing reduces write contention but does not eliminate conflicts on shared read dependencies or imply ordering across consumers. A source command segment is not assumed to represent the Model ID when its routing provenance is unknown.

Consecutive commands in one tracking batch

Section titled “Consecutive commands in one tracking batch”

Commands remain separate atomic actions, but automatic model handling provides read-your-writes within one ordered consumer segment. If a later command in the same tracking batch reads a model changed by an earlier command, it evaluates against the staged value rather than reloading stale durable state. The same applies when the later command injects a parent, grandparent or further ancestor whose value or relation changed earlier in that segment. Direct Fluxzero.loadModel(...), batched repository loads and current loadGraph(...) composition share the same view; aliases, deletions and relationship moves therefore behave consistently across automatic injection and explicit loads. Explicit historical graph loads remain isolated at their requested stateIndex.

An explicit Fluxzero.assertAndApply(...) started from a tracked handler and a stored-event apply participate in the same batch view. They remain separate durable commits; if their evaluation reads an earlier pending value, they wait for that producer and evaluate again before committing.

Fluxzero preserves both correctness and concurrency:

  • independent model chains continue evaluating and committing in parallel;
  • an overlapping command waits for its predecessor’s durable completion;
  • after that completion, the dependent command is reevaluated against canonical durable state before it commits;
  • a failed predecessor triggers fresh Model evaluation, so each command receives its own success or rejection.

A command rejected during validation does not fail independent commands in that batch, including deferred commits. Each command retains its own commit/result outcome. A real outer batch abort still stops pending batch work; this cannot roll back a commit already accepted by storage.

With normal commit-before-result handling, an automatic Model command’s validation or commit failure is handled once by that command’s consumer error policy. Batch completion still awaits every commit, but does not report an already handled failure again as a whole-batch error or retry unrelated commands. Unhandled failures and real batch aborts remain failures. Disabling fluxzero.model.awaitAfterHandlerCommitsBeforeResults keeps deferred commit errors at the batch boundary; an early successful response does not promise a successful commit.

With ASYNC consumer handling, automatic Model commits that start after the handler also coordinate overlapping readsets within the tracking batch. Evaluation stays parallel; a ready commit first waits for earlier evaluations to discover their readsets, then waits only for overlapping predecessors in the same namespace and reevaluates before committing. Disjoint scopes can commit concurrently. A Model command that read pending state waits for its producer to finish, then reevaluates even if that producer failed. Its own assertions determine its result; it does not inherit another command’s rejection. A validation failure against pending state is provisional too: partial changes are never staged, and evaluation resumes after the discovered predecessors settle. This dependency coordination does not consume or increase the configured conflict retry budget. External writers and newly discovered readsets still require atomic conflict validation. Ordinary handlers that return results based on pending state retain their producer-success barrier; they cannot publish a successful result from state that was rejected.

An ordinary handler that explicitly reads pending state also delays its result until the producing commit completes. This prevents a success response from escaping for state that is subsequently rejected.

This mechanism does not combine several commands into one transaction. Each command keeps its own atomic model commit, result and conflict policy. It also does not create ordering across different consumers or routing segments; use a shared consumer and routing key when application-level command order matters.

@InterceptApply can reject, replace or expand an incoming update before applies run:

record ImportOrder(List<ImportLine> lines) {
@InterceptApply
List<CreateLine> expand() {
return lines.stream().map(ImportLine::toCreateLine).toList();
}
}

Every emitted update runs through the same assertions and apply machinery. Interceptors may inject the same direct models and ancestors as assertions and applies.

With current-input checks disabled, interception selects the payloads to which assertions apply:

Interceptor outcomeAssertions and application
Retain the payloadIts matching immediate @AssertLegal methods run before @Apply
Suppress the payloadNeither its assertions nor its apply methods run
Replace the payloadOnly the replacement’s matching assertions and apply methods run
Split the payloadEach part’s immediate assertions and apply run in order; later parts see earlier changes

Use @InterceptApply(assertCurrent = AssertCurrent.ENABLED) to retain legality checks for the input of that interceptor. AssertCurrent is in io.fluxzero.sdk.persisting.eventsourcing. DEFAULT follows fluxzero.interceptApply.assertCurrent (FLUXZERO_INTERCEPT_APPLY_ASSERT_CURRENT): when absent, it is enabled from fluxzero.defaults.version=2026.10.04 and disabled for older or absent defaults versions. An explicit annotation choice wins over the application property, which wins over the defaults version. Use DISABLED, or property false for unconfigured interceptors, when rewriting is deliberately allowed before checking legality. Configuration is resolved for the owning application when its helpers/plans are created.

Current immediate assertions run before the selected interceptor, inside the same commit attempt and against its current state. The input is checked once even when it splits into several outputs or is suppressed. Every replacement keeps its normal checks. A bare unchanged input is not checked twice in the same scope; a new instance or message envelope receives its own checks. In A → B → C, each interceptor controls its own current input, not always A. Payload, Message, metadata, user and custom parameter injection use that input’s context. There is no combined original/replacement parameter. Existing nested legality checks also participate.

All Model afterHandler=true assertions run against the complete final state of the atomic Model operation, including every intercepted output and automatic child deletion. This includes ordinary payload, Model and nested assertions as well as retained current-input and cascading checks. Immediate assertions still run before their own step and see earlier staged changes. Each deferred check retains its own payload, metadata and user context; a refusal rolls back the whole operation. Splitting an action does not introduce intermediate after-handler phases. This boundary covers one Model operation, not separate commands or commits. Aggregate/Entity keeps its existing handler-completion phase. These checks are pure validation and must not perform external effects. Immediate-only Model assertLegal does not run after-handler assertions. Apply-only legacy paths and replay do not start running assertions. Interceptors and assertions must remain free of external side effects.

Model assertion reads participate in the pinned commit and conflict checks. RETRY evaluates them again; an attempt that validates a current input upgrades configured ACCEPT to FAIL, so concurrent changes cannot retain stale permission. Other ACCEPT operations retain their behavior. S285 rules still guard effective Model mutations; an output’s Apply exception does not undo current-input checks. This requires no new Runtime protocol or Runtime upgrade. With current-input checks disabled, no validation history or additional Model reads are retained.

An @AssertLegal method may return a validation object or a collection of validation objects. Fluxzero recursively executes their matching assertions using the original payload, metadata, user and application parameter resolvers. The nested object is the assertion receiver, not a replacement payload. Additional Model injections, including ancestors and List<Graph<T>> associations, use the same pinned read boundary. RETRY and FAIL protect these reads; ACCEPT rebase and event replay do not rerun the checks.

Model selection is resolved per parameter from the nearest validator declaring a reference, then enclosing validators, and finally the triggering payload. A command for item A may return RemainingItem(itemBId): the nested check then injects B, even if both payloads name their ID property itemId. If that validator returns another reference-less validator, it still selects B. The original command, metadata and user remain injectable. Explicit nulls and empty ID collections are intentional selections, not fallback requests; association metadata retains its normal precedence for that parameter without changing unrelated selections. Ancestor injection follows the selected references rather than borrowing an outer validator’s different parent.

Return values are visited in the returning method’s phase: before-methods visit nested before-methods; after-methods visit nested after-methods against the fully composed state. A no-arg accessor is still a method and is not invoked again in the other phase. To expose one validator in both phases, use an @AssertLegal field (or an annotated record component). Fields delegate in both phases regardless of the field’s afterHandler value; the nested methods choose their own timing. Field and method priorities apply within each payload/Model phase; nested checks run immediately when encountered. Fluxzero.assertLegal(...) performs only immediate checks, without applying or committing.

Null values are ignored. Collection elements are visited in encounter order. Object identity prevents cycles and repeated visits within a payload or Model assertion phase, without calling domain equals or hashCode. Direct assertion methods always remain eligible, including explicitly implemented record accessors; only repeated traversal of their returned objects is suppressed. Nesting beyond 256 levels is rejected explicitly.

A variable set of existing children can be updated by returning Models read from an injected Graph. For a Child with childId, @Parent RootId rootId and an integer value:

record IncrementChildren(RootId rootId) {
@Apply List<Child> apply(Graph<Root> root) {
return root.childModels(Child.class).stream()
.map(child -> new Child(child.childId(), child.rootId(), child.value() + 1)).toList();
}
}

The SDK retains each inspected Model’s own revision; the children need not share a revision or sequence number. The values must come from this evaluation’s tracked Graph, not a detached search result or arbitrary current-state read. An identity not read in the evaluation remains a new create-if-absent target, never a blind overwrite. A collection or dynamically typed Model result may also create an identity already found absent by a nullable Model parameter in an assertion or interceptor. That read retains its absence or deletion revision for conflict checking; a concurrent creation cannot be blindly overwritten. Reading an existing Model in an assertion or interceptor alone does not make it writable. Existing results still need a resolved write target or a tracked Graph read.

Use @InterceptApply List<Graph<Child>> when you want explicit graph.update(...) or graph.delete() operations. Return those changed Graphs so their identity and read boundary travel with the mutation. Use an ordered collection of ordinary command payloads when each child operation deserves its own domain command; later parts see earlier staged changes and all parts commit atomically. The commit shares a commit ID, not one Model revision or state index. Each stored event retains its exact commit substep during replay, including when a later part updates the same Model again. A cold reader reconstructs the same state as the writer after the commit. This also applies to ancestors created by earlier parts of the same commit, even when no Model history existed before it. Each part’s assertions see earlier staged changes too, including removed child memberships. If a validation needs the pre-deletion collection, put its command before the Graph deletions in the returned collection. A later validation failure rolls back the whole commit; reordering does not turn the parts into separate transactions. RETRY rereads the selected graph and reevaluates the operation on a conflict. Do not add manual previousValues fields to compensate for lost revisions; historical inspection through previous() requires EVENT_SOURCED.

Flushing an existing automatic commit early

Section titled “Flushing an existing automatic commit early”

Fluxzero normally commits Model changes automatically at the configured handler or batch boundary. Code that runs later in the same handling context can exceptionally request that an already produced automatic commit be made durable immediately:

CompletableFuture<Void> durable = Fluxzero.commit();

Fluxzero.commit() does not apply an update and does not create a second commit. It releases the current commit through the ordinary conflict, retry, document, snapshot, relationship and completion path and returns that commit’s existing future. Repeated calls observe the same completion. The normal automatic boundary remains active but cannot commit the changes twice.

When the current handling context has not produced Model changes, the returned future is already complete and no Runtime request is sent. Use Fluxzero.assertAndApply(...), Graph.assertAndApply(...) or Graph.commit() to produce an explicit change; Fluxzero.commit() is only an early durability boundary for changes that already exist. Never block on it from inside @Apply: applies must remain pure and have not returned their change yet.


© 2026 Fluxzero