Using DynamoDB in a Micronaut Application

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

Authors: Sergio del Amo

Micronaut Version: 3.7.0

1. Getting Started

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

2. DynamoDB

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

3. 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.

4. Writing the Application

4.1. Create application

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

mn create-app example.micronaut.micronautguide \
              --features=dynamodb \
              --build=gradle \
              --lang=java \
              --jdk=11
If you don’t specify the --build argument, Gradle is used as the build tool.
If you don’t specify the --lang argument, Java is used as the language.

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

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.

4.2. Dynamo DB Dependencies

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

build.gradle
implementation("io.micronaut.aws:micronaut-aws-sdk-v2")
implementation("software.amazon.awssdk:dynamodb")

4.3. Id Generation

Create an interface to encapsulate id generation.

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

import io.micronaut.core.annotation.NonNull;

@FunctionalInterface
public interface IdGenerator {

    @NonNull
    String generate();
}
1 An interface with one abstract method declaration is known as a functional interface. The compiler verifies that all interfaces annotated with @FunctionInterface really contain one and only one abstract method.

4.4. 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.

build.gradle
implementation("com.github.ksuid:ksuid")

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

Create a singleton implementation of IdGenerator.

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

import com.github.ksuid.Ksuid;
import io.micronaut.context.annotation.Requires;
import io.micronaut.core.annotation.NonNull;
import jakarta.inject.Singleton;

@Requires(classes = Ksuid.class) (1)
@Singleton (2)
public class KsuidGenerator implements IdGenerator {

    @Override
    @NonNull
    public String generate() {
        return Ksuid.newKsuid().toString();
    }
}
1 This bean loads only if the specified classes are available. @Requires(classes allows you to provide libraries with
2 Use jakarta.inject.Singleton to designate a class as a singleton.

4.5. POJOs

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

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

import io.micronaut.core.annotation.NonNull;

public interface Identified {

    @NonNull
    String getId();
}

Create a POJO to save books to DynamoDB.

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

import io.micronaut.core.annotation.Introspected;
import io.micronaut.core.annotation.NonNull;

import javax.validation.constraints.NotBlank;

@Introspected (1)
public class Book implements Identified {

    @NonNull
    @NotBlank (2)
    private final String id;

    @NonNull
    @NotBlank (2)
    private final String isbn;

    @NonNull
    @NotBlank (2)
    private final String name;

    public Book(@NonNull String id,
                @NonNull String isbn,
                @NonNull String name) {
        this.id = id;
        this.isbn = isbn;
        this.name = name;
    }

    @Override
    @NonNull
    public String getId() {
        return id;
    }

    @NonNull
    public String getIsbn() {
        return isbn;
    }

    @NonNull
    public String getName() {
        return name;
    }
}
1 Annotate the class with @Introspected to generate BeanIntrospection metadata at compilation time. This information can be used, for example, to render the POJO as JSON using Jackson without using reflection.
2 Use javax.validation.constraints Constraints to ensure the data matches your expectations.

4.6. Configuration

Define the name of the DynamoDB table in configuration:

src/main/resources/application.yml
dynamodb:
  table-name: 'bookcatalogue'

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

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

import io.micronaut.context.annotation.Requires;
import io.micronaut.context.annotation.ConfigurationProperties;
import javax.validation.constraints.NotBlank;

@Requires(property = "dynamodb.table-name") (1)
@ConfigurationProperties("dynamodb") (2)
public interface DynamoConfiguration {
    @NotBlank
    String getTableName();
}
1 This bean loads only if the specified property is defined.
2 You can annotate an interface with @ConfigurationProperties to create an immutable configuration.

4.7. Repository

Create an interface to encapsulate Book persistence.

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

import io.micronaut.core.annotation.NonNull;

import javax.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.

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 javax.validation.constraints.NotBlank;
import javax.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.

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

import io.micronaut.core.annotation.NonNull;
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.AttributeValue;
import software.amazon.awssdk.services.dynamodb.model.PutItemRequest;
import software.amazon.awssdk.services.dynamodb.model.PutItemResponse;
import software.amazon.awssdk.services.dynamodb.model.QueryRequest;
import software.amazon.awssdk.services.dynamodb.model.QueryResponse;

import javax.validation.Valid;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;

@Singleton (1)
public class DefaultBookRepository extends DynamoRepository<Book> implements BookRepository {
    private static final Logger LOG = LoggerFactory.getLogger(DefaultBookRepository.class);
    private static final String ATTRIBUTE_ID = "id";
    private static final String ATTRIBUTE_ISBN = "isbn";
    private static final String ATTRIBUTE_NAME = "name";

    private final IdGenerator idGenerator;
    public DefaultBookRepository(DynamoDbClient dynamoDbClient,
                                 DynamoConfiguration dynamoConfiguration,
                                 IdGenerator idGenerator) {
        super(dynamoDbClient, dynamoConfiguration);
        this.idGenerator = idGenerator;
    }

    @Override
    @NonNull
    public String save(@NonNull @NotBlank String isbn,
                @NonNull @NotBlank String name) {
        String id = idGenerator.generate();
        save(new Book(id, isbn, name));
        return id;
    }

    protected void save(@NonNull @NotNull @Valid Book book) {
        PutItemResponse itemResponse = dynamoDbClient.putItem(PutItemRequest.builder()
                .tableName(dynamoConfiguration.getTableName())
                .item(item(book))
                .build());
        if (LOG.isDebugEnabled()) {
            LOG.debug(itemResponse.toString());
        }
    }

    @Override
    @NonNull
    public Optional<Book> findById(@NonNull @NotBlank String id) {
        return findById(Book.class, id)
                .map(this::bookOf);
    }

    @Override
    public void delete(@NonNull @NotBlank String id) {
        delete(Book.class, id);
    }

    @Override
    @NonNull
    public List<Book> findAll() {
        List<Book> result = new ArrayList<>();
        String beforeId = null;
        do {
            QueryRequest request = findAllQueryRequest(Book.class, beforeId, null);
            QueryResponse response = dynamoDbClient.query(request);
            if (LOG.isTraceEnabled()) {
                LOG.trace(response.toString());
            }
            result.addAll(parseInResponse(response));
            beforeId = lastEvaluatedId(response, Book.class).orElse(null);
        } while(beforeId != null); (2)
        return result;
    }

    private List<Book> parseInResponse(QueryResponse response) {
        List<Map<String, AttributeValue>> items = response.items();
        List<Book> result = new ArrayList<>();
        if (CollectionUtils.isNotEmpty(items)) {
            for (Map<String, AttributeValue> item : items) {
                result.add(bookOf(item));
            }
        }
        return result;
    }

    @NonNull
    private Book bookOf(@NonNull Map<String, AttributeValue> item) {
        return new Book(item.get(ATTRIBUTE_ID).s(),
                item.get(ATTRIBUTE_ISBN).s(),
                item.get(ATTRIBUTE_NAME).s());
    }

    @Override
    @NonNull
    protected Map<String, AttributeValue> item(@NonNull Book book) {
        Map<String, AttributeValue> result = super.item(book);
        result.put(ATTRIBUTE_ID, AttributeValue.builder().s(book.getId()).build());
        result.put(ATTRIBUTE_ISBN, AttributeValue.builder().s(book.getIsbn()).build());
        result.put(ATTRIBUTE_NAME, AttributeValue.builder().s(book.getName()).build());
        return result;
    }
}
1 Use jakarta.inject.Singleton to designate a class as a singleton.
2 Paginate instead of using a scan operation.

5. Controllers

Create a CRUD controller for Book.

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

import io.micronaut.core.annotation.NonNull;
import io.micronaut.http.HttpResponse;
import io.micronaut.http.HttpStatus;
import io.micronaut.http.annotation.*;
import io.micronaut.http.uri.UriBuilder;
import io.micronaut.scheduling.TaskExecutors;
import io.micronaut.scheduling.annotation.ExecuteOn;

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

@ExecuteOn(TaskExecutors.IO) (1)
@Controller("/books") (2)
public class BooksController {

    private final BookRepository bookRepository;

    public BooksController(BookRepository bookRepository) { (3)
        this.bookRepository = bookRepository;
    }

    @Get (4)
    public List<Book> index() {
        return bookRepository.findAll();
    }

    @Post (5)
    public HttpResponse<?> save(@Body("isbn") @NonNull @NotBlank String isbn, (6)
                             @Body("name") @NonNull @NotBlank String name) {
        String id = bookRepository.save(isbn, name);
        return HttpResponse.created(UriBuilder.of("/books").path(id).build());
    }

    @Get("/{id}") (7)
    public Optional<Book> show(@PathVariable @NonNull @NotBlank String id) { (8)
        return bookRepository.findById(id);
    }

    @Delete("/{id}") (9)
    @Status(HttpStatus.NO_CONTENT) (10)
    public void delete(@PathVariable @NonNull @NotBlank String id) {
        bookRepository.delete(id);
    }
}
1 It is critical that any blocking I/O operations (such as fetching the data from the database) are offloaded to a separate thread pool that does not block the Event loop.
2 The class is defined as a controller with the @Controller annotation mapped to the path /books.
3 Use constructor injection to inject a bean of type BookRepository.
4 The @Get annotation maps the index method to an HTTP GET request on /.
5 The @Post annotation maps the save method to an HTTP POST request on /.
6 You can use a qualifier within the HTTP request body. For example, you can use a reference to a nested JSON attribute.
7 The @Get annotation maps the show method to an HTTP GET request on /{id}.
8 You can define path variables with a RFC-6570 URI template in the HTTP Method annotation value. The method argument can optionally be annotated with @PathVariable.
9 The @Post annotation maps the delete method to an HTTP POST request on /.
10 You can return void in your controller’s method and specify the HTTP status code via the @Status annotation.

5.1. Running the application

For local development, we can set up DynamoDB locally.

An easy option is to run it via Docker. After installing Docker, execute the following command to run a DynamoDB local container:

docker run -it --rm \
     -p 8000:8000 \
     amazon/dynamodb-local

5.1.1. Dev default environment

Modify Application to use dev as a default environment.

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

5.1.2. Dev specific configuration

Create an environment-specific configuration class for the dev environment that defines the host and port of the DynamoDB local instance.

src/main/resources/application-dev.yml
dynamodb-local:
  host: localhost
  port: 8000

5.1.3. Dev Bootstrap

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

src/main/java/example/micronaut/DevBootstrap.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") (1)
@Requires(property = "dynamodb-local.port") (1)
@Requires(env = Environment.DEVELOPMENT) (2)
@Singleton (3)
public class DevBootstrap implements ApplicationEventListener<StartupEvent> {

    private final DynamoRepository<? extends Identified> dynamoRepository;

    public DevBootstrap(DynamoRepository<? extends Identified> dynamoRepository) {
        this.dynamoRepository = dynamoRepository;
    }

    @Override
    public void onApplicationEvent(StartupEvent event) {
        if (!dynamoRepository.existsTable()) {
            dynamoRepository.createTable();
        }
    }
}
1 This bean loads only if the specified property is defined.
2 This bean loads only if the specified environment is detected.
3 Use jakarta.inject.Singleton to designate a class as a singleton.

5.1.4. Pointing to DynamoDB Local

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

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

import io.micronaut.context.annotation.Requires;
import io.micronaut.context.annotation.Value;
import io.micronaut.context.event.BeanCreatedEvent;
import io.micronaut.context.event.BeanCreatedEventListener;
import io.micronaut.context.exceptions.ConfigurationException;
import jakarta.inject.Singleton;
import software.amazon.awssdk.auth.credentials.AwsCredentials;
import software.amazon.awssdk.services.dynamodb.DynamoDbClientBuilder;

import java.net.URI;
import java.net.URISyntaxException;

@Requires(property = "dynamodb-local.host") (1)
@Requires(property = "dynamodb-local.port") (1)
@Singleton (2)
class DynamoDbClientBuilderListener
        implements BeanCreatedEventListener<DynamoDbClientBuilder> { (3)
    private final URI endpoint;
    private final String accessKeyId;
    private final String secretAccessKey;

    DynamoDbClientBuilderListener(@Value("${dynamodb-local.host}") String host, (4)
                                  @Value("${dynamodb-local.port}") String port) { (4)
        try {
            this.endpoint = new URI("http://" + host + ":" + port);
        } catch (URISyntaxException e) {
            throw new ConfigurationException("dynamodb.endpoint not a valid URI");
        }
        this.accessKeyId = "fakeMyKeyId";
        this.secretAccessKey = "fakeSecretAccessKey";
    }

    @Override
    public DynamoDbClientBuilder onCreated(BeanCreatedEvent<DynamoDbClientBuilder> event) {
        return event.getBean().endpointOverride(endpoint)
                .credentialsProvider(() -> new AwsCredentials() {
                    @Override
                    public String accessKeyId() {
                        return accessKeyId;
                    }

                    @Override
                    public String secretAccessKey() {
                        return secretAccessKey;
                    }
                });
    }
}
1 This bean loads only if the specified property is defined.
2 Use jakarta.inject.Singleton to designate a class as a singleton.
3 Creating a @Singleton that implements BeanCreatedEventListener allows you to provide extra configuration to
4 You can inject configuration values into beans using the @Value annotation. The @Value annotation accepts a string that can have embedded placeholder values (the default value can be provided by specifying a value after the colon : character).

6. Running the Application

To run the application, use the ./gradlew 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!"}]

6.1. Tests

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

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.

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

import io.micronaut.core.annotation.NonNull;
import io.micronaut.core.type.Argument;
import io.micronaut.core.util.CollectionUtils;
import io.micronaut.http.HttpHeaders;
import io.micronaut.http.HttpRequest;
import io.micronaut.http.HttpResponse;
import io.micronaut.http.HttpStatus;
import io.micronaut.http.client.BlockingHttpClient;
import io.micronaut.http.client.HttpClient;
import io.micronaut.http.client.annotation.Client;
import io.micronaut.http.uri.UriBuilder;
import io.micronaut.test.extensions.junit5.annotation.MicronautTest;
import io.micronaut.test.support.TestPropertyProvider;
import jakarta.inject.Inject;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

import java.util.List;
import java.util.Map;

import static org.junit.jupiter.api.Assertions.*;
import static org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS;

@MicronautTest (1)
@Testcontainers (2)
@TestInstance(PER_CLASS) (3)
class BooksControllerTest implements TestPropertyProvider { (3)

    @Inject
    @Client("/")
    HttpClient httpClient; (4)

    @Container (2)
    static GenericContainer dynamoDBLocal =
            new GenericContainer("amazon/dynamodb-local")
                    .withExposedPorts(8000);
    @NonNull
    @Override
    public Map<String, String> getProperties() { (3)
        if (!dynamoDBLocal.isRunning()) {
            dynamoDBLocal.start();
        }
        return CollectionUtils.mapOf(
                "dynamodb-local.host", "localhost",
                        "dynamodb-local.port", dynamoDBLocal.getFirstMappedPort());
    }

    private static HttpRequest<?> saveRequest(String isbn, String name) {
        return HttpRequest.POST("/books",
                CollectionUtils.mapOf("isbn", isbn, "name", name));
    }

    @Test
    void testRetrieveBooks() {
        BlockingHttpClient client = httpClient.toBlocking();

        String releaseItIsbn = "1680502395";
        String releaseItName = "Release It!";
        HttpResponse<?> saveResponse = client.exchange(
                saveRequest(releaseItIsbn, releaseItName));
        assertEquals(HttpStatus.CREATED, saveResponse.status());
        String location = saveResponse.getHeaders().get(HttpHeaders.LOCATION);
        assertNotNull(location);
        assertTrue(location.startsWith("/books/"));
        String releaseItId = location.substring("/books/".length());

        String continuousDeliveryIsbn = "0321601912";
        String continuousDeliveryName = "Continuous Delivery";
        saveResponse = client.exchange(
                saveRequest(continuousDeliveryIsbn, continuousDeliveryName));
        assertEquals(HttpStatus.CREATED, saveResponse.status());
        location = saveResponse.getHeaders().get(HttpHeaders.LOCATION);
        assertNotNull(location);
        assertTrue(location.startsWith("/books/"));
        String continuousDeliveryId = location.substring("/books/".length());

        String buildingMicroservicesIsbn = "1491950358";
        String buildingMicroservicesName = "Building Microservices";
        saveResponse = client.exchange(
                saveRequest(buildingMicroservicesIsbn, buildingMicroservicesName));
        assertEquals(HttpStatus.CREATED, saveResponse.status());
        location = saveResponse.getHeaders().get(HttpHeaders.LOCATION);
        assertNotNull(location);
        assertTrue(location.startsWith("/books/"));
        String buildingMicroservicesId = location.substring("/books/".length());

        Book result = client.retrieve(
                HttpRequest.GET(UriBuilder.of("/books")
                        .path(continuousDeliveryId)
                        .build()), Book.class);
        assertEquals(continuousDeliveryName, result.getName());

        List<Book> books = client.retrieve(HttpRequest.GET("/books"),
                Argument.listOf(Book.class));
        assertEquals(3, books.size());
        
        assertTrue(books.stream().anyMatch(it ->
                it.getIsbn().equals(continuousDeliveryIsbn) &&
                        it.getName().equals(continuousDeliveryName)));
        assertTrue(books.stream().anyMatch(it ->
                it.getIsbn().equals(releaseItIsbn) &&
                        it.getName().equals(releaseItName)));
        assertTrue(books.stream().anyMatch(it ->
                it.getIsbn().equals(buildingMicroservicesIsbn) &&
                        it.getName().equals(buildingMicroservicesName)));

        HttpResponse<?> deleteResponse = client.exchange(
                HttpRequest.DELETE(UriBuilder.of("/books")
                        .path(continuousDeliveryId)
                        .build().toString()));
        assertEquals(HttpStatus.NO_CONTENT, deleteResponse.getStatus());
        deleteResponse = client.exchange(HttpRequest.DELETE(UriBuilder.of("/books")
                .path(releaseItId)
                .build().toString()));
        assertEquals(HttpStatus.NO_CONTENT, deleteResponse.getStatus());
        deleteResponse = client.exchange(HttpRequest.DELETE(UriBuilder.of("/books")
                .path(buildingMicroservicesId)
                .build().toString()));
        assertEquals(HttpStatus.NO_CONTENT, deleteResponse.getStatus());
    }
}
1 Annotate the class with @MicronautTest so the Micronaut framework will initialize the application context and the embedded server. More info.
2 Container will be started before and stopped after each test method. Containers declared as static fields will be shared between test methods. They will be started only once before any test method is executed and stopped after the last test method has executed.
3 Classes that implement TestPropertyProvider must use this annotation to create a single class instance for all tests (not necessary in Spock tests).
4 Inject the HttpClient bean and point it to the embedded server.

7. Testing the Application

To run the tests:

./gradlew test

Then open build/reports/tests/test/index.html in a browser to see the results.

8. Next steps

Explore more features with Micronaut Guides.

Check Micronaut AWS integration.

9. Help with the Micronaut Framework

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