Java / Maven

Using DynamoDB in a Micronaut application

Learn how to use DynamoDB as your persistence solution in a Micronaut application.

Sergio del Amo
On this guide
In this section

Getting Started

In this guide, we will create a Micronaut application written in Java.

DynamoDB

DynamoDB is a fast, flexible NoSQL database service for single-digit millisecond performance at any scale

Solution

We recommend that you follow the instructions in the next sections and create the application step by step. However, you can go right to the completed example.

Writing the Application

Create application

Create an application using the Micronaut Command Line Interface or with Micronaut Launch.

mn create-app example.micronaut.micronautguide \
              --features=dynamodb \
              --build=maven \
              --lang=java
Note
If you don’t specify the --build argument, Gradle with the Kotlin DSL is used as the build tool.
If you don’t specify the --lang argument, Java is used as the language.
If you don’t specify the --test argument, JUnit is used for Java and Kotlin, and Spock is used for Groovy.

The previous command creates a Micronaut application with the default package example.micronaut in a directory named micronautguide.

Note
If you have an existing Micronaut application and want to add the functionality described here, you can view the dependency and configuration changes from the specified features, and apply those changes to your application.

Dynamo DB Dependencies

To use DynamoDB with the Micronaut framework, your application should have the following dependencies:

pom.xml
<dependency>
    <groupId>io.micronaut.aws</groupId>
    <artifactId>micronaut-aws-sdk-v2</artifactId>
    <scope>compile</scope>
</dependency>
<dependency>
    <groupId>software.amazon.awssdk</groupId>
    <artifactId>dynamodb</artifactId>
    <scope>compile</scope>
</dependency>

Id Generation

Create an interface to encapsulate id generation.

java/src/main/java/example/micronaut/IdGenerator.java

Ksuid

Add the following dependency to generate KSUIDs (K-Sortable Globally Unique IDs).

KSUID is for K-Sortable Unique IDentifier. It’s a way to generate globally unique IDs similar to RFC 4122 UUIDs, but contain a time component so they can be "roughly" sorted by time of creation. The remainder of the KSUID is randomly generated bytes.

pom.xml
<dependency>
    <groupId>com.github.ksuid</groupId>
    <artifactId>ksuid</artifactId>
    <scope>compile</scope>
</dependency>

An identifier with a time component is useful when you work with a NoSQL solution such as DynamoDB.

Create a singleton implementation of IdGenerator.

java/src/main/java/example/micronaut/KsuidGenerator.java

optional functionality compiled with compile-only dependencies.

1 Use jakarta.inject.Singleton to designate a class as a singleton.

Domain Model

The application contains an interface to mark classes with a unique identifier.

java/src/main/java/example/micronaut/Identified.java
package example.micronaut;

import io.micronaut.core.annotation.NonNull;

public interface Identified {

    @NonNull
    String getId();
}

Create a class to save books to DynamoDB.

java/src/main/java/example/micronaut/Book.java

Configuration

Define the name of the DynamoDB table in configuration:

java/src/main/resources/application.properties
dynamodb.table-name=bookcatalogue

Inject the configuration into the application via a @ConfigurationProperties bean.

java/src/main/java/example/micronaut/DynamoConfiguration.java

Repository

Create an interface to encapsulate Book persistence.

java/src/main/java/example/micronaut/BookRepository.java
package example.micronaut;

import io.micronaut.core.annotation.NonNull;

import jakarta.validation.constraints.NotBlank;
import java.util.List;
import java.util.Optional;

public interface BookRepository {
    @NonNull
    List<Book> findAll();

    @NonNull
    Optional<Book> findById(@NonNull @NotBlank String id);

    void delete(@NonNull @NotBlank String id);

    @NonNull
    String save(@NonNull @NotBlank String isbn,
                @NonNull @NotBlank String name);
}

Create a singleton class to handle common operations with DynamoDB.

java/src/main/java/example/micronaut/DynamoRepository.java
package example.micronaut;

import io.micronaut.context.annotation.Requires;
import io.micronaut.context.annotation.Primary;
import io.micronaut.core.annotation.NonNull;
import io.micronaut.core.annotation.Nullable;
import io.micronaut.core.util.CollectionUtils;
import jakarta.inject.Singleton;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
import software.amazon.awssdk.services.dynamodb.model.AttributeDefinition;
import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
import software.amazon.awssdk.services.dynamodb.model.BillingMode;
import software.amazon.awssdk.services.dynamodb.model.CreateTableRequest;
import software.amazon.awssdk.services.dynamodb.model.DeleteItemRequest;
import software.amazon.awssdk.services.dynamodb.model.DeleteItemResponse;
import software.amazon.awssdk.services.dynamodb.model.DescribeTableRequest;
import software.amazon.awssdk.services.dynamodb.model.GetItemRequest;
import software.amazon.awssdk.services.dynamodb.model.GetItemResponse;
import software.amazon.awssdk.services.dynamodb.model.GlobalSecondaryIndex;
import software.amazon.awssdk.services.dynamodb.model.KeySchemaElement;
import software.amazon.awssdk.services.dynamodb.model.KeyType;
import software.amazon.awssdk.services.dynamodb.model.Projection;
import software.amazon.awssdk.services.dynamodb.model.ProjectionType;
import software.amazon.awssdk.services.dynamodb.model.QueryRequest;
import software.amazon.awssdk.services.dynamodb.model.QueryResponse;
import software.amazon.awssdk.services.dynamodb.model.ResourceNotFoundException;
import software.amazon.awssdk.services.dynamodb.model.ScalarAttributeType;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;

@Requires(condition = CIAwsRegionProviderChainCondition.class)
@Requires(condition = CIAwsCredentialsProviderChainCondition.class)
@Requires(beans = { DynamoConfiguration.class, DynamoDbClient.class })
@Singleton
@Primary
public class DynamoRepository<T extends Identified> {
    private static final Logger LOG = LoggerFactory.getLogger(DynamoRepository.class);
    protected static final String HASH = "#";
    protected static final String ATTRIBUTE_PK = "pk";
    protected static final String ATTRIBUTE_SK = "sk";
    protected static final String ATTRIBUTE_GSI_1_PK = "GSI1PK";
    protected static final String ATTRIBUTE_GSI_1_SK = "GSI1SK";
    protected static final String INDEX_GSI_1 = "GSI1";

    protected final DynamoDbClient dynamoDbClient;
    protected final DynamoConfiguration dynamoConfiguration;

    public DynamoRepository(DynamoDbClient dynamoDbClient,
                            DynamoConfiguration dynamoConfiguration) {
        this.dynamoDbClient = dynamoDbClient;
        this.dynamoConfiguration = dynamoConfiguration;
    }

    public boolean existsTable() {
        try {
            dynamoDbClient.describeTable(DescribeTableRequest.builder()
                    .tableName(dynamoConfiguration.getTableName())
                    .build());
            return true;
        } catch (ResourceNotFoundException e) {
            return false;
        }
    }

    public void createTable() {
        dynamoDbClient.createTable(CreateTableRequest.builder()
                        .attributeDefinitions(AttributeDefinition.builder()
                                .attributeName(ATTRIBUTE_PK)
                                .attributeType(ScalarAttributeType.S)
                                .build(),
                                AttributeDefinition.builder()
                                        .attributeName(ATTRIBUTE_SK)
                                        .attributeType(ScalarAttributeType.S)
                                        .build(),
                                AttributeDefinition.builder()
                                        .attributeName(ATTRIBUTE_GSI_1_PK)
                                        .attributeType(ScalarAttributeType.S)
                                        .build(),
                                AttributeDefinition.builder()
                                        .attributeName(ATTRIBUTE_GSI_1_SK)
                                        .attributeType(ScalarAttributeType.S)
                                        .build())
                        .keySchema(Arrays.asList(KeySchemaElement.builder()
                                .attributeName(ATTRIBUTE_PK)
                                .keyType(KeyType.HASH)
                                .build(),
                                KeySchemaElement.builder()
                                        .attributeName(ATTRIBUTE_SK)
                                        .keyType(KeyType.RANGE)
                                        .build()))
                        .billingMode(BillingMode.PAY_PER_REQUEST)
                        .tableName(dynamoConfiguration.getTableName())
                        .globalSecondaryIndexes(gsi1())
                .build());
    }

    @NonNull
    public QueryRequest findAllQueryRequest(@NonNull Class<?> cls,
                                            @Nullable String beforeId,
                                            @Nullable Integer limit) {
        QueryRequest.Builder builder = QueryRequest.builder()
                .tableName(dynamoConfiguration.getTableName())
                .indexName(INDEX_GSI_1)
                .scanIndexForward(false);
        if (limit != null) {
            builder.limit(limit);
        }
        if (beforeId == null) {
            return  builder.keyConditionExpression("#pk = :pk")
                    .expressionAttributeNames(Collections.singletonMap("#pk", ATTRIBUTE_GSI_1_PK))
                    .expressionAttributeValues(Collections.singletonMap(":pk",
                            classAttributeValue(cls)))
                    .build();
        } else {
            return builder.keyConditionExpression("#pk = :pk and #sk < :sk")
                    .expressionAttributeNames(CollectionUtils.mapOf("#pk", ATTRIBUTE_GSI_1_PK, "#sk", ATTRIBUTE_GSI_1_SK))
                    .expressionAttributeValues(CollectionUtils.mapOf(":pk",
                            classAttributeValue(cls),
                            ":sk",
                            id(cls, beforeId)
                    ))
                    .build();
        }
    }

    protected void delete(@NonNull @NotNull Class<?> cls, @NonNull @NotBlank String id) {
        AttributeValue pk = id(cls, id);
        DeleteItemResponse deleteItemResponse = dynamoDbClient.deleteItem(DeleteItemRequest.builder()
                .tableName(dynamoConfiguration.getTableName())
                .key(CollectionUtils.mapOf(ATTRIBUTE_PK, pk, ATTRIBUTE_SK, pk))
                .build());
        if (LOG.isDebugEnabled()) {
            LOG.debug(deleteItemResponse.toString());
        }
    }

    protected Optional<Map<String, AttributeValue>> findById(@NonNull @NotNull Class<?> cls, @NonNull @NotBlank String id) {
        AttributeValue pk = id(cls, id);
        GetItemResponse getItemResponse = dynamoDbClient.getItem(GetItemRequest.builder()
                .tableName(dynamoConfiguration.getTableName())
                .key(CollectionUtils.mapOf(ATTRIBUTE_PK, pk, ATTRIBUTE_SK, pk))
                .build());
        return !getItemResponse.hasItem() ? Optional.empty() : Optional.of(getItemResponse.item());
    }

    @NonNull
    public static Optional<String> lastEvaluatedId(@NonNull QueryResponse response,
                                          @NonNull Class<?> cls) {
        if (response.hasLastEvaluatedKey()) {
            Map<String, AttributeValue> item = response.lastEvaluatedKey();
            if (item != null && item.containsKey(ATTRIBUTE_PK)) {
                return id(cls, item.get(ATTRIBUTE_PK));
            }
        }
        return Optional.empty();
    }

    private static GlobalSecondaryIndex gsi1() {
        return GlobalSecondaryIndex.builder()
                .indexName(INDEX_GSI_1)
                .keySchema(KeySchemaElement.builder()
                        .attributeName(ATTRIBUTE_GSI_1_PK)
                        .keyType(KeyType.HASH)
                        .build(), KeySchemaElement.builder()
                        .attributeName(ATTRIBUTE_GSI_1_SK)
                        .keyType(KeyType.RANGE)
                        .build())
                .projection(Projection.builder()
                        .projectionType(ProjectionType.ALL)
                        .build())
                .build();
    }

    @NonNull
    protected Map<String, AttributeValue> item(@NonNull T entity) {
        Map<String, AttributeValue> item = new HashMap<>();
        AttributeValue pk = id(entity.getClass(), entity.getId());
        item.put(ATTRIBUTE_PK, pk);
        item.put(ATTRIBUTE_SK, pk);
        item.put(ATTRIBUTE_GSI_1_PK, classAttributeValue(entity.getClass()));
        item.put(ATTRIBUTE_GSI_1_SK, pk);
        return item;
    }

    @NonNull
    protected static AttributeValue classAttributeValue(@NonNull Class<?> cls) {
        return AttributeValue.builder()
                .s(cls.getSimpleName())
                .build();
    }

    @NonNull
    protected static AttributeValue id(@NonNull Class<?> cls,
                                     @NonNull String id) {
        return AttributeValue.builder()
                .s(String.join(HASH, cls.getSimpleName().toUpperCase(), id))
                .build();
    }

    @NonNull
    protected static Optional<String> id(@NonNull Class<?> cls,
                                       @NonNull AttributeValue attributeValue) {
        String str = attributeValue.s();
        String substring = cls.getSimpleName().toUpperCase() + HASH;
        return str.startsWith(substring) ? Optional.of(str.substring(substring.length())) : Optional.empty();
    }
}

And an implementation of BookRepository.

java/src/main/java/example/micronaut/DefaultBookRepository.java

Controllers

Create a CRUD controller for Book.

java/src/main/java/example/micronaut/BooksController.java

Running the application

Under development and testing, we have configured Test Resources to supply the properties dynamodb-local.host and dynamodb-port.

java/src/main/resources/application.properties

This will start DynamoDB in a container via TestContainers, and inject the properties into your application.

Dev default environment

Modify Application to use dev as a default environment.

java/src/main/java/example/micronaut/Application.java
package example.micronaut;

import io.micronaut.context.ApplicationContextBuilder;
import io.micronaut.context.ApplicationContextConfigurer;
import io.micronaut.context.annotation.ContextConfigurer;
import io.micronaut.context.env.Environment;
import io.micronaut.core.annotation.NonNull;
import io.micronaut.runtime.Micronaut;

public class Application {
    @ContextConfigurer
    public static class DefaultEnvironmentConfigurer implements ApplicationContextConfigurer {
        @Override
        public void configure(@NonNull ApplicationContextBuilder builder) {
            builder.defaultEnvironments(Environment.DEVELOPMENT);
        }
    }

    public static void main(String[] args) {
        Micronaut.run(Application.class, args);
    }
}

Dev Bootstrap

Create a StartupEventListener that is loaded only for the dev environment that creates a dynamodb table if one does not already exist.

java/src/main/java/example/micronaut/DevBootstrap.java

Pointing to DynamoDB Local

Add a bean-created listener that points the DynamoDB client to the URL of the DynamoDB local instance.

java/src/main/java/example/micronaut/DynamoDbClientBuilderListener.java

Running the Application

To run the application, use the ./mvnw mn:run command, which starts the application on port 8080.

You should be able to execute the following curl requests.

curl http://localhost:8080/books
[]
curl -X POST -d '{"isbn":"1680502395","name":"Release It!"}' -H "Content-Type: application/json" http://localhost:8080/books
curl http://localhost:8080/books
[{"id":"2BLCWltdt3gGgSw1qsomXIfXBiX","isbn":"1680502395","name":"Release It!"}]

Tests

Create a StartupEventListener only loaded for the test environment which creates the DynamoDB table if it does not exist.

java/src/test/java/example/micronaut/TestBootstrap.java
package example.micronaut;

import io.micronaut.context.annotation.Requires;
import io.micronaut.context.env.Environment;
import io.micronaut.context.event.ApplicationEventListener;
import io.micronaut.context.event.StartupEvent;
import jakarta.inject.Singleton;

@Requires(property = "dynamodb-local.host")
@Requires(property = "dynamodb-local.port")
@Requires(env = Environment.TEST)
@Singleton
public class TestBootstrap implements ApplicationEventListener<StartupEvent> {

    private final DynamoRepository dynamoRepository;

    public TestBootstrap(DynamoRepository dynamoRepository) {
        this.dynamoRepository = dynamoRepository;
    }

    @Override
    public void onApplicationEvent(StartupEvent event) {
        if (!dynamoRepository.existsTable()) {
            dynamoRepository.createTable();
        }
    }
}

Create a test which verifies the CRUD functionality.

java/src/test/java/example/micronaut/BooksControllerTest.java

Testing the Application

To run the tests:

./mvnw test

Next Steps

Explore more features with Micronaut Guides.

Check Micronaut AWS integration.

Help with the Micronaut Framework

The Micronaut Foundation sponsored the creation of this Guide. A variety of consulting and support services are available.

License

Note
All guides are released with an Apache License 2.0 for the code and a Creative Commons Attribution 4.0 license for the writing and media (images).