Micronaut MicroStream

Micronaut integration with MicroStream

Version: 2.6.1-SNAPSHOT

1 Introduction

Integration with MicroStream.

2 Release History

For this project, you can find a list of releases (with release notes) here:

3 What's New

Since Micronaut MicroStream 2, you can use Storage Targets such as PostgreSQL, Amazon S3 and DynamoDB.

4 Dependency

To start, add the following dependencies to your classpath.

annotationProcessor("io.micronaut.microstream:micronaut-microstream-annotations")
<annotationProcessorPaths>
    <path>
        <groupId>io.micronaut.microstream</groupId>
        <artifactId>micronaut-microstream-annotations</artifactId>
    </path>
</annotationProcessorPaths>

For Kotlin, add the micronaut-microstream-annotations dependency in kapt or ksp scope. For Groovy annotation processing, the following implementation scope is adequate.

implementation("io.micronaut.microstream:micronaut-microstream-annotations")
<dependency>
    <groupId>io.micronaut.microstream</groupId>
    <artifactId>micronaut-microstream-annotations</artifactId>
</dependency>

implementation("io.micronaut.microstream:micronaut-microstream")
<dependency>
    <groupId>io.micronaut.microstream</groupId>
    <artifactId>micronaut-microstream</artifactId>
</dependency>

5 Configuration

A Micronaut application can have more than one MicroStream instances. Each MicroStream instance represents one coherent entity graph of persistent data.

You can use the same values described in the MicroStream Configuration documentation.

The following configuration example configures two beans of type EmbeddedStorageConfigurationProvider with Name Bean Qualifiers: orange and blue.

src/main/resources/application.yml
microstream:
  storage:
    orange: (1)
      root-class: 'io.micronaut.microstream.docs.OneData' (2)
      storage-directory: build/microstream${random.shortuuid}
      channel-count: 4
    blue: (2)
      root-class: 'io.micronaut.microstream.docs.AnotherData' (2)
      storage-directory: build/microstream${random.shortuuid}
      channel-count: 4
      channel-directory-prefix: 'channel_'
      data-file-prefix: 'channel_'
      data-file-suffix: '.dat'
1 Specify a different name qualifier for each MicroStream instance
2 Specify the class of the entity graph’s root.

6 Root Instance

The following example creates a Root Instance to store Customers:

package io.micronaut.microstream.docs;

import io.micronaut.core.annotation.NonNull;

import java.util.HashMap;
import java.util.Map;

public class Data {
    private Map<String, Customer> customers = new HashMap<>();

    @NonNull
    public Map<String, Customer> getCustomers() {
        return this.customers;
    }
}
package io.micronaut.microstream.docs

import io.micronaut.core.annotation.Introspected

@Introspected // (1)
class Data {
    Map<String, Customer> customers = [:]
}
package io.micronaut.microstream.docs

import io.micronaut.core.annotation.Introspected

@Introspected // (1)
data class Data(val customers: MutableMap<String, Customer> = mutableMapOf())

And Customer is defined as:

package io.micronaut.microstream.docs;

import io.micronaut.core.annotation.NonNull;
import io.micronaut.core.annotation.Nullable;
import io.micronaut.serde.annotation.Serdeable;

import jakarta.validation.constraints.NotBlank;

@Serdeable // (1)
public class Customer {
    @NonNull
    @NotBlank
	private final String id;

    @NonNull
    @NotBlank
	private String firstName;

    @Nullable
	private String lastName;

    public Customer(@NonNull String id, @NonNull String firstName, @Nullable String lastName) {
        this.id = id;
        this.firstName = firstName;
        this.lastName = lastName;
    }

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

    public void setFirstName(@NonNull String firstName) {
        this.firstName = firstName;
    }

    public void setLastName(@Nullable String lastName) {
        this.lastName = lastName;
    }

    @NonNull
    public String getFirstName() {
        return firstName;
    }

    @Nullable
    public String getLastName() {
        return lastName;
    }
}
package io.micronaut.microstream.docs

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

import jakarta.validation.constraints.NotBlank

@Introspected
class Customer {
    @NonNull
    @NotBlank
    String id

    @NonNull
    @NotBlank
    String firstName

    @Nullable
    String lastName

    Customer(@NonNull String id, @NonNull String firstName, @Nullable String lastName) {
        this.id = id
        this.firstName = firstName
        this.lastName = lastName
    }
}
package io.micronaut.microstream.docs

import io.micronaut.core.annotation.Introspected

@Introspected
class Customer(val id: String, var firstName: String, var lastName: String?)
1 The type is annotated with @Serdeable to enable serialization/deserialization

You specify the Root class via configuration:

src/main/resources/application.yml
microstream:
  storage:
    main: (1)
      root-class: 'io.micronaut.microstream.docs.Data' (2)
      storage-directory: '/Users/sdelamo/Documents/MicroStreamData'
1 Specify a name qualifier for the MicroStream instance
2 Specify the class of the entity graph’s root.

7 Annotations

Micronaut MicroStream ships the following around method annotations. They simplify storing objects in an associated Store Manager.

These annotations wrap the decorated method to ensure thread isolation.

@StoreParams

It stores the method parameters specified in the annotation value.

@StoreReturn

It stores the method return

@StoreRoot

It stores the root object.

@Store

Micronaut MicroStream maps other annotations to this annotation. You will not use this annotation directly.

If you work with multiple MicroStream instances, you can supply the name qualifier to the annotation to specify the instance you are working with.

Moreover, you can specify the StoringStrategy via the annotations. By default, LAZY is the default storing mode.

8 Storage

The following example shows how to create implementations for this repository:

package io.micronaut.microstream.docs;

import io.micronaut.core.annotation.NonNull;

import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.util.Collection;
import java.util.Optional;
import java.util.UUID;

public interface CustomerRepository {

    @NonNull
    Customer save(@NonNull @NotNull @Valid CustomerSave customerSave);

    void update(@NonNull @NotBlank String id,
                @NonNull @NotNull @Valid CustomerSave customerSave);

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

    void deleteById(@NonNull @NotBlank String id);
}
package io.micronaut.microstream.docs

import io.micronaut.core.annotation.NonNull

import jakarta.validation.Valid
import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.NotNull

interface CustomerRepository {

    @NonNull
    Customer save(@NonNull @NotNull @Valid CustomerSave customer)

    void update(@NonNull @NotBlank String id,
                @NonNull @NotNull @Valid CustomerSave customer)

    @NonNull
    Optional<Customer> findById(@NonNull @NotBlank String id)

    void deleteById(@NonNull @NotBlank String id)
}
package io.micronaut.microstream.docs

import jakarta.validation.Valid
import jakarta.validation.constraints.NotBlank

interface CustomerRepository {
    fun save(customerSave: @Valid CustomerSave): Customer
    fun update(id: @NotBlank String, customerSave: @Valid CustomerSave)
    fun findById(id: @NotBlank String): Customer?
    fun deleteById(id: @NotBlank String)
}

8.1 StorageManager

The following example shows how to create a repository which injects the StorageManager and retrieves the Root Instance:

@Singleton
public class CustomerRepositoryImpl implements CustomerRepository {

    private final StorageManager storageManager;

    public CustomerRepositoryImpl(StorageManager storageManager) { // (1)
        this.storageManager = storageManager;
    }

	@Override
    @NonNull
	public Customer save(@NonNull @NotNull @Valid CustomerSave customerSave) {
        return XThreads.executeSynchronized(() -> { // (2)
            String id = UUID.randomUUID().toString();
            Customer customer = new Customer(id, customerSave.getFirstName(), customerSave.getLastName());
            data().getCustomers().put(id, customer);
            storageManager.store(data().getCustomers()); // (3)
            return customer;
        });
	}

    @Override
    public void update(@NonNull @NotBlank String id,
                       @NonNull @NotNull @Valid CustomerSave customerSave) {
        XThreads.executeSynchronized(() -> { // (2)
            Customer c = data().getCustomers().get(id);
            c.setFirstName(customerSave.getFirstName());
            c.setLastName(customerSave.getLastName());
            storageManager.store(c); // (3)
        });
    }

    @Override
    @NonNull
    public Optional<Customer> findById(@NonNull @NotBlank String id) {
        return Optional.ofNullable(data().getCustomers().get(id));
    }

    @Override
    public void deleteById(@NonNull @NotBlank String id) {
        XThreads.executeSynchronized(() -> { // (2)
            data().getCustomers().remove(id);
            storageManager.store(data().getCustomers()); // (3)
        });
    }

    private Data data() {
        return (Data) storageManager.root();
    }
}
@Singleton
class CustomerRepositoryImpl implements CustomerRepository {

    private final StorageManager storageManager

    CustomerRepositoryImpl(StorageManager storageManager) { // (1)
        this.storageManager = storageManager
    }

    @Override
    @NonNull
    Customer save(@NonNull @NotNull @Valid CustomerSave customerSave) {
        XThreads.executeSynchronized(new Supplier<Customer>() { // (2)
            @Override
            Customer get() {
                String id = UUID.randomUUID().toString()
                Customer customer = new Customer(id, customerSave.getFirstName(), customerSave.getLastName())
                data().getCustomers().put(id, customer)
                store(data().getCustomers()) // (3)
                customer
            }
        })
    }

    @Override
    void update(@NonNull @NotBlank String id,
                @NonNull @NotNull @Valid CustomerSave customerSave) {
        XThreads.executeSynchronized(new Runnable() { // (2)
            @Override
            void run() {
                Customer c = data().getCustomers().get(id)
                c.setFirstName(customerSave.getFirstName())
                c.setLastName(customerSave.getLastName())
                store(c) // (3)
            }
        })
    }

    @Override
    @NonNull
    Optional<Customer> findById(@NonNull @NotBlank String id) {
        Optional.ofNullable(data().getCustomers().get(id))
    }

    @Override
    void deleteById(@NonNull @NotBlank String id) {
        XThreads.executeSynchronized(new Runnable() { // (2)
            @Override
            void run() {
                data().getCustomers().remove(id)
                store(data().getCustomers()) // (3)
            }
        })
    }

    private void store(Object instance) {
        storageManager.store(instance)
    }

    private Data data() {
        (Data) storageManager.root()
    }
}
@Singleton
class CustomerRepositoryImpl(private val storageManager: StorageManager) // (1)
    : CustomerRepository {
    override fun save(customerSave: CustomerSave): Customer {
        val id = UUID.randomUUID().toString()
        val customer = Customer(id, customerSave.firstName, customerSave.lastName)
        XThreads.executeSynchronized { // (2)
            data.customers[customer.id] = customer
            storageManager.store(data.customers) // (3)
        }
        return customer
    }

    override fun update(id : String, customerSave: CustomerSave) {
        XThreads.executeSynchronized { // (2)
            val customer : Customer? = data.customers[id]
            if (customer != null) {
                with(customer) {
                    firstName = customerSave.firstName
                    lastName = customerSave.lastName
                }
                storageManager.store(customer) // (3)
            }
        }
    }

    @NonNull
    override fun findById(id: @NotBlank String): Customer? {
        return data.customers[id]
    }

    override fun deleteById(id: @NotBlank String) {
        XThreads.executeSynchronized { // (2)
            data.customers.remove(id)
            storageManager.store(data.customers) // (3)
        }
    }

    private val data: Data
        get() {
            val root = storageManager.root()
            return if (root is Data) root else throw RuntimeException("Root is not Data")
        }
}
1 If your Micronaut application has only a single MicroStream Instance, you don’t need to specify a name qualifier to inject StorageManager.
2 When you are working with MicroStream technology in a multi-threaded environment, reading and writing to this shared object graph must be synchronized.
3 To store a newly created object, store the "owner" of the object.

8.2 Working with Annotations

The following example shows how an equivalent implementation which leverages the Micronaut MicroStream annotations to simplify object storage.

@Singleton
public class CustomerRepositoryStoreImpl implements CustomerRepository {

    private final RootProvider<Data> rootProvider;

    public CustomerRepositoryStoreImpl(RootProvider<Data> rootProvider) { // (1)
        this.rootProvider = rootProvider;
    }

    @Override
    @NonNull
    public Customer save(@NonNull @NotNull @Valid CustomerSave customerSave) {
        return addCustomer(rootProvider.root().getCustomers(), customerSave);
    }

    @Override
    public void update(@NonNull @NotBlank String id,
                       @NonNull @NotNull @Valid CustomerSave customerSave) {
        updateCustomer(id, customerSave);
    }

    @Override
    @NonNull
    public Optional<Customer> findById(@NonNull @NotBlank String id) {
        return Optional.ofNullable(rootProvider.root().getCustomers().get(id));
    }

    @Override
    public void deleteById(@NonNull @NotBlank String id) {
        removeCustomer(rootProvider.root().getCustomers(), id);
    }

    @StoreReturn // (2)
    @Nullable
    protected Customer updateCustomer(@NonNull String id,
                                      @NonNull CustomerSave customerSave) {
        Customer c = rootProvider.root().getCustomers().get(id);
        if (c != null) {
            c.setFirstName(customerSave.getFirstName());
            c.setLastName(customerSave.getLastName());
            return c;
        }
        return null;
    }

    @StoreParams("customers") // (3)
    protected Customer addCustomer(@NonNull Map<String, Customer> customers,
                                   @NonNull CustomerSave customerSave) {
        Customer customer = new Customer(UUID.randomUUID().toString(),
            customerSave.getFirstName(),
            customerSave.getLastName());
        customers.put(customer.getId(), customer);
        return customer;
    }

    @StoreParams("customers") // (3)
    protected void removeCustomer(@NonNull Map<String, Customer> customers,
                                  @NonNull String id) {
        customers.remove(id);
    }
}
@Singleton
class CustomerRepositoryStoreImpl implements CustomerRepository {

    private final RootProvider<Data> rootProvider

    CustomerRepositoryStoreImpl(RootProvider<Data> rootProvider) { // (1)
        this.rootProvider = rootProvider
    }

    @Override
    @NonNull
    Customer save(@NonNull @NotNull @Valid CustomerSave customerSave) {
        return addCustomer(rootProvider.root().customers, customerSave)
    }

    @Override
    void update(@NonNull @NotBlank String id,
                @NonNull @NotNull @Valid CustomerSave customerSave) {
        updateCustomer(id, customerSave)
    }

    @Override
    @NonNull
    Optional<Customer> findById(@NonNull @NotBlank String id) {
        Optional.ofNullable(rootProvider.root().customers[id])
    }

    @Override
    void deleteById(@NonNull @NotBlank String id) {
        removeCustomer(rootProvider.root().customers, id)
    }

    @StoreReturn // (2)
    @Nullable
    protected Customer updateCustomer(@NonNull String id,
                                      @NonNull CustomerSave customerSave) {
        Customer c = rootProvider.root().customers[id]
        if (c != null) {
            c.with {
                firstName = customerSave.firstName
                lastName = customerSave.lastName
            }
            return c
        }
        null
    }

    @StoreParams("customers") // (3)
    protected Customer addCustomer(@NonNull Map<String, Customer> customers,
                                   @NonNull CustomerSave customerSave) {
        Customer customer = new Customer(UUID.randomUUID().toString(),
            customerSave.firstName,
            customerSave.lastName)
        customers[customer.id] = customer
        customer
    }

    @StoreParams("customers") // (3)
    protected void removeCustomer(@NonNull Map<String, Customer> customers,
                                  @NonNull String id) {
        customers.remove(id)
    }
}
@Singleton
open class CustomerRepositoryStoreImpl(private val rootProvider: RootProvider<Data>) // (1)
    : CustomerRepository {
    override fun save(customerSave: @Valid CustomerSave): Customer {
        return addCustomer(rootProvider.root().customers, customerSave)
    }

    override fun update(id : @NotBlank String,
                        customerSave: @Valid CustomerSave) {
        updateCustomer(id, customerSave)
    }

    @NonNull
    override fun findById(id: @NotBlank String): Customer? {
        return rootProvider.root().customers[id]
    }

    override fun deleteById(id: @NotBlank String) {
        removeCustomer(rootProvider.root().customers, id)
    }

    @StoreReturn // (2)
    @Nullable
    open fun updateCustomer(id: String, customerSave: CustomerSave): Customer? {
        val c: Customer? = rootProvider.root().customers[id]
        return if (c != null) {
            c.firstName = customerSave.firstName
            c.lastName = customerSave.lastName
            c
        } else null
    }

    @StoreParams("customers") // (3)
    open fun addCustomer(customers: MutableMap<String, Customer>, customerSave: CustomerSave): Customer {
        val customer = Customer(
            UUID.randomUUID().toString(),
            customerSave.firstName,
            customerSave.lastName
        )
        customers[customer.id] = customer
        return customer
    }

    @StoreParams("customers") // (3)
    open fun removeCustomer(customers: MutableMap<String, Customer>, id: String) {
        customers.remove(id)
    }
}
1 You can also inject an instance of RootProvider to easily access the MicroStream Root Instance. If your Micronaut application has only a single MicroStream Instance, you don’t need to specify a name qualifier to inject it.
2 The rule is: "The Object that has been modified has to be stored!".
3 To store a newly created object, store the "owner" of the object.
Micronaut MicroStream annotations only work for synchronous methods. It does not do any logic for methods returning a Publisher or a CompletableFuture. For those scenarios, use directly the StorageManager.

9 Storage Targets

MicroStream supports a variety of storage targets. Through an abstracted file system (AFS), it is possible to connect to a lot of different back ends. The AFS allows to use folders and files, like in all common file systems, but with different connectors it is possible to use different solutions as the actual storage

By default, the Micronaut Framework MicroStream integration will use a local directory as a backing store for the object graph.

You can switch this to a different target via configuration. Currently supported targets are:

9.1 Postgres

To get started, add the following dependencies:

runtimeOnly("io.micronaut.sql:micronaut-jdbc-hikari")
<dependency>
    <groupId>io.micronaut.sql</groupId>
    <artifactId>micronaut-jdbc-hikari</artifactId>
    <scope>runtime</scope>
</dependency>

runtimeOnly("org.postgresql:postgresql")
<dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>postgresql</artifactId>
    <scope>runtime</scope>
</dependency>

runtimeOnly("one.microstream:microstream-afs-sql")
<dependency>
    <groupId>one.microstream</groupId>
    <artifactId>microstream-afs-sql</artifactId>
    <scope>runtime</scope>
</dependency>

The following defines a datasource named default that connects to a Postgres database:

datasources.default.url=jdbc:postgresql://host:port/database
datasources.default.username=«user»
datasources.default.password=«password»
datasources.default.driverClassName=org.postgresql.Driver
datasources:
    default:
        url: jdbc:postgresql://host:port/database
        username: «user»
        password: «password»
        driverClassName: org.postgresql.Driver
[datasources]
  [datasources.default]
    url="jdbc:postgresql://host:port/database"
    username="\u00abuser\u00bb"
    password="\u00abpassword\u00bb"
    driverClassName="org.postgresql.Driver"
datasources {
  'default' {
    url = "jdbc:postgresql://host:port/database"
    username = "\u00ABuser\u00BB"
    password = "\u00ABpassword\u00BB"
    driverClassName = "org.postgresql.Driver"
  }
}
{
  datasources {
    default {
      url = "jdbc:postgresql://host:port/database"
      username = "«user»"
      password = "«password»"
      driverClassName = "org.postgresql.Driver"
    }
  }
}
{
  "datasources": {
    "default": {
      "url": "jdbc:postgresql://host:port/database",
      "username": "«user»",
      "password": "«password»",
      "driverClassName": "org.postgresql.Driver"
    }
  }
}

The Postgres Storage Target can then be configured as follows:

microstream.postgres.storage.default.table-name=microstream
microstream.postgres.storage.default.root-class=com.example.model.Root
microstream:
    postgres:
        storage:
            default:                               # (1)
                table-name: microstream            # (2)
                root-class: com.example.model.Root # (3)
[microstream]
  [microstream.postgres]
    [microstream.postgres.storage]
      [microstream.postgres.storage.default]
        table-name="microstream"
        root-class="com.example.model.Root"
microstream {
  postgres {
    storage {
      'default' {
        tableName = "microstream"
        rootClass = "com.example.model.Root"
      }
    }
  }
}
{
  microstream {
    postgres {
      storage {
        default {
          table-name = "microstream"
          root-class = "com.example.model.Root"
        }
      }
    }
  }
}
{
  "microstream": {
    "postgres": {
      "storage": {
        "default": {
          "table-name": "microstream",
          "root-class": "com.example.model.Root"
        }
      }
    }
  }
}
1 The name for this Storage Manager
2 The name of the table to use in the database
3 The root class of the object graph to store

The storage manager will first attempt to look up a DataSource with the same name as the Storage Manager, and if this is not found, it will attempt to look up an un-named DataSource.

If you require to use a DataSource with a different name, this can be configured via the datasource-name property as below.

microstream.postgres.storage.backing.table-name=microstream
microstream.postgres.storage.backing.root-class=com.example.model.Root
microstream.postgres.storage.backing.datasource-name=default
microstream:
    postgres:
        storage:
            backing:
                table-name: microstream
                root-class: com.example.model.Root
                datasource-name: default # (1)
[microstream]
  [microstream.postgres]
    [microstream.postgres.storage]
      [microstream.postgres.storage.backing]
        table-name="microstream"
        root-class="com.example.model.Root"
        datasource-name="default"
microstream {
  postgres {
    storage {
      backing {
        tableName = "microstream"
        rootClass = "com.example.model.Root"
        datasourceName = "default"
      }
    }
  }
}
{
  microstream {
    postgres {
      storage {
        backing {
          table-name = "microstream"
          root-class = "com.example.model.Root"
          datasource-name = "default"
        }
      }
    }
  }
}
{
  "microstream": {
    "postgres": {
      "storage": {
        "backing": {
          "table-name": "microstream",
          "root-class": "com.example.model.Root",
          "datasource-name": "default"
        }
      }
    }
  }
}
1 The name of the DataSource to use

9.2 S3

Configuration of an S3 storage target requires a bean of type S3Client and configuration.

To get started, add the following dependencies:

runtimeOnly("software.amazon.awssdk:s3")
<dependency>
    <groupId>software.amazon.awssdk</groupId>
    <artifactId>s3</artifactId>
    <scope>runtime</scope>
</dependency>

runtimeOnly("io.micronaut.aws:micronaut-aws-sdk-v2")
<dependency>
    <groupId>io.micronaut.aws</groupId>
    <artifactId>micronaut-aws-sdk-v2</artifactId>
    <scope>runtime</scope>
</dependency>

runtimeOnly("one.microstream:microstream-afs-aws-s3")
<dependency>
    <groupId>one.microstream</groupId>
    <artifactId>microstream-afs-aws-s3</artifactId>
    <scope>runtime</scope>
</dependency>

The configuration can then be defined as follows:

microstream.s3.storage.default.bucket-name=microstream
microstream.s3.storage.default.root-class=com.example.model.Root
microstream:
    s3:
        storage:
            default:                               # (1)
                bucket-name: microstream           # (2)
                root-class: com.example.model.Root # (3)
[microstream]
  [microstream.s3]
    [microstream.s3.storage]
      [microstream.s3.storage.default]
        bucket-name="microstream"
        root-class="com.example.model.Root"
microstream {
  s3 {
    storage {
      'default' {
        bucketName = "microstream"
        rootClass = "com.example.model.Root"
      }
    }
  }
}
{
  microstream {
    s3 {
      storage {
        default {
          bucket-name = "microstream"
          root-class = "com.example.model.Root"
        }
      }
    }
  }
}
{
  "microstream": {
    "s3": {
      "storage": {
        "default": {
          "bucket-name": "microstream",
          "root-class": "com.example.model.Root"
        }
      }
    }
  }
}
1 The name for the created Storage Manager
2 The name of the S3 bucket to use for storage
3 The root class of the object graph to store

The storage manager will first attempt to look up an S3Client with the same name as the Storage Manager, and if this is not found, it will attempt to look up an un-named S3Client bean.

If you require to use an S3Client with a different name, this can be configured via the s3-client-name property as below.

microstream.s3.storage.default.s3-client-name=my-s3-client
microstream.s3.storage.default.bucket-name=microstream
microstream.s3.storage.default.root-class=com.example.model.Root
microstream:
    s3:
        storage:
            default:
                s3-client-name: my-s3-client       # (1)
                bucket-name: microstream
                root-class: com.example.model.Root
[microstream]
  [microstream.s3]
    [microstream.s3.storage]
      [microstream.s3.storage.default]
        s3-client-name="my-s3-client"
        bucket-name="microstream"
        root-class="com.example.model.Root"
microstream {
  s3 {
    storage {
      'default' {
        s3ClientName = "my-s3-client"
        bucketName = "microstream"
        rootClass = "com.example.model.Root"
      }
    }
  }
}
{
  microstream {
    s3 {
      storage {
        default {
          s3-client-name = "my-s3-client"
          bucket-name = "microstream"
          root-class = "com.example.model.Root"
        }
      }
    }
  }
}
{
  "microstream": {
    "s3": {
      "storage": {
        "default": {
          "s3-client-name": "my-s3-client",
          "bucket-name": "microstream",
          "root-class": "com.example.model.Root"
        }
      }
    }
  }
}
1 The name of the S3Client bean to use

9.3 DynamoDB

To get started, add the following dependencies:

runtimeOnly("software.amazon.awssdk:dynamodb")
<dependency>
    <groupId>software.amazon.awssdk</groupId>
    <artifactId>dynamodb</artifactId>
    <scope>runtime</scope>
</dependency>

runtimeOnly("io.micronaut.aws:micronaut-aws-sdk-v2")
<dependency>
    <groupId>io.micronaut.aws</groupId>
    <artifactId>micronaut-aws-sdk-v2</artifactId>
    <scope>runtime</scope>
</dependency>

runtimeOnly("one.microstream:microstream-afs-aws-dynamodb")
<dependency>
    <groupId>one.microstream</groupId>
    <artifactId>microstream-afs-aws-dynamodb</artifactId>
    <scope>runtime</scope>
</dependency>

The configuration can then be defined as follows:

microstream.dynamodb.storage.default.table-name=microstream
microstream.dynamodb.storage.default.root-class=com.example.model.Root
microstream:
    dynamodb:
        storage:
            default:                               # (1)
                table-name: microstream            # (2)
                root-class: com.example.model.Root # (3)
[microstream]
  [microstream.dynamodb]
    [microstream.dynamodb.storage]
      [microstream.dynamodb.storage.default]
        table-name="microstream"
        root-class="com.example.model.Root"
microstream {
  dynamodb {
    storage {
      'default' {
        tableName = "microstream"
        rootClass = "com.example.model.Root"
      }
    }
  }
}
{
  microstream {
    dynamodb {
      storage {
        default {
          table-name = "microstream"
          root-class = "com.example.model.Root"
        }
      }
    }
  }
}
{
  "microstream": {
    "dynamodb": {
      "storage": {
        "default": {
          "table-name": "microstream",
          "root-class": "com.example.model.Root"
        }
      }
    }
  }
}
1 The name for the created Storage Manager
2 The name of the DynamoDB table to use for storage
3 The root class of the object graph to store
If the DynamoDB table does not exist, MicroStream creates it.

The storage manager will first attempt to look up an DynamoDbClient with the same name as the Storage Manager, and if this is not found, it will attempt to look up an un-named DynamoDbClient bean.

If you require to use an DynamoDbClient with a different name, this can be configured via the dynamo-db-client-name property as below.

microstream.dynamodb.storage.default.dynamo-db-client-name=my-dynamo-client
microstream.dynamodb.storage.default.table-name=microstream
microstream.dynamodb.storage.default.root-class=com.example.model.Root
microstream:
    dynamodb:
        storage:
            default:
                dynamo-db-client-name: my-dynamo-client       # (1)
                table-name: microstream
                root-class: com.example.model.Root
[microstream]
  [microstream.dynamodb]
    [microstream.dynamodb.storage]
      [microstream.dynamodb.storage.default]
        dynamo-db-client-name="my-dynamo-client"
        table-name="microstream"
        root-class="com.example.model.Root"
microstream {
  dynamodb {
    storage {
      'default' {
        dynamoDbClientName = "my-dynamo-client"
        tableName = "microstream"
        rootClass = "com.example.model.Root"
      }
    }
  }
}
{
  microstream {
    dynamodb {
      storage {
        default {
          dynamo-db-client-name = "my-dynamo-client"
          table-name = "microstream"
          root-class = "com.example.model.Root"
        }
      }
    }
  }
}
{
  "microstream": {
    "dynamodb": {
      "storage": {
        "default": {
          "dynamo-db-client-name": "my-dynamo-client",
          "table-name": "microstream",
          "root-class": "com.example.model.Root"
        }
      }
    }
  }
}
1 The name of the DynamoDbClient bean to use

10 Cache

MicroStream can be used as a cache abstraction layer.

10.1 Cache Configuration

To use the MicroStream cache abstraction, you must declare a dependency on

implementation("io.micronaut.microstream:micronaut-microstream-cache")
<dependency>
    <groupId>io.micronaut.microstream</groupId>
    <artifactId>micronaut-microstream-cache</artifactId>
</dependency>

You can then define a cache by adding the following configuration to your application:

src/main/resources/application.yml
microstream:
  cache:
    counter: (1)
      key-type: java.lang.String
      value-type: java.lang.Long
1 Define a cache named counter which has String keys and Long values.

This cache can then be used via the following:

package io.micronaut.microstream.docs;

import io.micronaut.cache.annotation.CacheConfig;
import io.micronaut.cache.annotation.CachePut;
import io.micronaut.cache.annotation.Cacheable;
import jakarta.inject.Singleton;

import java.util.HashMap;
import java.util.Map;

@Singleton
@CacheConfig("counter") // (1)
public class CounterService {

    Map<String, Long> counters = new HashMap<>();

    @Cacheable // (2)
    public Long currentCount(String name) {
        return counters.get(name);
    }

    @CachePut(parameters = {"name"}) // (3)
    public Long setCount(String name, Long count) {
        counters.put(name, count);
        return count;
    }
}
package io.micronaut.microstream.docs

import io.micronaut.cache.annotation.CacheConfig
import io.micronaut.cache.annotation.CachePut
import io.micronaut.cache.annotation.Cacheable
import jakarta.inject.Singleton

@Singleton
@CacheConfig("counter") // (1)
class CounterService {

    Map<String, Long> counters = [:]

    @Cacheable // (2)
    Long currentCount(String name) {
        return counters.get(name)
    }

    @CachePut(parameters = ["name"]) // (3)
    Long setCount(String name, Long count) {
        counters.put(name, count)
        count
    }
}
package io.micronaut.microstream.docs

import io.micronaut.cache.annotation.CacheConfig
import io.micronaut.cache.annotation.CachePut
import io.micronaut.cache.annotation.Cacheable
import jakarta.inject.Singleton
import java.util.HashMap

@Singleton
@CacheConfig("counter") // (1)
open class CounterService {

    val counters: MutableMap<String, Long> = HashMap()

    @Cacheable // (2)
    open fun currentCount(name: String): Long? = counters[name]

    @CachePut(parameters = ["name"]) // (3)
    open fun setCount(name: String, count: Long): Long {
        counters[name] = count
        return count
    }
}
1 Use the counter cache.
2 The result of this call will be cached.
3 Setting the counter will invalidate the cache for this key.

10.2 Cache Storage

If caching that persists across restarts is required, you can back a MicroStream cache with a Storage Manager.

src/main/resources/application.yml
microstream:
  storage:
    backing: (1)
      storage-directory: "${storageDirectory}"
      channel-count: 4
  cache:
    counter: (2)
      key-type: java.lang.String
      value-type: java.lang.Long
      storage: backing (3)
1 Define a storage manager called backing
2 Define a cache called counter to store Strings keys and Long values
3 Configure the cache to use the backing storage manager

The above example will then persist across restarts.

11 Metrics

You can enable MicroStream Metrics collection by enabling Micrometer Metrics.

If you do not wish to collect MicroStream metrics, you can set micronaut.metrics.binders.microstream.enabled to false in application.yml.

12 Health

Health Endpoint exposes the status of the MicroStream Instances in your Micronaut Application.

After you add the management dependency, the /health endpoint of a Micronaut application with one MicroStream instance named blue will return:

{
  "name": "application",
  "status": "UP",
  "details": {
    "microstream.blue": {
      "name": "application",
      "status": "UP",
      "details": {
        "startingUp": false,
        "running": true,
        "active": true,
        "acceptingTasks": true,
        "shuttingDown": false,
        "shutdown": false
      }
    },
    ...
  }
}
By default, the details visible above are only shown to authenticated users. See the Health Endpoint documentation for how to configure that setting.

If you wish to disable the MicroStream health check while still using the management dependency you can set the property endpoints.health.microstream.enabled to false in your application configuration.

endpoints:
  health:
    microstream:
      enabled: false

13 REST API

The MicroStream Storage isn’t a typical database server with administrative tooling, it is a pure java persistence engine which runs embedded in your application. When developing a MicroStream application, it is useful to be able to inspect the graph that is currently in your storage.

The micronaut-microstream-rest library exposes the MicroStream REST API from inside your Micronaut application.

13.1 Enabling the REST API

To enable it, add the following dependency to your application.

developmentOnly("io.micronaut.microstream:micronaut-microstream-rest")
<dependency>
    <groupId>io.micronaut.microstream</groupId>
    <artifactId>micronaut-microstream-rest</artifactId>
    <scope>provided</scope>
</dependency>

You also need to enable it in your configuration, as for security it is disabled by default.

src/main/resources/application.yml
microstream:
  rest:
    enabled: true

It will output a warning each time your application is started if the REST endpoint is enabled that this should not be deployed to production.

13.2 Configuration options

The API path is /microstream by default.

When multiple storage managers are defined, the URL must be suffixed by the manager name /microstream/«storage-name».

This prefix can be configured via the configuration.

src/main/resources/application.yml
microstream:
  rest:
    path: custom-prefix

The above configuration would expose the REST API at /custom-prefix. (or /custom-prefix/«storage-name» if you have multiple storage managers defined).

13.3 GUI

The simplest way of interacting with this REST API is to use the MicroStream client GUI.

Instructions on downloading and running this can be found here.

14 Repository

You can find the source code of this project in this repository: