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.
Change one Model
Section titled “Change one Model”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)); }}data class RenameProduct(val productId: ProductId, @field:NotBlank val name: String) { @Apply fun apply(product: Product) = product.copy(details = product.details.copy(name = 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:
@Modelrecord Product(@EntityId ProductId productId, ProductDetails details) { @Apply Product rename(RenameProduct command) { return new Product(productId, details.withName(command.name())); }}@Modeldata class Product(@EntityId val productId: ProductId, val details: ProductDetails) { @Apply fun rename(command: RenameProduct) = copy(details = details.copy(name = command.name))}Register the model class as a handler when receiver methods are used. Payload-side applies are discovered from the registered command payload.
Calling the model pipeline explicitly
Section titled “Calling the model pipeline explicitly”An explicit command handler can enter the same pipeline exactly once:
@HandleCommandString handle(RenameProduct command) { Fluxzero.assertAndApply(command); return "renamed";}@HandleCommandfun handle(command: RenameProduct): String { 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"));Fluxzero.assertAndApply(command, Metadata.of("source", "admin"))Creating and deleting
Section titled “Creating and deleting”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); }}data class CreateProduct( @field:NotNull val productId: ProductId, @field:NotNull @field:Valid val details: ProductDetails) { @Apply fun apply() = 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; }}data class RemoveProduct(val productId: ProductId) { @Apply fun apply(product: Product): Product? = 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.
Reading other models during an action
Section titled “Reading other models during an action”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); }}data class PlaceOrder(val orderId: OrderId, val inventoryId: InventoryId, val quantity: Int) { @AssertLegal fun assertAvailable(inventory: Inventory) { if (inventory.available < quantity) { throw IllegalCommandException("Insufficient inventory") } }
@Apply fun create(inventory: Inventory) = 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:
@ApplyAccount debit(@Association("sourceId") Account source) { return source.debit(amount);}
@ApplyAccount credit(@Association("destinationId") Account destination) { return destination.credit(amount);}@Applyfun debit(@Association("sourceId") source: Account): Account = source.debit(amount)
@Applyfun credit(@Association("destinationId") destination: Account): Account = destination.credit(amount)Selecting a Model by alias
Section titled “Selecting a Model by alias”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:
@Modelrecord 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:
| Selection | Apply result |
|---|---|
| Every selector finds the same owner | May update that owner, change its aliases or delete it. |
| Some selectors find one owner, others are absent | Updates that owner and must satisfy every selector in the final value. |
| Every selector is absent | May create a Model with a valid primary ID and matching alias properties. It cannot overwrite another existing primary Model. |
| Selectors find different owners | Rejected 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.
Parents and ancestors
Section titled “Parents and ancestors”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:
@AssertLegalvoid assertOwner( LineItem line, Order order, Customer customer) {}@AssertLegalfun assertOwner(line: LineItem, 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 action, multiple models
Section titled “One action, multiple models”One update may return changes for several independent models. Fluxzero:
- resolves and batch-loads every direct target and requested ancestor;
- runs all assertions and interceptors;
- stages every returned model and relation delta;
- submits one model-commit package to the runtime;
- 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:
ACCEPTstores the original event and rebases derived documents, snapshots and relations on current model state;FAILrolls the runtime action back and returns a conflict;RETRYrolls it back and performs a bounded complete reevaluation against freshly loaded models;DEFAULTinherits the application policy, otherwiseRETRY, for updates and creations alike, independently offluxzero.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.
Automatic Model routing
Section titled “Automatic Model routing”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.
Apply interceptors
Section titled “Apply interceptors”@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(); }}data class ImportOrder(val lines: List<ImportLine>) { @InterceptApply fun expand(): List<CreateLine> = lines.map { it.toCreateLine() }}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 outcome | Assertions and application |
|---|---|
| Retain the payload | Its matching immediate @AssertLegal methods run before @Apply |
| Suppress the payload | Neither its assertions nor its apply methods run |
| Replace the payload | Only the replacement’s matching assertions and apply methods run |
| Split the payload | Each part’s immediate assertions and apply run in order; later parts see earlier changes |
Validate the current interceptor input
Section titled “Validate the current interceptor input”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.
Composable legality checks
Section titled “Composable legality checks”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.
Dynamic write targets
Section titled “Dynamic write targets”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(); }}data class IncrementChildren(val rootId: RootId) { @Apply fun apply(root: Graph<Root>): List<Child> = root.childModels(Child::class.java).map { it.copy(value = it.value + 1) }}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();val durable: CompletableFuture<Void> = 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