Contents
- Dependency & Client Setup
- Entity Mapping with @DynamoDbBean
- CRUD Operations
- Query & Scan with GSI
- Pagination
- Optimistic Locking with @DynamoDbVersionAttribute
- Transactions
- Testing with LocalStack
The AWS SDK v2 Enhanced Client is the recommended way to access DynamoDB from Java. It uses a bean mapping layer on top of the low-level DynamoDb client. Configure the client as a Spring bean so it can be injected wherever needed.
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>dynamodb-enhanced</artifactId>
</dependency>
<!-- BOM for consistent SDK versions -->
<dependencyManagement>
<dependencies>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>bom</artifactId>
<version>2.25.40</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
@Configuration
public class DynamoDbConfig {
@Value("${aws.region:us-east-1}")
private String region;
@Bean
public DynamoDbClient dynamoDbClient() {
return DynamoDbClient.builder()
.region(Region.of(region))
// In production: use DefaultCredentialsProvider (IAM role)
.credentialsProvider(DefaultCredentialsProvider.create())
.build();
}
@Bean
public DynamoDbEnhancedClient enhancedClient(DynamoDbClient client) {
return DynamoDbEnhancedClient.builder()
.dynamoDbClient(client)
.build();
}
}
Annotate a POJO with @DynamoDbBean to enable Enhanced Client mapping. Every attribute that should persist needs a getter/setter pair. The partition key (and optional sort key) are marked with dedicated annotations. No separate table creation is needed in code — the table must exist in DynamoDB already.
@DynamoDbBean
public class Product {
private String productId; // partition key
private String category; // sort key
private String name;
private BigDecimal price;
private String status;
private Instant createdAt;
private Long version; // for optimistic locking
@DynamoDbPartitionKey
public String getProductId() { return productId; }
@DynamoDbSortKey
public String getCategory() { return category; }
@DynamoDbAttribute("product_name") // custom DynamoDB attribute name
public String getName() { return name; }
// GSI key — index on status + createdAt
@DynamoDbSecondaryPartitionKey(indexNames = "status-createdAt-index")
public String getStatus() { return status; }
@DynamoDbSecondarySortKey(indexNames = "status-createdAt-index")
public Instant getCreatedAt() { return createdAt; }
@DynamoDbVersionAttribute // auto-incremented for optimistic locking
public Long getVersion() { return version; }
// standard setters...
}
// Create a table reference (reuse this — it's just metadata, not a connection)
@Bean
public DynamoDbTable<Product> productTable(DynamoDbEnhancedClient client) {
return client.table("Products", TableSchema.fromBean(Product.class));
}
DynamoDbTable provides straightforward CRUD methods that map directly to DynamoDB operations. PutItem, GetItem, UpdateItem, and DeleteItem all use the annotated entity class — no separate query-building step needed for key-based access.
@Repository
public class ProductRepository {
private final DynamoDbTable<Product> table;
public ProductRepository(DynamoDbTable<Product> productTable) {
this.table = productTable;
}
// Create / full replace
public void save(Product product) {
product.setCreatedAt(Instant.now());
table.putItem(product);
}
// Read by primary key
public Optional<Product> findById(String productId, String category) {
Key key = Key.builder()
.partitionValue(productId)
.sortValue(category)
.build();
return Optional.ofNullable(table.getItem(key));
}
// Conditional update — only updates specified attributes (not a full replace)
public Product update(Product product) {
return table.updateItem(r -> r
.item(product)
.ignoreNulls(true)); // only write non-null fields
}
// Delete by key
public void delete(String productId, String category) {
Key key = Key.builder()
.partitionValue(productId)
.sortValue(category)
.build();
table.deleteItem(key);
}
// Conditional delete — only if attribute matches
public void deleteIfActive(String productId, String category) {
Expression condition = Expression.builder()
.expression("#s = :active")
.putExpressionName("#s", "status")
.putExpressionValue(":active", AttributeValues.stringValue("ACTIVE"))
.build();
table.deleteItem(r -> r
.key(k -> k.partitionValue(productId).sortValue(category))
.conditionExpression(condition));
}
}
DynamoDB Query fetches items by key (partition key required, sort key optional with conditions). Scan reads the entire table — use only for admin tasks or small tables. Global Secondary Indexes (GSIs) let you query on non-key attributes efficiently.
// Query all products in a category (using sort key condition)
public List<Product> findByPartitionKey(String productId) {
QueryConditional condition = QueryConditional
.keyEqualTo(k -> k.partitionValue(productId));
return table.query(condition).items().stream().collect(Collectors.toList());
}
// Query with sort key condition — products in Electronics with id starting with "laptop"
public List<Product> findLaptops(String productId) {
QueryConditional condition = QueryConditional
.sortBeginsWith(k -> k.partitionValue(productId).sortValue("laptop"));
return table.query(condition).items().stream().collect(Collectors.toList());
}
// Query GSI — all ACTIVE products, newest first
public List<Product> findActiveProducts() {
DynamoDbIndex<Product> statusIndex = table.index("status-createdAt-index");
QueryConditional condition = QueryConditional
.keyEqualTo(k -> k.partitionValue("ACTIVE"));
return statusIndex.query(r -> r
.queryConditional(condition)
.scanIndexForward(false) // descending sort — newest first
.limit(50)
).stream()
.flatMap(page -> page.items().stream())
.collect(Collectors.toList());
}
Avoid Scan in production. A Scan reads every item in the table (or index), consuming read capacity proportional to the total table size — not the result set size. Use Query with appropriate indexes instead.
When a field is annotated with @DynamoDbVersionAttribute, the Enhanced Client automatically increments it on every putItem / updateItem and adds a condition that the stored version must match the client version. A concurrent update from another process will cause a ConditionalCheckFailedException, which you handle by retrying with a fresh read.
@Service
public class ProductService {
public Product applyDiscount(String productId, String category, BigDecimal discount) {
int maxRetries = 3;
for (int attempt = 0; attempt < maxRetries; attempt++) {
try {
Product product = repository.findById(productId, category)
.orElseThrow(() -> new ProductNotFoundException(productId));
product.setPrice(product.getPrice().subtract(discount));
return repository.update(product); // version check happens here
} catch (ConditionalCheckFailedException e) {
if (attempt == maxRetries - 1) throw new OptimisticLockException(
"Failed to update product after " + maxRetries + " attempts", e);
log.warn("Version conflict on attempt {}, retrying...", attempt + 1);
}
}
throw new IllegalStateException("unreachable");
}
}
DynamoDB supports ACID transactions across up to 100 items in a single request, spanning multiple tables. Transactions either fully commit or fully roll back. Use them for operations that must be atomic — e.g., creating an order and decrementing inventory in a single logical action.
@Service
public class OrderService {
private final DynamoDbEnhancedClient enhancedClient;
private final DynamoDbTable<Order> orderTable;
private final DynamoDbTable<Inventory> inventoryTable;
public void placeOrder(Order order, String productId, int quantity) {
// TransactWriteItems — atomic across both tables
enhancedClient.transactWriteItems(tx -> tx
// Write the new order
.addPutItem(orderTable, TransactPutItemEnhancedRequest.builder(Order.class)
.item(order)
.conditionExpression(Expression.builder()
.expression("attribute_not_exists(orderId)") // prevent duplicate
.build())
.build())
// Decrement inventory — fail if stock insufficient
.addUpdateItem(inventoryTable, TransactUpdateItemEnhancedRequest.builder(Inventory.class)
.item(buildInventoryUpdate(productId, -quantity))
.conditionExpression(Expression.builder()
.expression("quantity >= :needed")
.putExpressionValue(":needed", AttributeValues.numberValue(quantity))
.build())
.build())
);
}
}
LocalStack emulates DynamoDB locally. With Testcontainers you can spin it up in tests, create tables programmatically, and test your repository code against a real DynamoDB-compatible engine without an AWS account.
@SpringBootTest
@Testcontainers
class ProductRepositoryTest {
@Container
static LocalStackContainer localstack = new LocalStackContainer(
DockerImageName.parse("localstack/localstack:3.5"))
.withServices(LocalStackContainer.Service.DYNAMODB);
@DynamicPropertySource
static void properties(DynamicPropertyRegistry registry) {
registry.add("aws.endpoint-override",
() -> localstack.getEndpointOverride(LocalStackContainer.Service.DYNAMODB).toString());
registry.add("aws.region", () -> localstack.getRegion());
}
@Autowired
private DynamoDbEnhancedClient enhancedClient;
@Autowired
private ProductRepository repository;
@BeforeEach
void createTable() {
DynamoDbTable<Product> table = enhancedClient.table("Products",
TableSchema.fromBean(Product.class));
table.createTable(r -> r
.globalSecondaryIndices(
GlobalSecondaryIndex.builder()
.indexName("status-createdAt-index")
.projection(p -> p.projectionType(ProjectionType.ALL))
.provisionedThroughput(t -> t.readCapacityUnits(5L).writeCapacityUnits(5L))
.build())
.provisionedThroughput(t -> t.readCapacityUnits(5L).writeCapacityUnits(5L)));
}
@Test
void saveAndFindProduct() {
Product p = new Product();
p.setProductId(UUID.randomUUID().toString());
p.setCategory("electronics");
p.setName("Laptop Pro");
p.setPrice(new BigDecimal("1299.99"));
p.setStatus("ACTIVE");
repository.save(p);
Optional<Product> found = repository.findById(p.getProductId(), "electronics");
assertThat(found).isPresent()
.hasValueSatisfying(f -> assertThat(f.getName()).isEqualTo("Laptop Pro"));
}
}