---
title: "Object mapping"
description: "Map Java objects to and from Aerospike records with the Developer SDK's RecordMapper API, or add annotation-based mapping with aerospike-sdk-mapper-java."
---

# Object mapping

> For the complete documentation index see: [llms.txt](https://aerospike.com/docs/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.

::: java developer sdk only
Object mapping is available only in the Java Developer SDK. The Python Developer SDK does not provide `RecordMapper`, `TypedDataSet`, or an annotation-based mapper, so work with bin data as plain `dict` values instead. See [Read records](https://aerospike.com/docs/develop/client/sdk/usage/read) and [Update records](https://aerospike.com/docs/develop/client/sdk/usage/update).
:::

## Overview

There are two ways to map objects:

1.  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.
2.  `aerospike-sdk-mapper-java` implements `RecordMapper<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`](https://aerospike.com/docs/develop/client/sdk/connect), [`Session`](https://aerospike.com/docs/develop/client/sdk/connect), 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.
-   `RecordMappingFactory` finds the mapper registered for a Java class.
-   `TypedDataSet<T>` and `TypedKey<T>` carry the Java class through an operation so the SDK can select the mapper and return a `TypedRecordStream<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:

```java
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; }

}
```

```java
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.

```java
import com.aerospike.client.sdk.DefaultRecordMappingFactory;

import com.aerospike.client.sdk.RecordMapper;

RecordMapper<Customer> customerMapper = new CustomerRecordMapper();

cluster.setRecordMappingFactory(

    DefaultRecordMappingFactory.of(Customer.class, customerMapper)

);
```

::: exact-class lookup
Register every mapped root type. Factory lookup uses the exact `Class`, so a mapper registered for `Customer` is not automatically used for a `BusinessCustomer` subclass.
:::

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:

```java
DataSet customerRecords = DataSet.of("production", "customers");

Key customerKey = customerRecords.id("customer-1001");
```

A `TypedDataSet<T>` adds `Class<T>`:

```java
TypedDataSet<Customer> customers =

    TypedDataSet.of("production", "customers", Customer.class);
```

Calling `id` on it produces a `TypedKey<T>`:

```java
TypedKey<Customer> customerKey = customers.id("customer-1001");
```

The types interplay as follows:

```text
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`:

```java
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`:

```java
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:

```java
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>`:

```java
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:

```java
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:

```java
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()`.

```java
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:

```java
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`](#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:

```java
public record Address(

    String line1,

    String city,

    String state,

    String postalCode

) {}
```

```java
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:

```java
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:

```java
@Override

public 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 a `RecordReadContext` and call the four-argument method.
-   Mapper-only helpers on an untyped `RecordStream`, such as `getFirst(customerMapper)` and `toObjectList(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 `RecordMapper` instance is shared across every session and thread on the `Cluster`. Implement `fromMap`, `toMap`, and `id` as 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 `fromMap` for 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.

::: schema changes don’t migrate stored records
Renaming a Java property or changing its type does not migrate previously stored records. Old records keep the old bin name and type. A type change can throw `ClassCastException` when `fromMap` reads a pre-existing record. A rename orphans historical data under the old bin name until you migrate it. Migrate stored records first, or handle both the old and new bin names or types in `fromMap` until migration completes.
:::

## Annotation mapping with aerospike-sdk-mapper-java

The [`aerospike-sdk-mapper-java`](https://github.com/aerospike/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:

```xml
<dependency>

    <groupId>com.aerospike</groupId>

    <artifactId>aerospike-sdk-mapper-java</artifactId>

    <version>1.0.0-SNAPSHOT</version>

</dependency>
```

::: snapshot dependency
This snapshot is not published to Maven Central. Install it from a project source checkout, or use a copy supplied by your organization’s Maven repository. It requires Java 21 and depends on `com.aerospike:aerospike-client-sdk:1.0.0`.
:::

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.

```java
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:

```java
@AerospikeConstructor

public 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:

```java
@AerospikeRecord(namespace = "production", set = "customers")

public class Customer { ... }
```

A class that is only ever nested with `@AerospikeEmbed` can use `@AerospikeRecord` with both attributes omitted:

```java
@AerospikeRecord

public 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:

```java
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:

```java
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:

```java
@AerospikeRecord

public 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`:

```java
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:

```java
@AerospikeRecord

public class OrderSummary {

    @AerospikeKey

    private String orderId;

    @AerospikeEmbed

    private List<LineItem> items;

    public OrderSummary() {}

}

@AerospikeRecord

public 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.

```java
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:

```java
@AerospikeReference

private 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.

::: referenced children don’t cascade on saving
Saving a parent does not save referenced children. Loading a parent does load non-lazy referenced children automatically.
:::

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:

```java
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:

```java
session.upsert(orders).objects(customer.getRecentOrders()).execute();

session.upsert(customers).object(customer).execute();
```

The common reference options are:

-   `batchLoad = true` is the default. References collected while loading the parent are read in batches. Keep this enabled for collections and object graphs.
-   `batchLoad = false` resolves each reference inline. Use it only for unusual read flows. It can cause many network calls.
-   `lazy = true` does not read the child. The mapper creates a child object with only its key populated.
-   `type = AerospikeReference.ReferenceType.DIGEST` stores 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:

```java
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.

::: untyped reads skip annotation behavior
The mapper’s SDK adapter requires a `RecordReadContext`. Do not extract its record mapper and pass it only to an untyped helper such as `RecordStream.getFirst(recordMapper)`. That path calls the unsupported three-argument deserializer. Instead construct your own context. For example `stream.getFirst(recordMapper, new RecordReadContext(session, Customer.class)`. Otherwise, use a typed dataset or key after `engine.installOn(cluster)`, or use `MappingSession.read` directly.
:::

### 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:

```java
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");

}
```

```java
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)](https://aerospike.com/docs/develop/client/sdk/concepts/cdt-operations) 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. `ttl` supplies a record TTL in seconds. `mapAll = false` limits persistence to explicitly mapped bins.
-   `@AerospikeKey` declares the single user-key property.
-   `@AerospikeBin(name = "...")` renames a bin. `useAccessors = true` uses the property’s accessor methods.
-   `@AerospikeExclude` prevents a field from being persisted.
-   `@AerospikeEmbed` recursively stores an object inside its parent.
-   `@AerospikeReference` stores a pointer to an independently persisted object.
-   `@AerospikeGeneration` maps the record generation for optimistic concurrency.
-   `@AerospikeConstructor` and `@ParamFrom` support loading objects through a constructor.

Example with explicit fields and optimistic concurrency:

```java
@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`](https://aerospike.com/docs/develop/client/sdk/concepts/errors#generation-mismatch-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](https://aerospike.com/docs/develop/data-modeling/record-sizing) 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](#closing-streams). Direct `MappingSession` methods (see [Optional: MappingSession convenience methods](#optional-mappingsession-convenience-methods)) consume and close their internal streams.

## Optional: MappingSession convenience methods

::: most applications don’t need this section
Once `engine.installOn(cluster)` runs (see [Setting up the mapping engine](#setting-up-the-mapping-engine)), any plain `Session` created from the cluster already supports annotation-aware reads and writes through `TypedDataSet`/`TypedKey`, the pattern used throughout this guide. Read on only if you also want `MappingSession`’s imperative convenience methods, such as `save`/`read`.
:::

`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:

```java
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`](https://aerospike.com/docs/develop/client/sdk/concepts/behaviors) 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:

```java
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:

```java
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](https://aerospike.com/docs/develop/client/sdk/concepts/connections) and cause unrelated operations to time out. Always close (or use try-with-resources) on any code path that might exit early.

```java
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:

```java
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.

[Data model →](https://aerospike.com/docs/develop/client/sdk/concepts/data-model)

Understanding the SDK API

Review the fluent, builder-based design that object mapping builds on.

[Understanding the SDK API →](https://aerospike.com/docs/develop/client/sdk/concepts/sdk-api)

Create records

Start writing data with insert and upsert.

[Create records →](https://aerospike.com/docs/develop/client/sdk/usage/create)

Read records

Query and point-read records, typed or untyped.

[Read records →](https://aerospike.com/docs/develop/client/sdk/usage/read)

Error handling

Handle the exceptions that mapped reads and writes can throw.

[Error handling →](https://aerospike.com/docs/develop/client/sdk/concepts/error-handling)

Data modeling best practices

Design access patterns before you decide what to embed versus reference.

[Data modeling best practices →](https://aerospike.com/docs/develop/client/sdk/concepts/data-model#data-modeling-best-practices)

Java Developer SDK repository

Browse the SDK source, including `RecordMapper` and related types.

[GitHub →](https://github.com/aerospike/aerospike-client-java-sdk)

aerospike-sdk-mapper-java repository

Browse the annotation-based mapper’s source, including `MappingEngine` and `MappingSession`.

[GitHub →](https://github.com/aerospike/aerospike-sdk-mapper-java)