Tracking, updating documents
Fluxzero allows you to track changes to your document store using the @HandleDocument annotation.
This enables handlers to react to document updates in real time — much like handling events.
@HandleDocumentvoid handle(UserAccount user) { log.info("UserAccount {} was added or updated", user.getUserId());}@HandleDocumentfun handle(user: UserAccount) { log.info("UserAccount ${user.userId} was added or updated")}How it works
Section titled “How it works”Every time a document is (re)indexed, it receives a new message index (based on the timestamp of the update).
Handlers annotated with @HandleDocument will observe these updates as they pass through the document log.
This makes @HandleDocument ideal for live processing, projecting the latest known state, or
cache rebuilds.
Transforming stored documents
Section titled “Transforming stored documents”@HandleDocument can also be used to update documents in place.
If the handler returns a newer revision of the document, Fluxzero will reindex and persist the result:
@HandleDocument("users")UserAccount upgrade(UserAccount oldUser) { return new UserAccount(oldUser.getId(), normalizeEmail(oldUser.getEmail()));}@HandleDocument("users")fun upgrade(oldUser: UserAccount): UserAccount { return UserAccount(oldUser.id, normalizeEmail(oldUser.email))}The returned value replaces the previous document (same ID), only if it has a higher @Revision.
A plain returned document retains all metadata from the exact stored version being handled, including through
upcasting. Derived search indexes/exclusions are regenerated from the replacement. Tracking metadata such as the envelope’s $start and $end is not persisted as document metadata.
Existing document timestamps remain in effect unless the returned type declares timestamp/end paths.
For an ordinary document, return a Message to explicitly replace the complete metadata map. Its payload still
needs a higher revision; its message ID and timestamp do not override the document’s identity or indexed times.
Use Metadata.empty() to remove all document metadata. Returning null directly retains the deletion behavior.
Only payload and metadata participate in this write; other Message envelope fields are ignored.
A custom Serializer.deserializeMessages(...) implementation may buffer, reorder or split a document batch.
If it constructs entirely new envelopes or decoded messages, call
DocumentMessageReader.retainSource(decodedOutput, originalInput) for every corresponding output, keeping the
original stored input before transformations. Standard input-envelope withData, withMetadata and withSegment
operations preserve that source automatically. The helper transfers the original document bytes for metadata
preservation; the custom serializer still owns ordinary message identity, revision and transport fields.
See DocumentMessageReader.retainSource Javadoc for the complete extension contract.
@HandleDocument("users")Message upgrade(UserAccount user) { return new Message(user, Metadata.of("source", "migration"));}@HandleDocument("users")fun upgrade(user: UserAccount): Message = Message(user, Metadata.of("source", "migration"))To delete a document from the store, return null:
@HandleDocument("users")UserAccount upgrade(UserAccount user) { return user.isTestUser() ? null : user;}@HandleDocument("users")fun upgrade(user: UserAccount): UserAccount? { return if (user.isTestUser) null else user}Model documents
Section titled “Model documents”Model documents have a narrower return contract: a handler can persist a schema upgrade, but business changes and
deletion go through Model commands. For internal non-searchable document state, select
source = DocumentSource.MODEL_STATE. See Testing Model migrations.
Use cases
Section titled “Use cases”- Auto-upcasting legacy documents
- Filling or correcting derived fields
- Cleaning up invalid or deprecated data
- Running background migrations or rehydration jobs
- Current-state analytics (use events when every change must be counted or logged)
Handler configuration
Section titled “Handler configuration”The parameter selects what the handler observes:
| Declaration | Subscription |
|---|---|
@HandleDocument with Task | Canonical searchable Task node |
@HandleDocument with Graph<Task> | Task and its included descendants, in NONE, ASYNC and AWAIT |
@HandleDocument(source = DocumentSource.MODEL_STATE) with Task | Internal maintained Task state, even without searchability |
@HandleDocument(documentClass = Task.class) | Explicit node type when it cannot be inferred |
@HandleDocument("myCollection") | An ordinary named document collection |
A Graph handler observes its root and included descendants. It can observe a live Graph (NONE) or a stored
projection (ASYNC/AWAIT). Updates may coalesce; use event-bound Graph handlers when you need history or every
transition. A deleted root arrives as an empty Graph.
For subscription selection, registration and durable projection changes, see HandleDocument and Choosing Model and Graph queries.
Evolving a materialized Model Graph
Section titled “Evolving a materialized Model Graph”Every node in a materialized Graph stores its own type and revision. When the Graph is read, the normal serializer
applies each Model type’s ordinary @Upcast chain independently and lazily. There is no separate Graph upcaster and a
child Model does not inherit the root’s revision.
If lazy read-time upcasting is sufficient, no document handler is needed. To durably rematerialize the evolved node JSON for search, return the complete Graph from a dedicated handler:
@HandleDocumentGraph<Project> rematerialize(Graph<Project> graph) { return graph;}@HandleDocumentfun rematerialize(graph: Graph<Project>): Graph<Project> = graphFluxzero migrates nodes whose type or revision has evolved, using their verified current canonical state. Return the complete, unchanged Graph; use Model commands for business changes. This works in NONE, ASYNC and AWAIT and keeps history and relationships unchanged. It does not copy stale business values from the handled Graph into newer state.
Node writes complete before the handler completes; affected projections refresh durably afterward. Verify new search paths after catch-up. Migration is idempotent per node, not atomic across the whole Graph. The consumer’s error policy determines how failures are recovered; use a Runtime that supports this migration contract.
For a deliberate migration of only the stored composition, select
@HandleDocument(graphMigration = GraphMigrationTarget.PROJECTION). This leaves canonical nodes unchanged and has
no storage effect in NONE; a later rebuild from unchanged sources can restore old paths. See
HandleDocument for concurrency, completion and custom-serializer contracts.
Replay for full collection migration
Section titled “Replay for full collection migration”Want to upgrade all existing documents in a collection? Combine @HandleDocument with replays:
- Create a handler method that returns upgraded documents
- Increment the
@Revisionon the model class - Attach a custom consumer with
@Consumer(minIndex = 0, ...) - Deploy temporarily until migration completes
This is a robust and rapid way to reindex, clean, or refactor your stored documents in-place.
@Consumer(name = "reindex-users", minIndex = 0)class ReindexUsers { @HandleDocument("users") UserAccount upgrade(UserAccount legacyUser) { return fixLegacyState(legacyUser); }}@Consumer(name = "reindex-users", minIndex = 0)class ReindexUsers { @HandleDocument("users") fun upgrade(legacyUser: UserAccount): UserAccount { return fixLegacyState(legacyUser) }}Once the transformation is complete, the handler can be safely removed.
© 2026 Fluxzero