NoSQL documents: DocumentStore
One API for Amazon DynamoDB, Azure Cosmos DB for NoSQL and Google Cloud Firestore (native
mode), plus an in-memory store. Items are keyed by a partition key and an optional sort key, and hold a
portable Document.
Tables#
A TableConfig names the table (DynamoDB table, Cosmos container or Firestore collection) and its keys:
DocumentStore orders = halyx.provider("aws").documentStore(TableConfig.builder("orders")
.sortKey("sk") // omit for tables keyed by partition only
.index("by-status", "status", "placedAt") // a secondary index: partition and sort attribute
.build());
DocumentStore customers = halyx.provider("gcp").documentStore(TableConfig.of("customers"));
The key attributes are called pk and sk by default (partitionKey(...) and sortKey(...) rename
them). Tables are infrastructure: create them with your usual tooling, with the key attributes named as
in the TableConfig.
Items and documents#
Key key = Key.of("acme", "2026-10-11#1"); // partition, sort
Document order = Document.builder()
.put("status", "PAID")
.put("total", 120)
.put("placedAt", "2026-10-11T09:30:00Z")
.put("lines", List.of(Document.builder().put("sku", "A-1").put("qty", 2).build()))
.build();
Item item = orders.put(key, order); // create or replace
Optional<Item> found = orders.get(key);
found.ifPresent(i -> {
Document d = i.document();
String status = d.getString("status").orElseThrow();
long total = d.getLong("total").orElse(0L);
Version version = i.version(); // for optimistic concurrency
});
boolean deleted = orders.delete(key); // false if it didn't exist
Values: strings, whole numbers, doubles, decimals (BigDecimal), booleans, binary, null, lists and
nested documents. Read them back with getString, getLong, getDouble, getDecimal, getBoolean,
getBinary, getDocument and getList.
Records#
RecordMapper maps Java records to documents and back, including enums, Instant, LocalDate, UUID,
Optional, lists and maps:
record Order(String status, BigDecimal total, Instant placedAt, List<Line> lines) {}
RecordMapper<Order> mapper = RecordMapper.of(Order.class);
orders.put(key, mapper.toDocument(order));
Order loaded = mapper.fromDocument(orders.get(key).orElseThrow().document());
Partial updates#
update changes fields atomically, without reading the item first:
Item updated = orders.update(key, DocumentUpdate.builder()
.set("status", "SHIPPED")
.increment("total", 5)
.remove("draftNotes")
.build());
Concurrent increments are never lost. Updating a missing item fails with NotFoundException.
Optimistic concurrency and conditions#
Every item has a Version. WriteOptions turn lost updates into PreconditionFailedException:
Item current = orders.get(key).orElseThrow();
try {
orders.update(key, DocumentUpdate.builder().set("status", "PAID").build(),
WriteOptions.ifVersion(current.version())); // fails if someone else wrote first
} catch (PreconditionFailedException e) {
// reread and retry, or report a conflict
}
orders.put(Key.of("acme", "2026-10-11#2"), order, WriteOptions.ifNotExists()); // create-only
orders.delete(key, current.version()); // conditional delete
Expiry#
orders.put(Key.of("session", token), session, WriteOptions.builder().ttl(Duration.ofHours(1)).build());
Expired items are never returned, even before the store deletes them, and they don't block
ifNotExists. Enable the store's own expiry so they are eventually removed (see
provider details).
Queries#
Queries read one partition, ordered by sort key, optionally narrowed by a sort-key condition:
// Lazily stream every match, following page tokens for you
orders.queryAll(Query.builder("acme")
.where(SortCondition.beginsWith("2026-10"))
.descending()
.build())
.map(i -> mapper.fromDocument(i.document()))
.forEach(this::show);
// Or page by hand, for example behind an API
QueryPage page = orders.query(Query.builder("acme").limit(50).pageToken(token).build());
page.items();
page.nextPageToken(); // Optional: empty on the last page
Sort conditions: equalTo, beginsWith, between, greaterThan, greaterThanOrEqual, lessThan and
lessThanOrEqual. Query.partition("acme") reads a whole partition.
Batches#
Map<Key, Item> items = orders.getAll(List.of(key1, key2, key3)); // missing keys are left out
orders.putAll(Map.of(key1, doc1, key2, doc2));
Batches are split into each provider's limits. If the store throttles part of a batch (DynamoDB returns
unprocessed items), the call throws the retryable ThrottledException naming the items, instead of
waiting and resending. Retrying putAll is safe, because it rewrites the same content.
Secondary indexes#
Declare an index on the table, then query across partitions by its attribute:
orders.queryIndexAll(IndexQuery.builder("by-status")
.equalTo("PAID")
.where(SortCondition.greaterThan("2026-10-01")) // on the index's sort attribute
.descending()
.build())
.forEach(this::ship);
Results are ordered and narrowed by the index's sort attribute. Items without the indexed attribute aren't in the index.
Transactions#
Up to 100 operations on one partition, applied atomically:
Map<Key, Version> versions = orders.transact(Transaction.builder()
.put(Key.of("acme", "order#2"), orderDoc, WriteOptions.ifNotExists())
.put(Key.of("acme", "order#2#line#1"), lineDoc)
.update(Key.of("acme", "counter"), DocumentUpdate.builder().increment("orders", 1).build())
.delete(Key.of("acme", "draft"))
.check(Key.of("acme", "account"), accountVersion) // must still be at this version
.build());
If any condition fails, nothing is applied, and the call throws PreconditionFailedException. It
returns the new versions of the written items.
Asynchronous calls#
async() gives the store's asynchronous view: get, put, update, delete, query and queryIndex as
CompletableFutures, with the same checks, conditions, expiry handling and errors as the blocking calls.
orders.async().put(key, document, WriteOptions.ifNotExists())
.thenCompose(item -> orders.async().query(Query.partition(customer)))
.thenAccept(page -> log.info("{} orders", page.items().size()));
They're non-blocking on DynamoDB (DynamoDbAsyncClient over Netty, created on the first asynchronous call), Cosmos
DB (the SDK's async client) and Firestore (its own futures), except where a call needs a read-then-write: update
on Cosmos DB and Firestore, and a versioned put on Firestore, which runs in a transaction. Those, and every call on
the memory provider, run the blocking call on a virtual thread, or on the executor given to async(executor).
Batches and transactions are on blocking(). The reactive views use it.
Provider details#
| DynamoDB | Cosmos DB (NoSQL API) | Firestore (native mode) | |
|---|---|---|---|
| Table | table with string pk (and sk) keys |
container partitioned on /pk |
collection; sorted items in <table>/<pk>/<table>/<sk> |
| Version | halyx_version attribute |
ETag | update time |
| Expiry | halyx_expires (epoch seconds): enable TTL on it |
ttl (container default TTL must be on, for example -1) |
halyx_expires Timestamp: add a TTL policy |
| Query | key condition on sk |
SQL within the partition, ORDER BY sk |
ordered on the halyx_sort field |
| Transaction | TransactWriteItems |
transactional batch | read-check-write transaction |
| Index | a global secondary index of that name (eventually consistent) | a cross-partition query (default indexing) | a collection-group query: add a composite index on the index fields |
| Decimals | exact | exact (stored as a string wrapper) | stored as doubles |
| Item size | 400 KB | 2 MB | about 1 MiB |
| Reads | strongly consistent | the account's consistency level | strongly consistent |
On Cosmos DB, item ids can't contain / \ ? #; Halyx escapes them (with ~), so any key string works.
Keys with those characters are covered by the conformance kit on every provider.
Spring Data repositories#
Records can also be stored through Spring Data repositories, with derived queries on the keys and indexes, optimistic locking and keyset scrolling. See Spring Data repositories.
Native clients#
DynamoDbClient dynamo = orders.unwrap(DynamoDbClient.class); // AWS
CosmosContainer container = orders.unwrap(CosmosContainer.class); // Azure
Firestore firestore = orders.unwrap(Firestore.class); // Google Cloud