Overview

The DynamoDB Enhanced Client API library allows us to integrate DynamoDB into Java and Kotlin application code. The client supports an annotation-driven programming model to map objects into DynamoDB tables. In this article, we want to explore how to use the client to map JavaBeans, Lombok Beans, and Kotlin data classes. We will also use property-based testing (using Kotest) to thoroughly test our mapping with generated test inputs to ensure we get all edge cases in the mapping. Let’s start with a quick overview of the DynamoDB concepts that are relevant for this article.

What is DynamoDB?

Amazon DynamoDB is a key-value NoSQL database. Keys are of type String, and values can be simple types such as String, Number, Boolean or complex types such as List, Map, or Set.1

A table is the fundamental concept in DynamoDB. In contrast to relational databases, DynamoDB tables are schemaless. A DynamoDB table only needs its primary key to be defined when created. The primary key can be simple (partition key of type String, Number, or Binary) or compound (partition key and sort key). All items inserted into the table need to define that primary key. Besides that, every item in the table can have a different set of attribute keys and value types:

partitionKey attribute1 attribute2 attribute3
key-1 string value number value string value
key-2 string value null list value
key-3 string value not set set value

Items in the database are referenced by their primary key to be read, updated, or deleted. DynamoDB also allows basic query support. With a compound key, you can query with the (partial) partition part of the key and traverse all items in the order defined by the sort key.

This is, of course, only a brief overview of what DynamoDB is. Visit the AWS documentation for more details about DynamoDB.

Using the low-level API

Our first option to for accessing DynamoDB is to use the low-level client API. With the low-level client, an item in a DynamoDB table is represented as a map of type Map<String, AttributeValue>.

// use the low-level API to put an item into the table
val item =
    mapOf(
        "partitionKey" to AttributeValue.builder().s("my partition key").build(),
        "sortKey" to AttributeValue.builder().n("12345").build(),
        "stringAttribute" to AttributeValue.builder().s("my string value").build(),
    )

dynamoClient.putItem(
    PutItemRequest.builder()
        .tableName("sample-table")
        .item(item).build(),
)

// use the low-level API to fetch the item from the table
val key =
    mapOf(
        "partitionKey" to AttributeValue.builder().s("my partition key").build(),
        "sortKey" to AttributeValue.builder().n("12345").build(),
    )

val request =
    GetItemRequest.builder()
        .key(key)
        .tableName("sample-table")
        .build()

val getResponse = dynamoClient.getItem(request)
getResponse.hasItem().shouldBeTrue()

// this result contains the data from the table
val result: Map<String, AttributeValue> = getResponse.item()
result.shouldContain("stringAttribute", AttributeValue.builder().s("my string value").build())
println(result)

// alternatively we can do a query
val queryResponse =
    dynamoClient.query(
        QueryRequest.builder()
            .tableName("sample-table")
            .keyConditionExpression("partitionKey = :pk")
            .expressionAttributeValues(mapOf(":pk" to AttributeValue.builder().s("my partition key").build())).build(),
    )
queryResponse.count() shouldBe 1
println(queryResponse.items())

// finally we can convert it to JSON or handle it as we need
val json = EnhancedDocument.fromAttributeValueMap(result).toJson()
json.shouldBeValidJson()
    .shouldContainJsonKeyValue("$.stringAttribute", "my string value")
println(json)
src/test/kotlin/basic/SimpleMappingSpec.kt view raw

AttributeValue is similar to a union type; this means it contains a value that can be of various types. Depending on the actual type of the value, we’ll need to use the correct accessor method (e.g., s() for String) to access the contained values.

In most applications, we don’t want to use this low-level representation of the database contents throughout the application as it is unwieldy and error-prone to use. Instead, we typically create model classes to represent database items. We now will look at the dynamodb-enhanced client that offers a straightforward way to achieve that.

Using DynamodDB-enhanced

JavaBean

We’ll first look at a basic JavaBean. A JavaBean is a class that needs to follow these conventions:

  • It needs to have a no-arg constructor,
  • it needs to have a getter and setter method for every attribute,
  • the class needs to be annotated with @DynamoDbBean,
  • the getter methods for the partition and sort key need to be annotated with (@DynamoDbPartitionKey and @DynamoDbSortKey),
  • the fields of the class can use types like String, List, or Map suitable to the column type in the table.
@DynamoDbBean
public class JavaItem {

    private String partitionKey;
    private int sortKey;

    private String stringAttribute;

    @DynamoDbPartitionKey
    public String getPartitionKey() {
        return partitionKey;
    }

    public void setPartitionKey(String partitionKey) {
        this.partitionKey = partitionKey;
    }

    @DynamoDbSortKey
    public int getSortKey() {
        return sortKey;
    }


    // More getters and setters omitted for brevity
}
src/main/java/basic/JavaItem.java view raw

Java records

Unfortunately, Java records are not supported by DynamoDB enhanced yet (see Github issue).

Lombok @Data

As Java records are not supported, we can also use Lombok to avoid some of that boilerplate code needed to set up a JavaBean. Using the @Data annotation, we can achieve the same result as in the JavaBean example. The onMethod parameter in Lombok’s @Getter annotation is used to put the DynamoDB annotations on the generated getter code (see documentation).

@DynamoDbBean
@Data
public class LombokMutableItem {
    @Getter(onMethod_ = {@DynamoDbPartitionKey})
    String partitionKey;

    @Getter(onMethod_ = {@DynamoDbSortKey})
    int sortKey;

    String stringAttribute;
}
src/main/java/basic/LombokMutableItem.java view raw

Lombok @Value

If we want to use an immutable Lombok value, we need to use a slightly different configuration to allow DynamodDB to use the generated Lombok builder so it knows how to instantiate our model class.

@Value
@Builder
@DynamoDbImmutable(builder = LombokImmutableItem.LombokImmutableItemBuilder.class)
public class LombokImmutableItem {
    @Getter(onMethod_ = {@DynamoDbPartitionKey})
    String partitionKey;

    @Getter(onMethod_ = {@DynamoDbSortKey})
    int sortKey;

    String stringAttribute;
}
src/main/java/basic/LombokImmutableItem.java view raw

Kotlin data classes

Kotlin data classes play poorly together with DynamoDB. We either need to give up some of Kotlin’s features we love, like having immutable data classes, or we need to manually write builder classes for our data classes, which is cumbersome.

Fortunately, there is another option. We can use the third-party library dynamodb-kotlin-module to easily use data classes with DynamoDB. It offers its own set of annotations for annotating the model class and provides an alternative schema implementation compatible with Kotlin’s data classes.

data class KotlinItem(
    @DynamoKtPartitionKey val partitionKey: String,
    @DynamoKtSortKey val sortKey: Int,
    val stringAttribute: String?,
)
src/main/kotlin/sample/KotlinItem.kt view raw

Testing our mapping

We are using property-based testing2 to test our mapping comprehensively. The benefit of using a property-based testing approach is that we will exercise our mapping configuration with a bigger range of generated inputs than if we write single example-based tests. We can cover more edge cases and get good feedback if our model can be reliably mapped to the database and back.

In our tests, we will map a generated value to the database. Then, we will read that item from the database again and finally compare if the item from the database equals our initial value.3

Our tests are built using the property-based testing support of Kotest. Each such test uses a test data generator. It allows us to create and populate our model classes with random values. The data generator is implemented using some Kotest generators:

private val aShortString = Arb.string(1..10)

// partition key may not be empty
private val aPartitionKey = Arb.uuid().map { it.toString() }

private val aSortKey = Arb.int()

val aJavaItem =
    arbitrary {
        // this block is evaluated to produce a new testdata value
        JavaItem().apply {
            // we use the existing generators to populate our item with random values
            partitionKey = aPartitionKey.bind()
            sortKey = aSortKey.bind()

            // attributes may be null
            stringAttribute = aShortString.orNull().bind()
        }
    }
src/test/kotlin/basic/Testdata.kt view raw

Next is the test itself. It consists of the infrastructure needed to spin up a Localstack Docker container.

val localstack =
    install(ContainerExtension(LocalStackContainer(DockerImageName.parse("localstack/localstack")))) {
    }

val dynamoClient =
    DynamoDbClient.builder()
        .endpointOverride(localstack.endpoint)
        .credentialsProvider(
            StaticCredentialsProvider.create(
                AwsBasicCredentials.create(
                    localstack.accessKey,
                    localstack.secretKey,
                ),
            ),
        ).region(Region.of(localstack.region)).build()

val enhancedClient = DynamoDbEnhancedClient.builder().dynamoDbClient(dynamoClient).build()
src/test/kotlin/basic/SimpleMappingSpec.kt view raw

Using the Kotest testcontainers integration, we ensure that the test runner starts a Localstack container before executing our test code. We then configure a DynamoDB client to connect to DynamoDB inside the Docker container.

With this infrastructure in place, we can finally define the test.

"can map JavaBean" {
    val table = enhancedClient.table("java-record-table", TableSchema.fromClass(JavaItem::class.java))
    table.createTable()

    checkAll(50, aJavaItem) { givenItem ->
        // this block will be evaluated 50 times, each time with a new generated testdata value
        val key = Key.builder().partitionValue(givenItem.partitionKey).sortValue(givenItem.sortKey).build()

        table.putItem(givenItem)

        val actualItem =
            table.getItem(key)

        actualItem shouldBe givenItem

        table.deleteItem(key)
    }
}
src/test/kotlin/basic/SimpleMappingSpec.kt view raw

The tests all passed, meaning we don’t have any issues with our mapping. Part 2 of this article will look at a more complex mapping. Our test setup will be helpful when testing the more complex mapping.

We have added equivalent tests for the Lombok data bean, the Lombok value bean, and the Kotlin data class. They mainly differ in the generators they use to create the test data. Also, the Kotlin data class tests use the alternative schema implementation provided by the dynamodb-kotlin-module. See the corresponding GitHub example.

Conclusion

In this example, we showed how to use the DynamoDB-enhanced client to map seamlessly between the Database items and our model classes. We examined different options for how to set up the model classes with plain Java, Lombok, and Kotlin data classes.

Furthermore, we used property-based testing for comprehensive coverage of our mapping configuration. For completeness we have also added a JUnit-based test.

The DynamoDB Enhanced Client API is way more flexible than we showed today. Head over to its documentation for more details on how to use it further.

Find the source code of our examples on GitHub.

Notes

  1. See Supported data types for the list of supported data types. 

  2. Read The “Property Based Testing” series to get an introduction to the fundamentals of property-based testing. 

  3. This corresponds to the There and back again approach as described in the The “Property Based Testing” series