mn create-app example.micronaut.micronautguide \
--features=properties,eclipsestore,serialization-jackson,validation \
--build=gradle \
--lang=java \
--test=junit
Using EclipseStore persistence with Micronaut
Learn how to use EclipseStore as a high-performance persistence layer.
Authors: Tim Yates, Sergio del Amo
Micronaut Version: 4.6.3
1. Getting Started
In this guide, we will create a Micronaut application written in Java.
You will use EclipseStore 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 (e.g. IntelliJ IDEA)
-
JDK 17 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.
-
Download and unzip the source
4. Writing the Application
Create an application using the Micronaut Command Line Interface or with Micronaut Launch.
If you don’t specify the --build argument, Gradle with the Kotlin DSL is used as the build tool. If you don’t specify the --lang argument, Java is used as the language.If you don’t specify the --test argument, JUnit is used for Java and Kotlin, and Spock is used for Groovy.
|
The previous command creates a Micronaut application with the default package example.micronaut
in a directory named micronautguide
.
If you use Micronaut Launch, select Micronaut Application as application type and add properties
, eclipsestore
, serialization-jackson
, and validation
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 eclipsestore
features adds the following dependencies:
annotationProcessor("io.micronaut.eclipsestore:micronaut-eclipsestore-annotations")
implementation("io.micronaut.eclipsestore:micronaut-eclipsestore-annotations")
implementation("io.micronaut.eclipsestore:micronaut-eclipsestore")
4.2. Domain object
Create a Fruit
class which will be used as the domain object.
package example.micronaut;
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 Fruit {
@NonNull
@NotBlank (2)
private final String name;
@Nullable (3)
private String description;
public Fruit(@NonNull String name,
@Nullable String description) {
this.name = name;
this.description = description;
}
@NonNull
public String getName() {
return name;
}
@Nullable
public String getDescription() {
return description;
}
public void setDescription(@Nullable String description) {
this.description = description;
}
}
1 | Declare the @Serdeable annotation at the type level in your source code to allow the type to be serialized or deserialized. |
2 | Use jakarta.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
POJO which we will be used as the root of our object graph.
package example.micronaut;
import io.micronaut.core.annotation.NonNull;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public class FruitContainer {
@NonNull
private final Map<String, Fruit> fruits = new ConcurrentHashMap<>();
@NonNull
public Map<String, Fruit> getFruits() {
return fruits;
}
}
4.4. Configuration
Add the following snippet to application.properties
to configure EclipseStore.
eclipsestore.storage.main.root-class=example.micronaut.FruitContainer
eclipsestore.storage.main.storage-directory=build/fruit-storage
4.5. Command object
And a FruitCommand
class which will be used as the command object over HTTP.
package example.micronaut;
import io.micronaut.core.annotation.Creator;
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 FruitCommand {
@NonNull
@NotBlank (2)
private final String name;
@Nullable (3)
private final String description;
public FruitCommand(@NonNull String name) {
this(name, null);
}
@Creator
public FruitCommand(@NonNull String name,
@Nullable String description) {
this.name = name;
this.description = description;
}
@NonNull
public String getName() {
return name;
}
@Nullable
public String getDescription() {
return description;
}
}
1 | Declare the @Serdeable annotation at the type level in your source code to allow the type to be serialized or deserialized. |
2 | Use jakarta.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
.
package example.micronaut;
import io.micronaut.core.annotation.NonNull;
import io.micronaut.core.annotation.Nullable;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.util.Collection;
import jakarta.validation.Valid;
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.
package example.micronaut;
public class FruitDuplicateException extends RuntimeException{
public 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.
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)
public class FruitDuplicateExceptionHandler implements ExceptionHandler<FruitDuplicateException, HttpResponse<?>> {
private final ErrorResponseProcessor<?> errorResponseProcessor;
public FruitDuplicateExceptionHandler(ErrorResponseProcessor<?> errorResponseProcessor) {
this.errorResponseProcessor = errorResponseProcessor;
}
@Override
public 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
package example.micronaut;
import io.micronaut.core.annotation.NonNull;
import io.micronaut.core.annotation.Nullable;
import io.micronaut.eclipsestore.RootProvider;
import io.micronaut.eclipsestore.annotations.StoreParams;
import io.micronaut.eclipsestore.annotations.StoreReturn;
import jakarta.inject.Singleton;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.NotBlank;
import java.util.Collection;
import java.util.Map;
@Singleton (1)
public class FruitRepositoryImpl implements FruitRepository {
private final RootProvider<FruitContainer> rootProvider;
FruitRepositoryImpl(RootProvider<FruitContainer> rootProvider) { (2)
this.rootProvider = rootProvider;
}
@Override
@NonNull
public Collection<Fruit> list() {
return rootProvider.root().getFruits().values(); (3)
}
@Override
@NonNull
public Fruit create(@NonNull @NotNull @Valid FruitCommand fruit) throws FruitDuplicateException {
Map<String, Fruit> fruits = rootProvider.root().getFruits();
if (fruits.containsKey(fruit.getName())) {
throw new FruitDuplicateException(fruit.getName());
}
return performCreate(fruits, fruit);
}
@StoreParams("fruits") (4)
protected Fruit performCreate(Map<String, Fruit> fruits, FruitCommand fruit) {
Fruit newFruit = new Fruit(fruit.getName(), fruit.getDescription());
fruits.put(fruit.getName(), newFruit);
return newFruit;
}
@Nullable
public Fruit update(@NonNull @NotNull @Valid FruitCommand fruit) {
Map<String, Fruit> fruits = rootProvider.root().getFruits();
Fruit foundFruit = fruits.get(fruit.getName());
if (foundFruit != null) {
return performUpdate(foundFruit, fruit);
}
return null;
}
@StoreReturn (5)
protected Fruit performUpdate(@NonNull Fruit foundFruit, @NonNull FruitCommand fruit) {
foundFruit.setDescription(fruit.getDescription());
return foundFruit;
}
@Override
@Nullable
public Fruit find(@NonNull @NotBlank String name) {
return rootProvider.root().getFruits().get(name);
}
@Override
public void delete(@NonNull @NotNull @Valid FruitCommand fruit) {
performDelete(fruit);
}
@StoreReturn (5)
protected Map<String, Fruit> performDelete(FruitCommand fruit) {
if (rootProvider.root().getFruits().remove(fruit.getName()) != null) {
return rootProvider.root().getFruits();
}
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 EclipseStore. |
5 | With @StoreReturn , on successful completion of this method, the return value will be persisted in EclipseStore. |
4.9. Controller
Create FruitController
:
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 io.micronaut.scheduling.annotation.ExecuteOn;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import java.util.Collection;
import static io.micronaut.scheduling.TaskExecutors.BLOCKING;
@Controller("/fruits") (1)
class FruitController {
private final FruitRepository fruitRepository;
FruitController(FruitRepository fruitRepository) { (2)
this.fruitRepository = fruitRepository;
}
@Get (3)
Collection<Fruit> list() {
return fruitRepository.list();
}
@ExecuteOn(BLOCKING)
@Post (4)
@Status(HttpStatus.CREATED) (5)
Fruit create(@NonNull @NotNull @Valid @Body FruitCommand fruit) { (6)
return fruitRepository.create(fruit);
}
@ExecuteOn(BLOCKING)
@Put
Fruit update(@NonNull @NotNull @Valid @Body FruitCommand fruit) {
return fruitRepository.update(fruit);
}
@Get("/{name}") (7)
Fruit find(@PathVariable String name) {
return fruitRepository.find(name);
}
@ExecuteOn(BLOCKING)
@Delete
@Status(HttpStatus.NO_CONTENT)
void delete(@NonNull @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:
package example.micronaut;
import io.micronaut.test.extensions.junit5.annotation.MicronautTest;
import jakarta.inject.Inject;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;
import org.junit.jupiter.api.function.Executable;
import jakarta.validation.ConstraintViolationException;
import static org.junit.jupiter.api.Assertions.assertThrows;
@MicronautTest(startApplication = false) (1)
class FruitRepositoryTest {
@Inject
FruitRepository fruitRepository;
@Test
void methodsValidateParamers() {
Executable e = () -> fruitRepository.create(new FruitCommand(""));
assertThrows(ConstraintViolationException.class, e);
}
}
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
:
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.junit5.annotation.MicronautTest;
import jakarta.inject.Inject;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
@MicronautTest (1)
class FruitValidationControllerTest {
@Inject
@Client("/")
HttpClient httpClient; (2)
@Test
void fruitIsValidated() {
HttpClientResponseException exception = assertThrows(
HttpClientResponseException.class,
() -> httpClient.toBlocking().exchange(HttpRequest.POST("/fruits", new FruitCommand("", "")))
);
assertEquals(HttpStatus.BAD_REQUEST, exception.getStatus());
}
}
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.
package example.micronaut;
import io.micronaut.test.support.TestPropertyProvider;
import org.junit.jupiter.api.io.TempDir;
import jakarta.validation.constraints.NotNull;
import java.io.File;
import java.util.Collections;
import java.util.Map;
import io.micronaut.core.annotation.NonNull;
abstract class BaseTest implements TestPropertyProvider {
@TempDir
static File tempDir;
@Override
@NonNull
public Map<String, String> getProperties() {
return Collections.singletonMap(
"microstream.storage.main.storage-directory", tempDir.getAbsolutePath()
);
}
}
Create a test which validate FruitDuplicateExceptionHandler
.
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.junit5.annotation.MicronautTest;
import jakarta.inject.Inject;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
@MicronautTest (1)
@TestInstance(TestInstance.Lifecycle.PER_CLASS) (2)
class FruitDuplicationExceptionHandlerTest extends BaseTest {
@Inject
@Client("/")
HttpClient httpClient; (3)
@Test
void duplicatedFruitsReturns400() {
FruitCommand banana = new FruitCommand("Banana");
HttpRequest<?> request = HttpRequest.POST("/fruits", banana);
HttpResponse<?> response = httpClient.toBlocking().exchange(request);
assertEquals(HttpStatus.CREATED, response.status());
HttpClientResponseException exception = assertThrows(
HttpClientResponseException.class,
() -> httpClient.toBlocking().exchange(request)
);
assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, exception.getStatus());
HttpRequest<?> deleteRequest = HttpRequest.DELETE("/fruits", banana);
HttpResponse<?> deleteResponse = httpClient.toBlocking().exchange(deleteRequest);
assertEquals(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 | Classes that implement TestPropertyProvider must use this annotation to create a single class instance for all tests (not necessary in Spock tests). |
3 | 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.
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 jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.util.Optional;
@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 EclipseStore correctly:
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 org.junit.jupiter.api.Test;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import java.util.stream.StreamSupport;
import static org.junit.jupiter.api.Assertions.*;
class FruitControllerTest extends BaseTest {
@Test
void testInteractionWithTheController() {
FruitCommand apple = new FruitCommand("apple", "Keeps the doctor away");
String bananaName = "banana";
String bananaDescription = "Yellow and curved";
Map<String, Object> properties = new HashMap<>(getProperties());
try (EmbeddedServer embeddedServer = ApplicationContext.run(EmbeddedServer.class, properties)) { (1)
FruitClient fruitClient = embeddedServer.getApplicationContext().getBean(FruitClient.class);
HttpResponse<Fruit> response = fruitClient.create(new FruitCommand(bananaName));
assertEquals(HttpStatus.CREATED, response.getStatus());
assertTrue(response.getBody().isPresent());
Fruit banana = response.getBody().get();
List<Fruit> fruitList = fruitsList(fruitClient);
assertEquals(1, fruitList.size());
assertEquals(banana.getName(), fruitList.get(0).getName());
assertNull(fruitList.get(0).getDescription());
Optional<Fruit> bananaOptional = fruitClient.update(apple);
assertFalse(bananaOptional.isPresent());
response = fruitClient.create(apple);
assertEquals(HttpStatus.CREATED, response.getStatus());
assertTrue(fruitsStream(fruitClient)
.anyMatch(f -> "Keeps the doctor away".equals(f.getDescription())));
bananaOptional = fruitClient.update(new FruitCommand(bananaName, bananaDescription));
assertTrue(bananaOptional.isPresent());
assertEquals(
Stream.of("Keeps the doctor away", "Yellow and curved").collect(Collectors.toSet()),
fruitsStream(fruitClient)
.map(Fruit::getDescription)
.collect(Collectors.toSet())
);
}
try (EmbeddedServer embeddedServer = ApplicationContext.run(EmbeddedServer.class, properties)) { (1)
FruitClient fruitClient = embeddedServer.getApplicationContext().getBean(FruitClient.class);
assertEquals(2, numberOfFruits(fruitClient));
fruitClient.delete(apple);
fruitClient.delete(new FruitCommand(bananaName, bananaDescription));
}
try (EmbeddedServer embeddedServer = ApplicationContext.run(EmbeddedServer.class, properties)) { (1)
FruitClient fruitClient = embeddedServer.getApplicationContext().getBean(FruitClient.class);
assertEquals(0, numberOfFruits(fruitClient));
}
}
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 EclipseStore and can be retrieved after application restart. |
5. Testing the Application
To run the tests:
./gradlew test
Then open build/reports/tests/test/index.html
in a browser to see the results.
6. Running the Application
To run the application, use the ./gradlew run
command, which starts the application on port 8080.
curl -i -d '{"name":"Pear"}' \
-H "Content-Type: application/json" \
-X POST http://localhost:8080/fruits
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"}
curl -i localhost:8080/fruits
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. EclipseStore REST and GUI
Often, during development is useful to see the data being saved by EclipseStore. Micronaut EclipseStore integration helps to do that.
Add the following dependency:
developmentOnly("io.micronaut.eclipsestore:micronaut-eclipsestore-rest")
The above dependency provides several JSON endpoints which expose the contents of the EclipseStore storage.
We need to enable Micronaut EclipseStore Rest endpoints via configuration. For security, it is disabled by default. We will enable only in the dev
environment.
7.1. Dev default environment
Modify Application
to use dev
as a default environment.
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);
}
}
8. Development Environment Configuration
Create src/main/resources/application-dev.properties
.
The Micronaut framework applies this configuration file only for the dev
environment.
eclipsestore.rest.enabled=true
8.1. EclipseStore Client GUI
Run the client and connect to the EclipseStore REST API exposed by the Micronaut application:
You can visualize the data you saved via cURL.
9. Next steps
Explore more features with Micronaut Guides.
Read more about:
10. License
All guides are released with an Apache license 2.0 license for the code and a Creative Commons Attribution 4.0 license for the writing and media (images…). |