Object mapping
For the complete documentation index see: llms.txt
All documentation pages available in markdown.
Learn how to map Java domain objects to and from Aerospike records using the Developer SDK’s built-in RecordMapper API, or add annotation-based mapping with aerospike-sdk-mapper-java.
Aerospike records contain bins: named values such as name, email, and address. Production Java applications usually work with domain objects such as Customer, Address, and Order. Object mapping converts between those two representations.
Overview
There are two ways to map objects:
- The Java Developer SDK includes typed datasets, typed keys, and the
RecordMapper<T>extension point. This is dependency-free beyond the SDK, but application code implements each mapping. aerospike-sdk-mapper-javaimplementsRecordMapper<T>from annotations. It also supplies create, read, update, and delete (CRUD) methods and handles embedded and referenced object graphs.
Both approaches use the same Java Developer SDK Cluster, Session, operation builders, queries, and record streams.
Mapping provided by the Developer SDK
The built-in API separates three concerns:
RecordMapper<T>converts bins to and from one Java type and obtains an object’s user key.RecordMappingFactoryfinds the mapper registered for a Java class.TypedDataSet<T>andTypedKey<T>carry the Java class through an operation so the SDK can select the mapper and return aTypedRecordStream<T>.
All of these types, along with TypedKeyList, RecordStream, TypedRecordStream, RecordResult, and RecordReadContext used later in this guide, are in com.aerospike.client.sdk. Import each one from that package on first use.
RecordMapper, TypedDataSet, and TypedKey require com.aerospike:aerospike-client-sdk 1.0.0 or later, and Java 21.
A business object and its mapper
The SDK does not infer a mapping from fields or annotations. Implement RecordMapper<T> explicitly:
public final class Customer { private final String customerId; private final String name; private final String email; private final int loyaltyPoints;
public Customer(String customerId, String name, String email, int loyaltyPoints) { this.customerId = customerId; this.name = name; this.email = email; this.loyaltyPoints = loyaltyPoints; }
public String customerId() { return customerId; } public String name() { return name; } public String email() { return email; } public int loyaltyPoints() { return loyaltyPoints; }}import com.aerospike.client.sdk.Key;import com.aerospike.client.sdk.RecordMapper;import com.aerospike.client.sdk.util.MapUtil;
import java.util.Map;
public final class CustomerRecordMapper implements RecordMapper<Customer> { @Override public Customer fromMap(Map<String, Object> bins, Key key, int generation) { return new Customer( MapUtil.asString(bins, "customerId"), MapUtil.asString(bins, "name"), MapUtil.asString(bins, "email"), MapUtil.asInt(bins, "loyaltyPoints") ); }
@Override public Map<String, Object> toMap(Customer customer) { return MapUtil.buildMap() .add("customerId", customer.customerId()) .add("name", customer.name()) .add("email", customer.email()) .add("loyaltyPoints", customer.loyaltyPoints()) .done(); }
@Override public Object id(Customer customer) { return customer.customerId(); }}toMap values must be values that the SDK can store, such as strings, integral numbers, doubles, byte arrays, Lists, and Maps. Mapping Java-specific types such as BigDecimal, Instant, or a business object is the mapper’s responsibility.
id returns the Aerospike user key used by object-based writes. Supported user-key representations are strings, integral numeric types, and byte arrays.
Registering mappers
Register a mapping factory on the Cluster. A session can then resolve a mapper from the class carried by a typed dataset or key.
import com.aerospike.client.sdk.DefaultRecordMappingFactory;import com.aerospike.client.sdk.RecordMapper;
RecordMapper<Customer> customerMapper = new CustomerRecordMapper();
cluster.setRecordMappingFactory( DefaultRecordMappingFactory.of(Customer.class, customerMapper));A typed operation on a class with no registered mapper (no RecordMappingFactory set on the Cluster, or a factory that returns no mapper for that class) throws IllegalStateException with a message naming the missing type, on both read and write paths.
DataSet, TypedDataSet, and TypedKey
A DataSet represents an Aerospike namespace and set. It is not associated with a Java class:
DataSet customerRecords = DataSet.of("production", "customers");Key customerKey = customerRecords.id("customer-1001");A TypedDataSet<T> adds Class<T>:
TypedDataSet<Customer> customers = TypedDataSet.of("production", "customers", Customer.class);Calling id on it produces a TypedKey<T>:
TypedKey<Customer> customerKey = customers.id("customer-1001");The types interplay as follows:
TypedDataSet<Customer> = namespace + set + Customer.class | +-- id("customer-1001") | -> TypedKey<Customer> | = native Key + Customer.class | +-- ids("customer-1001", "customer-1002") -> TypedKeyList<Customer>
typed dataset/key -> Session operation -> mapping factory -> RecordMapper<Customer> -> TypedRecordStream<Customer>TypedDataSet wraps a regular DataSet. Call asDataSet() when an operation expects the untyped form.
Writing objects
The mapper registered for Customer.class is selected automatically using the type from the TypedDataSet and the mapper from the RecordMappingFactory:
Customer alice = new Customer( "customer-1001", "Alice Chen", "alice@example.com", 720);
try (RecordStream result = session.insert(customers) .object(alice) .execute()) { // Consume or inspect the operation result when required.}The same pattern applies to upsert, update, replace, and replaceIfExists:
import java.util.List;
List<Customer> importedCustomers = loadCustomersFromTheBillingSystem();
try (RecordStream result = session.upsert(customers) .objects(importedCustomers) .execute()) { result.forEach(recordResult -> { // Check each result according to the application's error policy. });}An operation can override the registered mapper:
session.insert(customers) .object(alice) .using(customerMapper) .execute() .close();Prefer the cluster factory when the same mapping is used throughout the application. An explicit mapper is useful for a one-off alternative projection.
Typed reads
A typed dataset query produces a TypedRecordStream<Customer>:
List<Customer> highValueCustomers;
try (TypedRecordStream<Customer> stream = session.query(customers) .where("$.loyaltyPoints >= 500") .execute()) { highValueCustomers = stream.toObjectList();}A typed key gives a typed point read:
import java.util.Optional;
Optional<Customer> customer = session.query(customers.id("customer-1001")) .execute() .getFirstObject();Several keys for the same entity type can remain typed:
TypedKeyList<Customer> keys = customers.ids("customer-1001", "customer-1002", "customer-1003");
List<Customer> selectedCustomers;try (TypedRecordStream<Customer> stream = session.queryTypedKeys(keys).execute()) { selectedCustomers = stream.toObjectList();}Every key in one typed-key batch must carry the same entity class. For a batch containing customers and orders, create separate query legs. Such a heterogeneous chain returns an untyped RecordStream. Each successful row still carries its own mapping hint and can be converted with RecordResult.toObject().
TypedDataSet<Customer> customers = TypedDataSet.of("production", "customers", Customer.class);TypedDataSet<Order> orders = TypedDataSet.of("production", "orders", Order.class);
// Illegal: one typed-key list cannot mix Customer and Order.// session.queryTypedKeysAny(List.of(// customers.id("customer-1001"),// orders.id("order-9001")));
try (RecordStream stream = session.query(customers.id("customer-1001")) .query(orders.id("order-9001")) .execute()) { while (stream.hasNext()) { RecordResult row = stream.next(); if (!row.isOk()) { continue; } Object mapped = row.toObject(); if (mapped instanceof Customer customer) { // Use the customer. } else if (mapped instanceof Order order) { // Use the order. } }}The factory must register mappers for both Customer and Order. Do not assume two next() calls without checking isOk(). A failed or filtered leg can omit a row.
Using a mapper without typed keys
The mapping API can also be used with a normal DataSet or Key, but the stream does not know the target class. Supply the mapper explicitly:
DataSet rawCustomers = customers.asDataSet();
Optional<Customer> customer;try (RecordStream stream = session.query(rawCustomers.id("customer-1001")).execute()) { customer = stream.getFirst(customerMapper);}This explicit form invokes the three-argument RecordMapper.fromMap overload. See Three-argument and four-argument fromMap for when each overload is used.
Nested data with the built-in mapper
The SDK supplies the mapping extension point, not reflection-based nested-object mapping. A mapper can store a nested object as an Aerospike Map:
public record Address( String line1, String city, String state, String postalCode) {}private static Map<String, Object> addressToMap(Address address) { return Map.of( "line1", address.line1(), "city", address.city(), "state", address.state(), "postalCode", address.postalCode() );}
@SuppressWarnings("unchecked")private static Address addressFromMap(Object value) { Map<String, Object> address = (Map<String, Object>) value; return new Address( (String) address.get("line1"), (String) address.get("city"), (String) address.get("state"), (String) address.get("postalCode") );}Call those helpers from the parent mapper’s toMap and fromMap. If a nested object lives in a separate Aerospike record, store its key in the parent and load it explicitly. Dependent reads of that kind use the four-argument fromMap overload described in the following section.
Three-argument and four-argument fromMap
RecordMapper<T> defines two fromMap overloads:
T fromMap(Map<String, Object> bins, Key key, int generation);
default T fromMap( Map<String, Object> bins, Key key, int generation, RecordReadContext<T> context) { return fromMap(bins, key, generation);}The three-argument method is required. Implement it with bins, key, and generation only. The four-argument method is optional. Its default implementation delegates to the three-argument method.
Override the four-argument method when deserialization needs the current Session, mapping factory, or entity class. Typical uses are nested bins that require another mapper, or loading a referenced record:
@Overridepublic Customer fromMap( Map<String, Object> bins, Key key, int generation, RecordReadContext<Customer> context) { Customer customer = fromMap(bins, key, generation); // context.getSession() can perform dependent reads. return customer;}Which overload the SDK calls depends on the read path:
- Typed, factory-backed reads (
TypedRecordStream.getFirstObject(),toObjectList(),RecordResult.toObject()on a typed leg) pass aRecordReadContextand call the four-argument method. - Mapper-only helpers on an untyped
RecordStream, such asgetFirst(customerMapper)andtoObjectList(customerMapper), call the three-argument method.
Built-in mapping caveats
- Mapping code must handle missing bins when a query reads only a projection.
- The registered
RecordMapperinstance is shared across every session and thread on theCluster. ImplementfromMap,toMap, andidas stateless, or guard any instance state so it is thread-safe. TypedDataSet<T>improves compile-time safety, but it does not validate that all records in the Aerospike set were written with the same schema.- Adding a second operation to a typed point-read chain can widen its return type to
RecordStream. Use per-row mapping for mixed operation chains. - Avoid dependent point reads from
fromMapfor large result sets. Each dependent read is an extra round trip to the cluster, so a single query can multiply into thousands of round trips and drive up tail latency and cluster load. Batch-load referenced keys instead. - The built-in API has no generation parameter on
toMap. To do a generation-checked write, apply the write policy’s generation check on the SDK operation itself, the same way any non-mapped write does, rather than through the mapper.
Annotation mapping with aerospike-sdk-mapper-java
The aerospike-sdk-mapper-java library supplies reflection- and annotation-based implementations of the SDK mapping interfaces. It removes most hand-written RecordMapper code and adds automatic handling of embedded and referenced objects.
As well as standard primitives that Aerospike understands, the object mapper can also map fields to/from other Java classes including Date, LocalDate, LocalTime, LocalDateTime, Instant, BigInteger and BigDecimal.
Maven dependency
The current project artifact is:
<dependency> <groupId>com.aerospike</groupId> <artifactId>aerospike-sdk-mapper-java</artifactId> <version>1.0.0-SNAPSHOT</version></dependency>Like the Java SDK, the mapper uses the SLF4J API for logging. We recommend also providing an SLF4J implementation appropriate for the application’s runtime.
Annotating a customer
@AerospikeRecord identifies a mapped class and its storage location. @AerospikeKey identifies the user key. With the default mapAll = true, fields are mapped even when they do not have @AerospikeBin. Use @AerospikeBin to rename a bin.
import com.aerospike.mapper.annotations.AerospikeBin;import com.aerospike.mapper.annotations.AerospikeEmbed;import com.aerospike.mapper.annotations.AerospikeKey;import com.aerospike.mapper.annotations.AerospikeRecord;import com.aerospike.mapper.annotations.AerospikeReference;
import java.util.ArrayList;import java.util.List;
@AerospikeRecord(namespace = "production", set = "customers")public class Customer { @AerospikeKey @AerospikeBin(name = "customerId") private String id;
private String name; private String email;
@AerospikeBin(name = "shipAddr") @AerospikeEmbed private Address shippingAddress;
@AerospikeReference private List<Order> recentOrders;
private boolean vip;
public Customer() { recentOrders = new ArrayList<>(); }
public Customer(String id, String name, String email) { this(); this.id = id; this.name = name; this.email = email; }
// Getters and setters omitted.}The mapper needs a way to instantiate each loaded object. The common choice is a no-argument constructor. For immutable classes, annotate a constructor and bind its parameters:
@AerospikeConstructorpublic Customer( @ParamFrom("customerId") String id, @ParamFrom("name") String name, @ParamFrom("email") String email) { this.id = id; this.name = name; this.email = email; this.recentOrders = new ArrayList<>();}Every class that is stored as its own Aerospike record must declare a namespace on @AerospikeRecord. Set the set attribute as well so the mapping engine knows which set to read and write:
@AerospikeRecord(namespace = "production", set = "customers")public class Customer { ... }A class that is only ever nested with @AerospikeEmbed can use @AerospikeRecord with both attributes omitted:
@AerospikeRecordpublic class Address { ... }Setting up the mapping engine
Build a MappingEngine and install it on the Cluster. The engine owns class introspection, annotation configuration, and the RecordMappingFactory implementation that backs the SDK’s typed reads and writes:
import com.aerospike.mapper.tools.MappingEngine;
MappingEngine engine = MappingEngine.builder().build();engine.installOn(cluster);
TypedDataSet<Customer> customers = engine.typedDataSet(Customer.class);installOn registers the engine’s mapping factory on the cluster, equivalent to calling cluster.setRecordMappingFactory(...) directly. After that call, any plain Session created from the cluster can use TypedDataSet and TypedKey with standard SDK operations, fully annotation-aware, with no further mapper-specific setup.
MappingEngine is thread-safe and relatively expensive to construct (it introspects and caches mapped classes). Keep a single engine in the application and create sessions from it as needed.
Database operations through engine.typedDataSet and the other mapper methods used in this guide fail if @AerospikeRecord’s namespace attribute is blank.
To confirm the wiring works before writing production code, write an object and read it back:
Customer probe = new Customer("verify-1", "Probe", "probe@example.com");session.upsert(customers).object(probe).execute().close();
Optional<Customer> roundTrip;try (TypedRecordStream<Customer> stream = session.query(customers.id("verify-1")).execute()) { roundTrip = stream.getFirstObject();}assert roundTrip.isPresent();Embedded objects
An embedded object is serialized inside its parent record. It does not need a namespace or set because it is not stored as an independent Aerospike record. Unlike the immutable Address record used with the hand-written mapper earlier in this guide, annotation-based mapping needs a mutable class with a no-arg (or @AerospikeConstructor-annotated) constructor:
@AerospikeRecordpublic class Address { @AerospikeKey private String line1;
private String city; private String state;
@AerospikeBin(name = "postalCode") private String postcode;
public Address() {}
public Address( String line1, String city, String state, String postcode) { this.line1 = line1; this.city = city; this.state = state; this.postcode = postcode; }
// Getters and setters omitted.}@AerospikeEmbed defaults to map representation. Saving a customer recursively converts the address into a nested map, and reading the customer recursively reconstructs the Address:
Customer customer = new Customer();customer.setId("customer-1001");customer.setName("Alice Chen");customer.setShippingAddress( new Address("15 Market Street", "Sydney", "NSW", "2000"));
session.upsert(customers).object(customer).execute(); // The embedded Address is saved as part of Customer.
Optional<Customer> loaded = session.query(customers.id("customer-1001")) .execute() .getFirstObject();loaded.ifPresent(cust -> { Address address = cust.getShippingAddress(); // Already reconstructed});Embeds can be nested and can appear in Lists, Maps, and arrays. A collection’s elements are recursively mapped:
@AerospikeRecordpublic class OrderSummary { @AerospikeKey private String orderId;
@AerospikeEmbed private List<LineItem> items;
public OrderSummary() {}}
@AerospikeRecordpublic class LineItem { @AerospikeKey private String sku;
private int quantity; private long unitPriceInCents;
public LineItem() {}}For the usual document-like representation, leave type and elementType at their defaults. @AerospikeEmbed(type = LIST) stores an object’s fields by position and is more compact, but field ordering then becomes part of the stored schema. Prefer map embeds unless that trade-off is intentional.
Referenced objects
A referenced object is a separate Aerospike record. The parent stores the child’s ID or digest rather than embedding all child fields.
import java.time.Instant;
@AerospikeRecord(namespace = "production", set = "orders")public class Order { @AerospikeKey private String orderId;
private Instant placedAt; private long totalInCents;
public Order() {}
public Order(String orderId, Instant placedAt, long totalInCents) { this.orderId = orderId; this.placedAt = placedAt; this.totalInCents = totalInCents; }}@AerospikeReference defaults to ID references and batch loading:
@AerospikeReferenceprivate List<Order> recentOrders;An annotated record field that is not explicitly embedded is also treated as a reference by default. We recommend using @AerospikeReference explicitly because it makes the storage model and loading options visible in the domain class.
When a non-lazy reference’s child record does not exist, the mapper sets that field to null rather than throwing or returning a stub object with only the key populated. This applies to both the default batched load and to batchLoad = false inline loads.
Save every referenced child explicitly before or alongside the parent. Writes go through any Session created from the cluster after engine.installOn(cluster), since that call registers the mapping factory upsert(...).object(...) needs to convert the annotated types:
TypedDataSet<Order> orders = engine.typedDataSet(Order.class);TypedDataSet<Customer> customers = engine.typedDataSet(Customer.class);
Order order = new Order("order-9001", Instant.now(), 12_500);Customer customer = new Customer( "customer-1001", "Alice Chen", "alice@example.com");customer.getRecentOrders().add(order);
session.upsert(orders).object(order).execute(); // Required: references do not cascade on write.session.upsert(customers).object(customer).execute(); // Stores "order-9001" as a reference.
Customer loaded;try (TypedRecordStream<Customer> stream = session.query(customers.id("customer-1001")).execute()) { loaded = stream.getFirstObject().orElseThrow();}Order loadedOrder = loaded.getRecentOrders().get(0);// loadedOrder is populated automatically by the default batch loader (see the batchLoad option in this section).For several children, a parent write still does not cascade:
session.upsert(orders).objects(customer.getRecentOrders()).execute();session.upsert(customers).object(customer).execute();The common reference options are:
batchLoad = trueis the default. References collected while loading the parent are read in batches. Keep this enabled for collections and object graphs.batchLoad = falseresolves each reference inline. Use it only for unusual read flows. It can cause many network calls.lazy = truedoes not read the child. The mapper creates a child object with only its key populated.type = AerospikeReference.ReferenceType.DIGESTstores the record digest instead of the user key. Digest references cannot be lazy.
Use @AerospikeEmbed when the nested value has the parent’s lifecycle and is normally read with it. Use @AerospikeReference when the child has its own identity, is updated independently, or is shared. The two annotations cannot be placed on the same field.
Using annotation-derived typed datasets with the SDK
Because engine.typedDataSet(Class) returns a normal Java Developer SDK TypedDataSet<T>, it works with every standard SDK call:
TypedDataSet<Customer> customers = engine.typedDataSet(Customer.class);
Customer customer = new Customer( "customer-1001", "Alice Chen", "alice@example.com");
session.upsert(customers) .object(customer) .execute() .close();
Optional<Customer> loaded;try (TypedRecordStream<Customer> stream = session.query(customers.id("customer-1001")).execute()) { loaded = stream.getFirstObject();}
List<Customer> vipCustomers;try (TypedRecordStream<Customer> stream = session.query(customers) .where("$.vip == true") .execute()) { vipCustomers = stream.toObjectList();}Annotation behavior is retained on these SDK paths. In particular, embedded objects are reconstructed and non-lazy references are resolved because typed reads provide the RecordReadContext needed by the mapper.
Object mapping coexists with ordinary database operations
Object mapping does not create a separate storage format. A mapped Customer is an ordinary Aerospike record, so the same data can be read or changed with standard SDK bin operations and later loaded again as an object:
TypedDataSet<Customer> customers = engine.typedDataSet(Customer.class);DataSet rawCustomers = customers.asDataSet();
try (RecordStream stream = session.query(rawCustomers.id("customer-1001")).execute()) { RecordResult result = stream.next(); Record record = result.recordOrThrow(); String email = record.getString("email");}session.update(rawCustomers.id("customer-1001")) .bin("loyaltyPoints").setTo(800) .execute();
Customer reloaded;try (TypedRecordStream<Customer> stream = session.query(customers.id("customer-1001")).execute()) { reloaded = stream.getFirstObject().orElseThrow();}This interoperability is useful for atomic collection data type (CDT) operations, targeted bin updates, and services that do not all use the same object mapper. Keep bin names and stored types compatible with the annotated model.
Common annotation options
The core annotations used by most applications are:
@AerospikeRecord(namespace, set)declares a mapped type.ttlsupplies a record TTL in seconds.mapAll = falselimits persistence to explicitly mapped bins.@AerospikeKeydeclares the single user-key property.@AerospikeBin(name = "...")renames a bin.useAccessors = trueuses the property’s accessor methods.@AerospikeExcludeprevents a field from being persisted.@AerospikeEmbedrecursively stores an object inside its parent.@AerospikeReferencestores a pointer to an independently persisted object.@AerospikeGenerationmaps the record generation for optimistic concurrency.@AerospikeConstructorand@ParamFromsupport loading objects through a constructor.
Example with explicit fields and optimistic concurrency:
@AerospikeRecord( namespace = "production", set = "customers", mapAll = false, ttl = 86_400)public class CustomerProfile { @AerospikeKey @AerospikeBin private String customerId;
@AerospikeBin(name = "displayName") private String name;
@AerospikeGeneration private int generation;
@AerospikeExclude private String requestCorrelationId;
public CustomerProfile() {}}When a positive mapped generation is written, the mapper applies a generation equality check using the same .ensureGenerationIs(...) mechanism as a hand-written SDK operation, executed directly rather than wrapped. A stale write therefore throws the same GenerationException any non-mapped generation-checked write throws, instead of silently overwriting a newer record.
Production guidance
- Model an embedded object as part of its parent record’s size. Aerospike’s record size limits and the cost of rewriting a large document still apply.
- Explicitly save referenced children. There is no cascading write or delete.
- Default reference reads are automatic and batched. Lazy references contain only their key.
- Use map embeds for schemas that evolve. Positional list embeds need deliberate versioning and ordering.
- Use partial-bin reads only when the mapped constructor and fields tolerate absent bins.
- Keep SDK and mapper versions compatible. The mapper implements SDK interfaces, so an incompatible SDK version can fail at compile time or linkage time.
- Supply a no-argument constructor, an unambiguous constructor, an
@AerospikeConstructor, or a configured factory method. - Close SDK streams only when a terminal operation does not already do so. See Closing streams. Direct
MappingSessionmethods (see Optional: MappingSession convenience methods) consume and close their internal streams.
Optional: MappingSession convenience methods
MappingSession wraps a Session and adds save/read-style methods that combine dataset resolution, the operation call, and stream consumption into a single call. Create one from the same engine used to install the mapping factory:
import com.aerospike.mapper.tools.MappingSession;
MappingSession mappingSession = cluster.createSession(Behavior.DEFAULT, engine.sessionExtension());
mappingSession.save(customer); // Replace the mapped record.Customer loaded = mappingSession.read(Customer.class, "customer-1001");MappingSession extends Session, so it works anywhere a Session is expected, and it carries the Behavior it was created with, the same way any Session does. Use sessionFor(behavior) to derive a session with a different Behavior from the same engine, rather than constructing a second engine:
MappingSession fast = mappingSession.sessionFor(fastBehavior);Customer quick = fast.read(Customer.class, "customer-1001");Database operations through mappingSession.save/mappingSession.read fail if @AerospikeRecord’s namespace attribute is blank, the same requirement as the plain Session path.
Closing streams
RecordStream and TypedRecordStream are Closeable. close() is idempotent. You do not always need try-with-resources.
Terminal operations
Terminal operations close the stream for you. On RecordStream, that includes getFirst(), getFirstRecord(), forEach(), and asCompletableFuture(). On TypedRecordStream, it also includes getFirstObject(), toObjectList(), and forEachObject(). After one of those returns, there is nothing left to close:
List<Customer> highValueCustomers = session.query(customers) .where("$.loyaltyPoints >= 500") .execute() .toObjectList();Point reads and batch operations
Point reads and batch/key operations are fully buffered. Iterating them with hasNext() / next() does not require an explicit close.
Set queries
Close set queries when you might stop early. session.query(TypedDataSet) / session.query(DataSet) can stream many records from the server. If you iterate with hasNext() / next() and might break, throw, or otherwise leave records unread, wrap the stream in try-with-resources. Closing an in-progress index query cancels it on the server. An unclosed set-query stream keeps its server-side query and pooled connection open. Under sustained load, accumulated unclosed streams can exhaust the connection pool and cause unrelated operations to time out. Always close (or use try-with-resources) on any code path that might exit early.
try (TypedRecordStream<Customer> stream = session.query(customers).execute()) { Optional<Customer> next; while ((next = stream.popObject()).isPresent()) { Customer customer = next.get(); if (shouldStop(customer)) { break; // close() cancels the remaining query } }}pop() and popObject()
pop() (on RecordStream and TypedRecordStream) and popObject() (on TypedRecordStream only) do not close the stream. Close it when you are finished:
TypedRecordStream<Customer> stream = session.query(customers.ids("customer-1001", "customer-1002")) .execute();Optional<RecordResult> first = stream.pop();Optional<RecordResult> second = stream.pop();stream.close();stream()
stream() does not close immediately. Close the returned Java Stream (try-with-resources is the usual way). That closes the underlying RecordStream.
Next steps
Data model
See how namespaces, sets, records, and bins underlie every mapped object.
Understanding the SDK API
Review the fluent, builder-based design that object mapping builds on.
Create records
Start writing data with insert and upsert.
Read records
Query and point-read records, typed or untyped.
Error handling
Handle the exceptions that mapped reads and writes can throw.
Data modeling best practices
Design access patterns before you decide what to embed versus reference.
Java Developer SDK repository
Browse the SDK source, including RecordMapper and related types.
aerospike-sdk-mapper-java repository
Browse the annotation-based mapper’s source, including MappingEngine and MappingSession.