Using MicroStream persistence with Micronaut

Learn how to use MicroStream as a high-performance persistence layer.

Authors: Tim Yates

Micronaut Version: 3.7.0

1. Getting Started

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

You will use MicroStream for persistence.

2. What you will need

To complete this guide, you will need the following:

  • Some time on your hands

  • A decent text editor or IDE

  • JDK 1.8 or greater installed with JAVA_HOME configured appropriately

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

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

mn create-app example.micronaut.micronautguide \
    --features=microstream \
    --build=maven --lang=groovy
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 use Micronaut Launch, select Micronaut Application as application type and add microstream features.

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.1. Dependencies

The microstream features adds the following dependencies:

pom.xml
<dependency>
    <groupId>io.micronaut.microstream</groupId>
    <artifactId>micronaut-microstream-annotations</artifactId>
    <scope>compile</scope>
</dependency>
<dependency>
    <groupId>io.micronaut.microstream</groupId>
    <artifactId>micronaut-microstream</artifactId>
    <scope>compile</scope>
</dependency>

4.2. Domain object

Create a Fruit class which will be used as the domain object.

src/main/groovy/example/micronaut/Fruit.groovy
package example.micronaut

import io.micronaut.core.annotation.Creator
import io.micronaut.core.annotation.Introspected
import io.micronaut.core.annotation.NonNull
import io.micronaut.core.annotation.Nullable
import javax.validation.constraints.NotBlank

@Introspected (1)
class Fruit {

    @NonNull
    @NotBlank (2)
    final String name

    @Nullable (3)
    String description

    Fruit(@NonNull String name) {
        this(name, null)
    }

    @Creator
    Fruit(@NonNull String name, @Nullable String description) {
        this.name = name
        this.description = description
    }
}
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.
3 The description is allowed to be null.

4.3. Root Object

Create a FruitContainer POGO which will be used as the root of our object graph.

src/main/groovy/example/micronaut/FruitContainer.groovy
package example.micronaut

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

import java.util.concurrent.ConcurrentHashMap

@Introspected (1)
class FruitContainer {

    @NonNull
    final Map<String, Fruit> fruits = new ConcurrentHashMap<>()
}
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.

4.4. Configuration

Add the following snippet to application.yml to configure MicroStream.

src/main/resources/application.yml
microstream:
  storage:
    main:
      root-class: 'example.micronaut.FruitContainer'
      storage-directory: 'build/fruit-storage'

4.5. Command object

And a FruitCommand class which will be used as the command object over HTTP.

src/main/groovy/example/micronaut/FruitCommand.groovy
package example.micronaut

import io.micronaut.core.annotation.Creator
import io.micronaut.core.annotation.Introspected
import io.micronaut.core.annotation.NonNull
import io.micronaut.core.annotation.Nullable
import javax.validation.constraints.NotBlank

@Introspected (1)
class FruitCommand {

    @NonNull
    @NotBlank (2)
    final String name

    @Nullable (3)
    final String description

    FruitCommand(@NonNull String name) {
        this(name, null)
    }

    @Creator
    FruitCommand(@NonNull String name, @Nullable String description) {
        this.name = name
        this.description = description
    }
}
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.
3 The description is allowed to be null.

4.6. Repository

Create a repository interface to encapsulate the CRUD actions for Fruit.

src/main/groovy/example/micronaut/FruitRepository.groovy
package example.micronaut

import io.micronaut.core.annotation.NonNull
import io.micronaut.core.annotation.Nullable

import javax.validation.Valid
import javax.validation.constraints.NotBlank
import javax.validation.constraints.NotNull

interface FruitRepository {

    @NonNull
    Collection<Fruit> list()

    @NonNull
    Fruit create(@NonNull @NotNull @Valid FruitCommand fruit) (1)
            throws FruitDuplicateException

    @Nullable
    Fruit update(@NonNull @NotNull @Valid FruitCommand fruit) (1)

    @Nullable
    Fruit find(@NonNull @NotBlank String name)

    void delete(@NonNull @NotNull @Valid FruitCommand fruit) (1)
}
1 Add @Valid to any method parameter which requires validation.

4.7. Error handling

In the event an attempt is made to create a duplicate fruit, we will catch the exception with a custom class.

src/main/groovy/example/micronaut/FruitDuplicateException.groovy
package example.micronaut

class FruitDuplicateException extends RuntimeException{

    FruitDuplicateException(String name) {
        super("Fruit '" + name + "' already exists.")
    }
}

This exception will be handled by a custom ExceptionHandler to return a 400 error with a sensible message.

src/main/groovy/example/micronaut/FruitDuplicateExceptionHandler.groovy
package example.micronaut

import io.micronaut.http.HttpRequest
import io.micronaut.http.HttpResponse
import io.micronaut.http.annotation.Produces
import io.micronaut.http.server.exceptions.ExceptionHandler
import io.micronaut.http.server.exceptions.response.ErrorContext
import io.micronaut.http.server.exceptions.response.ErrorResponseProcessor
import jakarta.inject.Singleton

@Produces (1)
@Singleton (2)
class FruitDuplicateExceptionHandler implements ExceptionHandler<FruitDuplicateException, HttpResponse<?>> {

    private final ErrorResponseProcessor<?> errorResponseProcessor

    FruitDuplicateExceptionHandler(ErrorResponseProcessor<?> errorResponseProcessor) {
        this.errorResponseProcessor = errorResponseProcessor
    }

    @Override
    HttpResponse<?> handle(HttpRequest request, FruitDuplicateException exception) {
        ErrorContext errorContext = ErrorContext.builder(request)
                .cause(exception)
                .errorMessage(exception.getMessage())
                .build()
        return errorResponseProcessor.processResponse(errorContext, HttpResponse.unprocessableEntity())
    }
}
1 Ensure the response content-type is set to application/json with the @Produces annotation.
2 Use jakarta.inject.Singleton to designate a class as a singleton.

4.8. Repository implementation

Implement the FruitRepository interface.

When an object in your graph changes, you need to persist the object that contains the change. This can be achieved through the StoreParams and StoreReturn annotations

src/main/groovy/example/micronaut/FruitRepositoryImpl.groovy
package example.micronaut

import io.micronaut.core.annotation.NonNull
import io.micronaut.core.annotation.Nullable
import io.micronaut.microstream.RootProvider
import io.micronaut.microstream.annotations.StoreParams
import io.micronaut.microstream.annotations.StoreReturn
import jakarta.inject.Singleton

import javax.validation.Valid
import javax.validation.constraints.NotBlank
import javax.validation.constraints.NotNull

@Singleton (1)
class FruitRepositoryImpl implements FruitRepository {

    private final RootProvider<FruitContainer> rootProvider

    FruitRepositoryImpl(RootProvider<FruitContainer> rootProvider) { (2)
        this.rootProvider = rootProvider
    }

    @Override
    @NonNull
    Collection<Fruit> list() {
        return rootProvider.root().fruits.values() (3)
    }

    @Override
    @NonNull
    Fruit create(@NonNull @NotNull @Valid FruitCommand fruit) {
        Map<String, Fruit> fruits = rootProvider.root().fruits
        if (fruits.containsKey(fruit.name)) {
            throw new FruitDuplicateException(fruit.name)
        }
        return performCreate(fruits, fruit)
    }

    @StoreParams("fruits") (4)
    protected Fruit performCreate(Map<String, Fruit> fruits, FruitCommand fruit) {
        Fruit newFruit = new Fruit(fruit.name, fruit.description)
        fruits.put(fruit.name, newFruit)
        return newFruit
    }

    @Nullable
    Fruit update(@NonNull @NotNull @Valid FruitCommand fruit) {
        Map<String, Fruit> fruits = rootProvider.root().getFruits()
        Fruit foundFruit = fruits.get(fruit.name)
        if (foundFruit != null) {
            return performUpdate(foundFruit, fruit)
        }
        return null
    }

    @StoreReturn (5)
    protected Fruit performUpdate(@NonNull Fruit foundFruit, @NonNull FruitCommand fruit) {
        foundFruit.setDescription(fruit.description)
        return foundFruit
    }

    @Override
    @Nullable
    Fruit find(@NonNull @NotBlank String name) {
        return rootProvider.root().fruits.get(name)
    }

    @Override
    void delete(@NonNull @NotNull @Valid FruitCommand fruit) {
        performDelete(fruit)
    }

    @StoreReturn (5)
    protected Map<String, Fruit> performDelete(FruitCommand fruit) {
        if (rootProvider.root().fruits.remove(fruit.name) != null) {
            return rootProvider.root().fruits
        }
        return null
    }
}
1 Use jakarta.inject.Singleton to designate a class as a singleton.
2 Use constructor injection to inject a bean of type RootProvider.
3 Return all the values in the FruitContainer.
4 With @StoreParams, on successful completion of this method, the Map argument fruits will be persisted in MicroStream.
5 With @StoreReturn, on successful completion of this method, the return value will be persisted in MicroStream.

4.9. Controller

Create FruitController:

src/main/groovy/example/micronaut/FruitController.groovy
package example.micronaut

import io.micronaut.core.annotation.NonNull
import io.micronaut.http.HttpStatus
import io.micronaut.http.annotation.Body
import io.micronaut.http.annotation.Controller
import io.micronaut.http.annotation.Delete
import io.micronaut.http.annotation.Get
import io.micronaut.http.annotation.PathVariable
import io.micronaut.http.annotation.Post
import io.micronaut.http.annotation.Put
import io.micronaut.http.annotation.Status

import javax.validation.constraints.NotBlank

import static io.micronaut.scheduling.TaskExecutors.IO
import io.micronaut.scheduling.annotation.ExecuteOn

import javax.validation.Valid
import javax.validation.constraints.NotNull

@Controller("/fruits") (1)
class FruitController {

    private final FruitRepository fruitRepository

    FruitController(FruitRepository fruitRepository) {  (2)
        this.fruitRepository = fruitRepository
    }

    @Get (3)
    Collection<Fruit> list() {
        fruitRepository.list()
    }

    @ExecuteOn(IO)
    @Post (4)
    @Status(HttpStatus.CREATED) (5)
    Fruit create(@NonNull @NotNull @Valid @Body FruitCommand fruit) { (6)
        fruitRepository.create(fruit)
    }

    @ExecuteOn(IO)
    @Put
    Fruit update(@NonNull @NotNull @Valid @Body FruitCommand fruit) {
        fruitRepository.update(fruit)
    }

    @Get("/{name}") (7)
    Fruit find(@NonNull @NotBlank @PathVariable String name) {
        fruitRepository.find(name)
    }

    @ExecuteOn(IO)
    @Delete
    @Status(HttpStatus.NO_CONTENT)
    void delete(@NonNull @NotNull @Valid @Body FruitCommand fruit) {
        fruitRepository.delete(fruit)
    }
}
1 The class is defined as a controller with the @Controller annotation mapped to the path /fruits.
2 Use constructor injection to inject a bean of type FruitRepository.
3 The @Get annotation maps the list method to an HTTP GET request on /fruits.
4 The @Post annotation maps the save method to an HTTP POST request on /fruits.
5 You can specify the HTTP status code via the @Status annotation.
6 Add @Valid to any method parameter which requires validation.
7 The @Get annotation maps the find method to an HTTP GET request on /fruits/{name}.

4.10. Test

Create a test that verifies the validation of the FruitCommand POJO when we invoke the FruitRepository interface:

src/test/groovy/example/micronaut/FruitRepositorySpec.groovy
package example.micronaut

import io.micronaut.test.extensions.spock.annotation.MicronautTest
import jakarta.inject.Inject
import spock.lang.Specification
import javax.validation.ConstraintViolationException

@MicronautTest(startApplication = false) (1)
class FruitRepositorySpec extends Specification {

    @Inject
    FruitRepository fruitRepository

    void "methods validate parameters"() {
        when:
        fruitRepository.create(new FruitCommand(""))

        then:
        thrown(ConstraintViolationException)
    }
}
1 Annotate the class with @MicronautTest so the Micronaut framework will initialize the application context and the embedded server. By default, each @Test method will be wrapped in a transaction that will be rolled back when the test finishes. This behaviour is is changed by setting transaction to false.

Create a test that verifies the validation of the FruitCommand POJO when we create a new entity via POST:

src/test/groovy/example/micronaut/FruitValidationControllerSpec.groovy
package example.micronaut

import io.micronaut.http.HttpRequest
import io.micronaut.http.HttpStatus
import io.micronaut.http.client.HttpClient
import io.micronaut.http.client.annotation.Client
import io.micronaut.http.client.exceptions.HttpClientResponseException
import io.micronaut.test.extensions.spock.annotation.MicronautTest
import jakarta.inject.Inject
import spock.lang.Specification

@MicronautTest (1)
class FruitValidationControllerSpec extends Specification {

    @Inject
    @Client("/")
    HttpClient httpClient (2)

    void "fruit validation works as expected"() {
        when:
        httpClient.toBlocking().exchange(HttpRequest.POST("/fruits", new FruitCommand("", "")))

        then:
        def e = thrown(HttpClientResponseException.class)
        e.status == HttpStatus.BAD_REQUEST
    }
}
1 Annotate the class with @MicronautTest so the Micronaut framework will initialize the application context and the embedded server. More info.
2 Inject the HttpClient bean and point it to the embedded server.

We will use temporary directories to persist our data under test.

To facilitate this, create a base test class that handles the creation of a temporary folder, and configuring the application.

src/test/groovy/example/micronaut/BaseSpec.groovy
package example.micronaut

import io.micronaut.test.support.TestPropertyProvider
import spock.lang.Specification
import java.nio.file.Files
import java.nio.file.Path
import io.micronaut.core.annotation.NonNull

abstract class BaseSpec extends Specification implements TestPropertyProvider {

    @Override
    @NonNull
    Map<String, String> getProperties() {
        Path tempDir = Files.createTempDirectory('microstream')
        ["microstream.storage.main.storage-directory": tempDir.toString()]
    }
}

Create a test which validate FruitDuplicateExceptionHandler.

src/test/groovy/example/micronaut/FruitDuplicationExceptionHandlerSpec.groovy
package example.micronaut

import io.micronaut.http.HttpRequest
import io.micronaut.http.HttpResponse
import io.micronaut.http.HttpStatus
import io.micronaut.http.client.HttpClient
import io.micronaut.http.client.annotation.Client
import io.micronaut.http.client.exceptions.HttpClientResponseException
import io.micronaut.test.extensions.spock.annotation.MicronautTest
import jakarta.inject.Inject

@MicronautTest (1)
class FruitDuplicationExceptionHandlerSpec extends BaseSpec {

    @Inject
    @Client("/")
    HttpClient httpClient (2)

    void "duplicated fruit returns 422"() {
        when:
        FruitCommand banana = new FruitCommand("Banana")
        HttpRequest<?> request = HttpRequest.POST("/fruits", banana)
        HttpResponse<?> response = httpClient.toBlocking().exchange(request)

        then:
        HttpStatus.CREATED == response.status()

        when:
        httpClient.toBlocking().exchange(request)

        then:
        HttpClientResponseException e = thrown()
        HttpStatus.UNPROCESSABLE_ENTITY == e.status

        when:
        HttpRequest<?> deleteRequest = HttpRequest.DELETE("/fruits", banana)
        HttpResponse<?> deleteResponse = httpClient.toBlocking().exchange(deleteRequest)

        then:
        HttpStatus.NO_CONTENT == deleteResponse.status()
    }
}
1 Annotate the class with @MicronautTest so the Micronaut framework will initialize the application context and the embedded server. More info.
2 Inject the HttpClient bean and point it to the embedded server.

Add a Micronaut declarative HTTP Client to src/test to ease the testing of the application’s API.

src/test/groovy/example/micronaut/FruitClient.groovy
package example.micronaut

import io.micronaut.core.annotation.NonNull
import io.micronaut.http.HttpResponse
import io.micronaut.http.HttpStatus
import io.micronaut.http.annotation.Body
import io.micronaut.http.annotation.Delete
import io.micronaut.http.annotation.Get
import io.micronaut.http.annotation.PathVariable
import io.micronaut.http.annotation.Post
import io.micronaut.http.annotation.Put
import io.micronaut.http.client.annotation.Client

import javax.validation.Valid
import javax.validation.constraints.NotBlank
import javax.validation.constraints.NotNull

@Client("/fruits")
interface FruitClient {

    @Get
    Iterable<Fruit> list()

    @Get("/{name}")
    Optional<Fruit> find(@NonNull @NotBlank @PathVariable String name)

    @Post
    HttpResponse<Fruit> create(@NonNull @NotNull @Valid @Body FruitCommand fruit)

    @Put
    Optional<Fruit> update(@NonNull @NotNull @Valid @Body FruitCommand fruit)

    @NonNull
    @Delete
    HttpStatus delete(@NonNull @Valid @Body FruitCommand fruit)
}

And finally, create a test that checks our controller works against MicroStream correctly:

src/test/groovy/example/micronaut/FruitControllerSpec.groovy
package example.micronaut

import io.micronaut.context.ApplicationContext
import io.micronaut.http.HttpResponse
import io.micronaut.http.HttpStatus
import io.micronaut.runtime.server.EmbeddedServer

import java.util.stream.Collectors
import java.util.stream.Stream
import java.util.stream.StreamSupport

class FruitControllerSpec extends BaseSpec {

    void "test interaction with the Controller"() {
        given:
        FruitCommand apple = new FruitCommand("apple", "Keeps the doctor away")
        String bananaName = "banana"
        String bananaDescription = "Yellow and curved"
        Map<String, Object> properties = new HashMap<>(getProperties())
        EmbeddedServer embeddedServer = ApplicationContext.run(EmbeddedServer, properties) (1)
        FruitClient fruitClient = embeddedServer.getApplicationContext().getBean(FruitClient)

        when:
        HttpResponse<Fruit> response = fruitClient.create(new FruitCommand(bananaName))
        then:
        HttpStatus.CREATED == response.status
        response.body.isPresent()

        when:
        Fruit banana = response.body.get()
        List<Fruit> fruitList = fruitsList(fruitClient)

        then:
        1 == fruitList.size()
        banana.name == fruitList.get(0).name
        !fruitList.get(0).description

        when:
        Optional<Fruit> bananaOptional = fruitClient.update(apple)

        then:
        !bananaOptional.isPresent()

        when:
        response = fruitClient.create(apple)

        then:
        HttpStatus.CREATED == response.status
        fruitsStream(fruitClient)
                    .anyMatch(f -> "Keeps the doctor away" == f.description)

        when:
        bananaOptional = fruitClient.update(new FruitCommand(bananaName, bananaDescription))
        then:
        bananaOptional.isPresent()
        Stream.of("Keeps the doctor away", "Yellow and curved").collect(Collectors.toSet()) ==
                fruitsStream(fruitClient)
                        .map(Fruit::getDescription)
                        .collect(Collectors.toSet())
        when:
        embeddedServer.close()
        embeddedServer = ApplicationContext.run(EmbeddedServer, properties) (1)
        fruitClient = embeddedServer.getApplicationContext().getBean(FruitClient)

        then:
        2 == numberOfFruits(fruitClient)
        when:
        fruitClient.delete(apple)
        fruitClient.delete(new FruitCommand(bananaName, bananaDescription))
        embeddedServer.close()
        embeddedServer = ApplicationContext.run(EmbeddedServer, properties) (1)
        fruitClient = embeddedServer.getApplicationContext().getBean(FruitClient)

        then:
        0 == numberOfFruits(fruitClient)

        cleanup:
        embeddedServer.close()
    }

    private int numberOfFruits(FruitClient fruitClient) {
        return fruitsList(fruitClient).size()
    }

    private List<Fruit> fruitsList(FruitClient fruitClient) {
        return fruitsStream(fruitClient)
                .collect(Collectors.toList())
    }

    private Stream<Fruit> fruitsStream(FruitClient fruitClient) {
        Iterable<Fruit> fruits = fruitClient.list()
        return StreamSupport.stream(fruits.spliterator(), false)
    }
}
1 Start and stop application to verify the data is persisted to disk by MicroStream and can be retrieved after application restart.

5. Testing the Application

To run the tests:

./mvnw test

6. Running the Application

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

Create a new fruit
curl -i -d '{"name":"Pear"}' \
     -H "Content-Type: application/json" \
     -X POST http://localhost:8080/fruits
Output
HTTP/1.1 201 Created
date: Thu, 12 May 2022 13:45:56 GMT
Content-Type: application/json
content-length: 16
connection: keep-alive

{"name":"Pear"}
Get a list of all fruits
curl -i localhost:8080/fruits
Output
HTTP/1.1 200 OK
date: Thu, 12 May 2022 13:46:54 GMT
Content-Type: application/json
content-length: 70
connection: keep-alive

[{"name":"Pear"}]

7. MicroStream REST and GUI

Often, during development is useful to see the data being saved by MicroStream. Micronaut MicroStream integration helps to do that.

Add the following dependency:

pom.xml
<dependency>
    <groupId>io.micronaut.microstream</groupId>
    <artifactId>micronaut-microstream-rest</artifactId>
    <scope>developmentOnly</scope>
</dependency>

The above dependency provides several JSON endpoints which expose the contents of the MicroStream storage.

7.1. MicroStream Client GUI

Run the client and connect to the MicroStream REST API exposed by the Micronaut application:

microstream rest 1

You can visualize the data you saved via cURL.

microstream rest 2

8. Next steps

Explore more features with Micronaut Guides.

Read more about the Micronaut MicroStream integration. Read more about MicroStream for Java.

9. Sponsors

MicroStream sponsored the creation of this Guide.