Spring Boot + AWS DynamoDB: NoSQL Data Access with Enhanced Client

Amazon DynamoDB is a fully managed NoSQL database that delivers single-digit millisecond performance at any scale. The AWS SDK v2 Enhanced Client provides a type-safe, annotation-driven API for mapping Java objects to DynamoDB items: similar in spirit to JPA but built for DynamoDB's key-value and document model. This article covers entity mapping, CRUD operations, querying with Global Secondary Indexes, pagination, optimistic locking, transactions, and integration testing with LocalStack.

Share

Contents

Dependency & Client Setup

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.

XML
<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>
Java
@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();
    }
}

Entity Mapping with @DynamoDbBean

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.

Java
@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...
}
Java
// 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));
}

CRUD Operations

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.

Java
@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));
    }
}

Query & Scan with GSI

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.

Java
// 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.

Pagination

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.

Java
// 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);
}

Optimistic Locking with @DynamoDbVersionAttribute

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.

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

Transactions

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.

Java
@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())
        );
    }
}

Testing with LocalStack

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.

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