Skip to content

Document indexing and search

Fluxzero provides a powerful and flexible document store that lets you persist and query models using full-text search, filters, and time-based constraints.

This system is especially useful for:

  • Querying across entities (e.g., active users, recent payments)
  • Supporting projections for read APIs or dashboards
  • Tracking workflows, external states, or business processes
  • Replacing the need for a traditional read model database

You can index any object manually using:

Fluxzero.index(myObject);

This stores myObject in the document store so it can be queried later via Fluxzero.search(...).

  • If the object is annotated with @Searchable, any declared collection, timestampPath, or endPath will be used.
  • If a field is annotated with @EntityId, it becomes the document ID. Otherwise, a random ID is generated.
  • Timestamps can be inferred from annotated paths or passed explicitly.

You can also specify the collection in which the object should be stored directly:

Fluxzero.index(myObject, "customCollection");

Many models in Fluxzero (e.g. aggregates or stateful handlers) are automatically indexable:

  • @Aggregate(searchable = true)
  • @Stateful (implicitly @Searchable)
  • Directly annotate any POJO with @Searchable
@Aggregate(searchable = true)
public record UserAccount(@EntityId UserId userId,
UserProfile profile,
boolean accountClosed) {
}

By default, the collection name is derived from the class’s simple name (UserAccount → "UserAccount"), unless explicitly overridden via an annotation like @Aggregate, @Stateful or @Searchable or in the search/index call:

@Aggregate(searchable = true, collection = "users", timestampPath = "profile/createdAt")

Use the fluent search(...) API:

List<UserAccount> admins = Fluxzero
.search("users")
.match("admin", "profile/role")
.inLast(Duration.ofDays(30))
.sortBy("profile/lastLogin", true)
.fetch(100);

Search is optimized for current state, but indexing still follows the commit path. When a command directly updates a searchable aggregate, Fluxzero waits for asynchronous after-handler aggregate commits before publishing the command result by default. In many cases, calling sendCommandAndWait(...) and then querying the aggregate’s document can read the just-committed state.

This does not cover downstream projections or side-effect indexing. If a document is indexed by an event handler or projection handler, treat it as eventually consistent and wait for that projection’s own completion signal when immediate read-after-write behavior matters.

You can also query by class:

List<UserAccount> users = Fluxzero
.search(UserAccount.class)
.match("Netherlands", "profile.country")
.fetchAll();

Fluxzero supports a rich set of constraints:

  • lookAhead("cat", paths...) – search-as-you-type lookups
  • query("*text & (cat* | hat)", paths...) – full-text search
  • match(value, path) – field match
  • matchFacet(name, value) – match field with @Facet
  • between(min, max, path) – numeric or time ranges
  • since(...), before(...), inLast(...) – temporal filters
  • anyExist(...) – match if any of the fields are present
  • Logical operations: not(...), all(...), any(...)
Fluxzero.search("payments")
.match("FAILED", "status")
.inLast(Duration.ofDays(1))
.fetchAll();

Search execution also has asynchronous variants for request handlers and web endpoints that can return a CompletableFuture. Return the future directly instead of calling .join() inside the handler; this lets Fluxzero keep processing other work while the search request is in flight.

  • fetchAsync(maxSize) / fetchAsync(maxSize, type) fetch matching documents asynchronously.
  • countAsync() returns the matching document count asynchronously.
  • aggregateAsync(fields...) and groupBy(paths...).aggregateAsync(fields...) return search statistics asynchronously.
  • facetStatsAsync() returns facet value counts asynchronously.
@HandleQuery
CompletableFuture<List<UserAccount>> handle(SearchUsers query) {
return Fluxzero.search(UserAccount.class)
.lookAhead(query.term(), "profile.name", "email")
.fetchAsync(50, UserAccount.class);
}

If you’re filtering on a field that is marked with @Facet, it’s better to use:

.matchFacet("status", "archived")

instead of:

.match("archived", "status")

While both achieve the same result, matchFacet(...) is generally faster and more efficient.


When a field or getter is annotated with @Facet, you can also retrieve facet statistics — e.g., how many documents exist per value of a given property.

Example: Product breakdown by category and brand

Section titled “Example: Product breakdown by category and brand”
@Searchable
public record Product(@Facet String category,
@Facet String brand,
String name,
BigDecimal price) {
}
List<FacetStats> stats = Fluxzero.search(Product.class)
.lookAhead("wireless")
.facetStats();

This gives you document counts per facet value:

[
{ "name": "category", "value": "headphones", "count": 55 },
{ "name": "brand", "value": "Acme", "count": 45 },
{ "name": "brand", "value": "NoName", "count": 10 }
]

Each FacetStats object will contain:

  • the facet name (e.g., category)
  • the distinct values (e.g., "electronics", "clothing")
  • the number of documents per value

Use facetStatsAsync() when the surrounding handler returns a CompletableFuture.



By default, all non-transient properties of a document are included in the search index. However, you can fine-tune what fields get indexed using the following annotations:

Use @SearchExclude to exclude a field or type from search indexing. This prevents the property from being matched in search queries, though it will still be present in the stored document and accessible at runtime.

public record Order(String id,
Customer customer,
@SearchExclude byte[] encryptedPayload) {
}

You can also exclude entire types:

@SearchExclude
public record EncryptedData(byte[] value) {
}

In this case, none of the properties of EncryptedData will be indexed, unless you override selectively with @SearchInclude.


Use @SearchInclude to override an exclusion. This is functionally equivalent to @SearchExclude(false) and is typically used on a field or class that would otherwise be excluded by inheritance or parent-level settings.

@SearchExclude
public record BaseDocument(String internalNotes,
@SearchInclude String publicSummary) {
}

Here, internalNotes will not be indexed, but publicSummary will be.


AnnotationEffect
@SearchExcludePrevents property/type from being indexed for search
@SearchExclude(false) or @SearchIncludeExplicitly includes a property even if a parent type is excluded
No annotationField is included in the search index by default

To enable efficient range filters and sorted results in document searches, annotate properties with @Sortable:

public record Product(@Sortable BigDecimal price,
@Sortable("releaseDate") Instant publishedAt) {
}

This tells Fluxzero to pre-index these fields in a lexicographically sortable format. When you issue a search with a between(...) constraint or .sortBy(...) clause, the Fluxzero Runtime can evaluate it directly in the data store — without needing to load and compare documents in memory.

List<Product> results = Fluxzero.search(Product.class)
.between(10, 100, "price")
.sortBy("releaseDate")
.fetch(100);

Fluxzero normalizes and encodes sortable fields depending on their value type:

TypeBehavior
NumbersPadded base-10 string (preserves order, supports negatives)
InstantsISO-8601 timestamp format
Strings/OthersNormalized (lowercased, trimmed, diacritics removed)

This ensures that sorting is consistent and correct across types and locales.

If the sortable field is:

  • A collection → Max element is indexed. Create a getter if you need sorting on the min element
  • A map → Values are indexed using key/propertyName path
  • A nested object annotated with @Sortable → Its toString() is used
  • A POJO with @Sortable fields → Those nested values are indexed with prefixed paths
  • No retroactive indexing: Adding @Sortable to a field does not automatically reindex existing documents.
  • To apply sorting retroactively, trigger a reindex (e.g. with @HandleDocument and a bumped @Revision).
  • Sorting and filtering still happen within the Fluxzero Runtime, but without @Sortable the logic falls back to in-memory evaluation — which is much slower.
  • Avoid re-implementing sorting/filtering in application code; express them in the search query so execution stays in the Runtime.

When performing a search, you can control which fields are included or excluded in the returned documents.

This is useful for:

  • Hiding sensitive fields (e.g. private data, tokens)
  • Reducing payload size
  • Optimizing performance when only partial data is needed

Given the following indexed document:

{
"id": "user123",
"profile": {
"name": "Alice",
"email": "alice@example.com",
"ssn": "123456789"
},
"roles": [
"user",
"admin"
]
}

You can exclude sensitive fields like so:

Fluxzero.search("users")
.exclude("profile.ssn")
.fetch(50);

This will return:

{
"id": "user123",
"profile": {
"name": "Alice",
"email": "alice@example.com"
},
"roles": [
"user",
"admin"
]
}

Fluxzero supports efficient streaming of large result sets:

Fluxzero.search("auditTrail")
.inLast(Duration.ofDays(7))
.stream().forEach(auditEvent -> process(auditEvent));

To remove documents from the index:

Fluxzero.search("expiredTokens")
.before(Instant.now())
.delete();

  • Use Fluxzero.index(...) to manually index documents.
  • Use @Searchable to configure the collection name or time range for an object.
  • Use @Aggregate(searchable = true) or @Stateful for automatic indexing.
  • Use Fluxzero.search(...) to query, stream, sort, and aggregate your documents.
  • Use fetchAsync(...), countAsync(), aggregateAsync(...), and facetStatsAsync() in asynchronous handlers.

© 2026 Fluxzero