The DynamoDB Enhanced Client in Java lets you write type-safe queries against DynamoDB without ever touching the low-level AttributeValue objects, and it’s surprisingly fast.
Let’s see it in action. Imagine you have a Product table with productId (String) and category (String) as your composite primary key, and you want to retrieve all products in a specific category.
Here’s a simple Product POJO:
@DynamoDbBean
public class Product {
private String productId;
private String category;
private String name;
private double price;
@DynamoDbPartitionKey
public String getProductId() {
return productId;
}
public void setProductId(String productId) {
this.productId = productId;
}
@DynamoDbSortKey
public String getCategory() {
return category;
}
public void setCategory(String category) {
this.category = category;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public double getPrice() {
return price;
}
public void setPrice(double price) {
this.price = price;
}
}
Now, to query for products in the "Electronics" category:
import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient;
import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable;
import software.amazon.awssdk.enhanced.dynamodb.TableSchema;
import software.amazon.awssdk.enhanced.dynamodb.Key;
import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest;
import software.amazon.awssdk.enhanced.dynamodb.model.PageIterable;
import java.util.List;
import java.util.ArrayList;
// Assuming you have a configured DynamoDbClient
DynamoDbEnhancedClient enhancedClient = DynamoDbEnhancedClient.builder()
.dynamoDbClient(dynamoDbClient)
.build();
DynamoDbTable<Product> productTable = enhancedClient.table("Product", TableSchema.fromBean(Product.class));
// Construct the key condition expression
// We want products where the category (sort key) is 'Electronics'
// Since productId is the partition key, we don't need to specify it for a query
// that scans the entire partition. However, for a more targeted query,
// you would include it. For this example, we're assuming we want to
// query across *all* product IDs, looking for the category.
// A more common scenario would be to query for a *specific* productId and category.
// Let's refine this to a more typical use case: find all items in a partition.
// Example: Find all items for a specific productId and category 'Electronics'
// This is not a typical query pattern for a composite key where category is the sort key.
// A more common query would be:
// 1. Get items for a specific productId (partition key).
// 2. Get items for a specific productId AND a range of categories.
// Let's demonstrate a query for a specific productId and then filter by category.
// If you want to query *all* items with category 'Electronics' across *all* productIds,
// you'd typically use a Scan operation with a FilterExpression, or a Global Secondary Index (GSI).
// For a QUERY operation, you *must* provide the partition key.
// Let's assume you want to find products in the 'Electronics' category
// for a specific productId, say 'prod-123'.
Key keyCondition = Key.builder()
.partitionValue("prod-123") // The productId
.build();
QueryEnhancedRequest queryRequest = QueryEnhancedRequest.builder()
.keyConditionExpression(expression -> expression.eq(e -> e.sortKeyValues("Electronics"))) // Filter by category
.build();
PageIterable<Product> response = productTable.query(keyCondition, queryRequest);
List<Product> productsInCategory = new ArrayList<>();
response.items().forEach(productsInCategory::add);
System.out.println("Products in category 'Electronics' for productId 'prod-123': " + productsInCategory.size());
This code defines a Product POJO, annotates its fields for DynamoDB mapping, and then uses the DynamoDbEnhancedClient to construct and execute a query operation. The Key.builder().partitionValue("prod-123") specifies the partition key we’re interested in, and the keyConditionExpression with expression.eq(e -> e.sortKeyValues("Electronics")) filters for items where the sort key (category) matches "Electronics". The PageIterable<Product> directly returns Product objects, not raw AttributeValue maps.
The core problem this solves is the impedance mismatch between your application’s object model and DynamoDB’s attribute-value store. The low-level SDK requires you to manually map Java types to DynamoDB’s internal representation (e.g., String to AttributeValue.builder().s("value").build()), which is tedious, error-prone, and obscures the intent of your data access code. The enhanced client automates this mapping, allowing you to work with your POJOs directly. It leverages Java’s reflection and annotations (@DynamoDbBean, @DynamoDbPartitionKey, @DynamoDbSortKey) to understand your object’s structure and translate it to DynamoDB operations and vice-versa.
Internally, when you call productTable.query(...), the enhanced client inspects your Product class via its TableSchema. It then constructs the underlying DynamoDB Query API call, including the correct KeyConditionExpression and ExpressionAttributeNames/ExpressionAttributeValues based on your POJO and the query parameters. Crucially, when the response comes back from DynamoDB, it iterates through the AttributeValue maps and deserializes them back into Product objects according to your TableSchema. This entire process happens transparently.
The levers you control are primarily through the annotations on your POJO and the parameters you pass to the DynamoDbEnhancedClient methods. You define your primary keys and indexes using @DynamoDbPartitionKey, @DynamoDbSortKey, and @DynamoDbSecondaryPartitionKey/@DynamoDbSecondarySortKey. For queries and scans, you use QueryEnhancedRequest and ScanEnhancedRequest to build filter expressions, projection expressions, sort orders, and limit the number of items returned. The TableSchema is the central piece that bridges your Java class to the DynamoDB table structure.
What most people don’t realize is how flexible the TableSchema is for handling complex data types. You can define custom marshaler methods for fields that don’t have a direct, built-in mapping. For example, if you had a java.time.Instant field, the enhanced client handles it by default by serializing it to a Unix timestamp string (e.g., "2023-10-27T10:30:00Z"). If you needed a different format, or wanted to map a complex custom object, you could provide a custom marshaler within your TableSchema definition. This allows you to maintain type safety even for data types that aren’t natively understood by DynamoDB, by defining exactly how they should be serialized and deserialized.
The next step is often exploring how to use Global Secondary Indexes (GSIs) with the enhanced client for more flexible querying patterns.