Contents

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.

DynamoDB returns at most 1 MB of data per request. The Enhanced Client wraps this in a PageIterable where each page corresponds to one DynamoDB request. For API-level pagination, use ExclusiveStartKey (the "cursor") from the last page.

// Cursor-based pagination — suitable for API endpoints public ProductPage findActiveProductsPage(String lastEvaluatedKey, int pageSize) { DynamoDbIndex<Product> index = table.index("status-createdAt-index"); QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() .queryConditional(QueryConditional.keyEqualTo(k -> k.partitionValue("ACTIVE"))) .limit(pageSize) .scanIndexForward(false); // Deserialise the cursor from the API caller and set as start key if (lastEvaluatedKey != null) { Map<String, AttributeValue> startKey = decodeCursor(lastEvaluatedKey); requestBuilder.exclusiveStartKey(startKey); } Page<Product> page = index.query(requestBuilder.build()).iterator().next(); String nextCursor = page.lastEvaluatedKey() != null ? encodeCursor(page.lastEvaluatedKey()) : null; // null means last page return new ProductPage(page.items(), nextCursor); }

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")); } }