Deploy a Micronaut Microservices Application to a Local Kubernetes Cluster

This guide shows how to use the Graal Development Kit for Micronaut (GDK) to create an application consisting of three microservices, build containerized versions of the microservices, and deploy them with Kubernetes. Thanks to the Micronaut® Kubernetes module, you can connect the microservices using Kubernetes Service Discovery and Distributed Configuration.

Kubernetes is a portable, extensible, open source platform for managing containerized workloads and services, that facilitates both declarative configuration and automation. It has a large, rapidly growing ecosystem. Kubernetes services, support, and tools are widely available.

Prerequisites #

A note regarding your development environment

Consider using Visual Studio Code, which provides native support for developing applications with the Graal Development Kit extension.

Note: If you use IntelliJ IDEA, enable annotation processing.

Follow the steps below to create the application from scratch. However, you can also download the completed example:

Note on the Sample Application #

The application consists of three microservices:

  • users - contains customer data that can place orders on items, also a new customer can be created. It requires HTTP basic authentication to access it.
  • orders - contains all orders that customers have created as well as available items that customers can order. This microservice also enables the creation of new orders. It requires HTTP basic authentication to access it.
  • api - acts as a gateway to the orders and users services. It combines results from both services and checks data when a customer creates a new order.

Initially, the URLs of the orders and users services will be included in the code for the api microservice. Additionally, you will hard-code the credentials (username and password) into every microservice configuration that are required for HTTP basic authentication.

The second part of this guide describes how to use Kubernetes Discovery Service and Kubernetes Configuration Maps to dynamically resolve the URLs of the orders and users microservices and retrieve authentication credentials. The microservices call the Kubernetes API to register when they start up and then resolve placeholders inside the microservices’ configurations.

Windows platform: The GDK guides are compatible with Gradle only. Maven support is coming soon.

1. Create the Users Microservice #

Create the users microservice using the GDK Launcher.

  1. Open the GDK Launcher in advanced mode.

  2. Create a new project using the following selections.
    • Project Type: Application (Default)
    • Project Name: users
    • Base Package: com.example (Default)
    • Clouds: None
    • Build Tool: Gradle (Groovy) or Maven
    • Language: Java (Default)
    • Test Framework: JUnit (Default)
    • Java Version: 17 (Default)
    • Micronaut Version: (Default)
    • Cloud Services: Kubernetes
    • Features: GraalVM Native Image, Kubernetes Service Discovery, Kubernetes Support, Micronaut Management, Micronaut Security, and Micronaut Serialization Jackson Core
    • Sample Code: No
  3. Click Generate Project, then click Download Zip. The GDK Launcher creates a directory named users containing an application with a package named com.example. The application ZIP file will be downloaded to your default downloads directory. Unzip it, open it in your code editor, and proceed to the next steps.

Alternatively, use the GDK CLI as follows:

gdk create-app com.example.users \
    --services=k8s \
    --features=discovery-kubernetes,management,security,serialization-jackson,kubernetes,graalvm \
    --example-code=false \
    --build=gradle \
    --jdk=17 \
    --lang=java
gdk create-app com.example.users \
    --services=k8s \
    --features=discovery-kubernetes,management,security,serialization-jackson,kubernetes,graalvm \
    --example-code=false \
    --build=maven \
    --jdk=17 \
    --lang=java

1.1. Controllers in the Users Microservice #

  1. Create a UsersController class to handle incoming HTTP requests for the users microservice in a file named users/src/main/java/com/example/controllers/UsersController.java:

     package com.example.controllers;
    
     import com.example.models.User;
     import io.micronaut.http.HttpStatus;
     import io.micronaut.http.annotation.Body;
     import io.micronaut.http.annotation.Controller;
     import io.micronaut.http.annotation.Get;
     import io.micronaut.http.annotation.Post;
     import io.micronaut.http.exceptions.HttpStatusException;
     import io.micronaut.security.annotation.Secured;
     import io.micronaut.security.rules.SecurityRule;
     import io.micronaut.validation.Validated;
     import jakarta.validation.Valid;
     import jakarta.validation.constraints.NotNull;
    
     import java.util.ArrayList;
     import java.util.List;
     import java.util.Optional;
    
     @Controller("/users") // <1>
     @Secured(SecurityRule.IS_AUTHENTICATED) // <2>
     @Validated
     class UsersController {
         List<User> persons = new ArrayList<>();
    
         @Post // <3>
         public User add(@Body @Valid User user) {
             Optional<User> existingUser = findByUsername(user.username());
    
             if (existingUser.isPresent()) {
                 throw new HttpStatusException(HttpStatus.CONFLICT, "User with provided username already exists");
             }
    
             User newUser = new User(persons.size() + 1, user.firstName(), user.lastName(), user.username());
             persons.add(newUser);
             return newUser;
         }
    
         @Get("/{id}") // <4>
         public User findById(int id) {
             return persons.stream()
                     .filter(it -> it.id().equals(id))
                     .findFirst().orElse(null);
         }
    
         @Get // <5>
         public List<User> getUsers() {
             return persons;
         }
    
         Optional<User> findByUsername(@NotNull String username) {
             return persons.stream()
                     .filter(it -> it.username().equals(username))
                     .findFirst();
         }
    
     }
    

    1 The class is defined as a controller with the @Controller annotation mapped to the path /users.

    2 Annotate with io.micronaut.security.Secured to configure secured access. The isAuthenticated() expression will allow access only to authenticated users.

    3 The @Post annotation maps the add method to an HTTP POST request on /users.

    4 The @Get annotation maps the findById method to an HTTP GET request on /users/{id}.

    5 The @Get annotation maps the getUsers method to an HTTP GET request on /users.

  2. Create a User record to represent the customer in a file named users/src/main/java/com/example/models/User.java, as follows:

     package com.example.models;
    
     import com.fasterxml.jackson.annotation.JsonProperty;
     import io.micronaut.core.annotation.Nullable;
     import io.micronaut.serde.annotation.Serdeable;
    
     import jakarta.validation.constraints.Max;
     import jakarta.validation.constraints.NotBlank;
    
     @Serdeable // <1>
     public record User(
             @Nullable @Max(10000) Integer id, // <2>
             @NotBlank @JsonProperty("first_name") String firstName,
             @NotBlank @JsonProperty("last_name") String lastName,
             String username
     ) {
     }
    

    1 Declare the @Serdeable annotation at the type level to enable the type to be serialized or deserialized.

    2 The ID will be generated by the application.

  3. Create a Credentials class in a file named users/src/main/java/com/example/auth/Credentials.java, which will load and store credentials (username and password) from a configuration file:

     package com.example.auth;
    
     import io.micronaut.context.annotation.ConfigurationProperties;
    
     @ConfigurationProperties("authentication-credentials") // <1>
     public record Credentials (String username, String password) {}
    

    1 The @ConfigurationProperties annotation takes the configuration prefix.

  4. Create a CredentialsChecker class in a file named users/src/main/java/com/example/auth/CredentialsChecker.java, which, as the name suggests, will check if the provided credentials inside the HTTP request’s Authorization header are the same as those that are stored inside the Credentials class created above:

     package com.example.auth;
    
     import io.micronaut.core.annotation.Nullable;
     import io.micronaut.http.HttpRequest;
     import io.micronaut.security.authentication.AuthenticationProvider;
     import io.micronaut.security.authentication.AuthenticationRequest;
     import io.micronaut.security.authentication.AuthenticationResponse;
     import jakarta.inject.Singleton;
     import org.reactivestreams.Publisher;
     import reactor.core.publisher.Mono;
    
     @Singleton // <1>
     class CredentialsChecker implements AuthenticationProvider<HttpRequest<?>> {
    
         private final Credentials credentials;
    
         CredentialsChecker(Credentials credentials) {
             this.credentials = credentials;
         }
    
         @Override
         public Publisher<AuthenticationResponse> authenticate(@Nullable HttpRequest<?> httpRequest,
                                                               AuthenticationRequest<?, ?> authenticationRequest) {
             return Mono.<AuthenticationResponse>create(emitter -> {
                 if ( authenticationRequest.getIdentity().equals(credentials.username()) &&
                         authenticationRequest.getSecret().equals(credentials.password()) ) {
                     emitter.success(AuthenticationResponse.success((String) authenticationRequest.getIdentity()));
                 } else {
                     emitter.error(AuthenticationResponse.exception());
                 }
             });
         }
     }
    

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

1.2. Tests to Verify Application Logic #

  1. Create a UsersClient Micronaut HTTP inline client for testing in a file named users/src/test/java/com/example/UsersClient.java, as follows:

     package com.example;
    
     import com.example.models.User;
     import io.micronaut.http.annotation.Body;
     import io.micronaut.http.annotation.Get;
     import io.micronaut.http.annotation.Header;
     import io.micronaut.http.annotation.Post;
     import io.micronaut.http.client.annotation.Client;
    
     import java.util.List;
    
     @Client("/") // <1>
     public interface UsersClient {
    
         @Get("/users/{id}")
         User getById(@Header String authorization, int id);
    
         @Post("/users")
         User createUser(@Header String authorization, @Body User user);
    
         @Get("/users")
         List<User> getUsers(@Header String authorization);
     }
    

    1 Use @Client to use declarative HTTP Clients. You can annotate interfaces or abstract classes. You can use the id member to provide a service identifier or specify the URL directly as the annotation’s value.

  2. Create a HealthTest test that checks there is /health endpoint (required for service discovery) in a file named users/src/test/java/com/example/HealthTest.java with the following contents:

     package com.example;
    
     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.test.extensions.junit5.annotation.MicronautTest;
     import org.junit.jupiter.api.Test;
    
     import jakarta.inject.Inject;
    
     import static org.junit.jupiter.api.Assertions.assertEquals;
    
     @MicronautTest // <1>
     class HealthTest {
    
         @Inject
         @Client("/")
         HttpClient client; // <2>
    
         @Test
         public void healthEndpointExposed() {
             HttpStatus status = client.toBlocking().retrieve(HttpRequest.GET("/health"), HttpStatus.class);
             assertEquals(HttpStatus.OK, status);
         }
     }
    

    1 Annotate the class with @MicronautTest so the Micronaut framework will initialize the application context and the embedded server. (For more information , see Micronaut Test.)

    2 Inject the HttpClient bean and point it to the embedded server.

  3. Create a UsersControllerTest class to test endpoints inside the UserController in a file named users/src/test/java/com/example/UsersControllerTest.java with the following contents:

     package com.example;
    
     import com.example.auth.Credentials;
     import com.example.models.User;
     import io.micronaut.http.HttpStatus;
     import io.micronaut.http.client.exceptions.HttpClientException;
     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 java.util.Base64;
     import java.util.List;
    
     import static org.junit.jupiter.api.Assertions.assertEquals;
     import static org.junit.jupiter.api.Assertions.assertNotEquals;
     import static org.junit.jupiter.api.Assertions.assertNotNull;
     import static org.junit.jupiter.api.Assertions.assertNull;
     import static org.junit.jupiter.api.Assertions.assertThrows;
     import static org.junit.jupiter.api.Assertions.assertTrue;
    
     @MicronautTest // <1>
     class UsersControllerTest {
    
         @Inject
         UsersClient usersClient;
    
         @Inject
         Credentials credentials;
    
         @Test
         void testUnauthorized() {
             HttpClientException exception = assertThrows(HttpClientException.class, () -> usersClient.getUsers(""));
             assertTrue(exception.getMessage().contains("Unauthorized"));
         }
    
         @Test
         void getUserThatDoesntExists() {
             String authHeader = "Basic " + Base64.getEncoder().encodeToString((credentials.username() + ":" + credentials.password()).getBytes());
             User retriedUser = usersClient.getById(authHeader, 100);
             assertNull(retriedUser);
         }
    
         @Test
         void multipleUserInteraction() {
             String authHeader = "Basic " + Base64.getEncoder().encodeToString((credentials.username() + ":" + credentials.password()).getBytes());
    
             String firstName = "firstName";
             String lastName = "lastName";
             String username = "username";
    
             User user = new User(0 ,firstName, lastName, username);
    
             User createdUser = usersClient.createUser(authHeader, user);
    
             assertEquals(firstName, createdUser.firstName());
             assertEquals(lastName, createdUser.lastName());
             assertEquals(username, createdUser.username());
             assertNotNull(createdUser.id());
    
             User retriedUser = usersClient.getById(authHeader, createdUser.id());
    
             assertEquals(firstName, retriedUser.firstName());
             assertEquals(lastName, retriedUser.lastName());
             assertEquals(username, retriedUser.username());
    
             List<User> users = usersClient.getUsers(authHeader);
             assertNotNull(users);
             assertTrue(users.stream()
                     .map(User::username)
                     .anyMatch(name -> name.equals(username)));
         }
    
         @Test
         void createSameUserTwice() {
             String authHeader = "Basic " + Base64.getEncoder().encodeToString((credentials.username() + ":" + credentials.password()).getBytes());
    
             String firstName = "SameUserFirstName";
             String lastName = "SameUserLastName";
             String username = "SameUserUsername";
    
             User user = new User(0 ,firstName, lastName, username);
    
             User createdUser = usersClient.createUser(authHeader, user);
    
             assertEquals(firstName, createdUser.firstName());
             assertEquals(lastName, createdUser.lastName());
             assertEquals(username, createdUser.username());
             assertNotNull(createdUser.id());
             assertNotEquals(createdUser.id(), 0);
    
             HttpClientResponseException exception = assertThrows(HttpClientResponseException.class, () -> usersClient.createUser(authHeader, user));
             assertEquals(HttpStatus.CONFLICT, exception.getStatus());
             assertTrue(exception.getResponse().getBody(String.class).orElse("").contains("User with provided username already exists"));
    
         }
     }
    

    1 Annotate the class with @MicronautTest so the Micronaut framework will initialize the application context and the embedded server. (For more information, see Micronaut Test.)

  4. Edit the users/src/main/resources/application-k8s.properties file as follows:

     # <1>
     authentication-credentials.username=${username}
     # <2>
     authentication-credentials.password=${password}
    

    1 Placeholder for a username that will be populated by Kubernetes.

    2 Placeholder for a password that will be populated by Kubernetes.

  5. Edit the users/src/main/resources/bootstrap-k8s.properties file to enable distributed configuration. Modify the contents so that it matches the following:

     micronaut.application.name=users
     # <1>
     micronaut.config-client.enabled=true
     # <2>
     kubernetes.client.secrets.enabled=true
     # <3>
     kubernetes.client.secrets.use-api=true
    

    1 Set micronaut.config-client.enabled: true to read and resolve configuration from distributed sources.

    2 Set kubernetes.client.secrets.enabled: true to enable Kubernetes secrets as distributed source.

    3 Set kubernetes.client.secrets.use-api: true to use the Kubernetes API to fetch the configuration.

  6. Create a file named users/src/main/resources/application-dev.properties. This configuration file is applied only for the dev environment.

     # <1>
     micronaut.server.port=8081
     # <2>
     authentication-credentials.username=gdkdemo
     # <3>
     authentication-credentials.password=example-password
    

    1 Configure the application to listen on port 8081.

    2 Username for the development environment.

    3 Password for the development environment.

  7. Create a file named users/src/main/resources/bootstrap-dev.properties to disable distributed configuration in the dev environment:

     # <1>
     kubernetes.client.secrets.enabled=false
    

    1 Disable the Kubernetes secrets client.

  8. Create a file named users/src/test/resources/application-test.properties for use in the test environment:

     # <1>
     authentication-credentials.username=gdkdemo
     # <2>
     authentication-credentials.password=example-password
    

    1 Username for the test environment.

    2 Password for the test environment.

  9. Run the unit test, as follows:

    ./gradlew test
    ./mvnw test

1.3. Run the Users Microservice #

Run the users microservice as follows:

MICRONAUT_ENVIRONMENTS=dev ./gradlew run
Or if you use Windows:
cmd /C "set MICRONAUT_ENVIRONMENTS=dev && gradlew run"
MICRONAUT_ENVIRONMENTS=dev ./mvnw mn:run
Or if you use Windows:
cmd /C "set MICRONAUT_ENVIRONMENTS=dev && mvnw mn:run""

2. Create the Orders Microservice #

Create the orders microservice using the GDK Launcher.

  1. Open the GDK Launcher in advanced mode.

  2. Create a new project using the following selections.
    • Project Type: Application (Default)
    • Project Name: orders
    • Base Package: com.example (Default)
    • Clouds: None
    • Build Tool: Gradle (Groovy) or Maven
    • Language: Java (Default)
    • Test Framework: JUnit (Default)
    • Java Version: 17 (Default)
    • Micronaut Version: (Default)
    • Cloud Services: Kubernetes
    • Features: GraalVM Native Image, Kubernetes Service Discovery, Kubernetes Support, Micronaut Management, Micronaut Security, and Micronaut Serialization Jackson Core
    • Sample Code: No
  3. Click Generate Project, then click Download Zip. The GDK Launcher creates a directory named orders containing a Micronaut application with a package named com.example. The application ZIP file will be downloaded to your default downloads directory. Unzip it, open it in your code editor, and proceed to the next steps.

Alternatively, use the GDK CLI as follows:

gdk create-app com.example.orders \
    --services=k8s \
    --features=discovery-kubernetes,management,security,serialization-jackson,kubernetes,graalvm \
    --example-code=false \
    --build=gradle \
    --jdk=17 \
    --lang=java
gdk create-app com.example.orders \
    --services=k8s \
    --features=discovery-kubernetes,management,security,serialization-jackson,kubernetes,graalvm \
    --example-code=false \
    --build=maven \
    --jdk=17 \
    --lang=java

2.1. Controllers in the Orders Microservice #

  1. Create the OrdersController and ItemsController classes to handle incoming HTTP requests to the orders microservice. OrdersController is in the orders/src/main/java/com/example/controllers/OrdersController.java file:

     package com.example.controllers;
    
     import com.example.models.Item;
     import com.example.models.Order;
     import io.micronaut.http.HttpStatus;
     import io.micronaut.http.annotation.Body;
     import io.micronaut.http.annotation.Controller;
     import io.micronaut.http.annotation.Get;
     import io.micronaut.http.annotation.Post;
     import io.micronaut.http.exceptions.HttpStatusException;
     import io.micronaut.security.annotation.Secured;
     import io.micronaut.security.rules.SecurityRule;
     import jakarta.validation.Valid;
    
     import java.math.BigDecimal;
     import java.util.ArrayList;
     import java.util.List;
     import java.util.stream.Collectors;
    
     @Controller("/orders")  // <1>
     @Secured(SecurityRule.IS_AUTHENTICATED) // <2>
     class OrdersController {
    
         private final List<Order> orders = new ArrayList<>();
    
         @Get("/{id}")  // <3>
         public Order findById(int id) {
             return orders.stream()
                     .filter(it -> it.id().equals(id))
                     .findFirst().orElse(null);
         }
    
         @Get  // <4>
         public List<Order> getOrders() {
             return orders;
         }
    
         @Post  // <5>
         public Order createOrder(@Body @Valid Order order) {
             if (order.itemIds() == null || order.itemIds().isEmpty()) {
                 throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Items must be supplied");
             }
    
             List<Item> items = order.itemIds().stream().map(
                     x -> Item.items.stream().filter(
                             y -> y.id().equals(x)
                     ).findFirst().orElseThrow(
                             () -> new HttpStatusException(HttpStatus.BAD_REQUEST, String.format("Item with id %s doesn't exist", x))
                     )
             ).collect(Collectors.toList());
    
             BigDecimal total = items.stream().map(Item::price).reduce(BigDecimal::add).orElse(new BigDecimal("0"));
             Order newOrder = new Order(orders.size() + 1, order.userId(), items, null, total);
    
             orders.add(newOrder);
             return newOrder;
         }
    
     }
    

    1 The class is defined as a controller with the @Controller annotation mapped to the path /orders.

    2 Annotate with io.micronaut.security.Secured to configure secured access. The isAuthenticated() expression will allow access only to authenticated users.

    3 The @Get annotation maps the findById method to an HTTP GET request on /orders/{id}.

    4 The @Get annotation maps the getOrders method to an HTTP GET request on /orders.

    5 The @Post annotation maps the createOrder method to an HTTP POST request on /orders.

  2. Create the ItemsController class in a file named orders/src/main/java/com/example/controllers/ItemsController.java, as follows:

     package com.example.controllers;
    
     import com.example.models.Item;
     import io.micronaut.http.annotation.Controller;
     import io.micronaut.http.annotation.Get;
     import io.micronaut.security.annotation.Secured;
     import io.micronaut.security.rules.SecurityRule;
    
     import java.util.List;
    
     @Controller("/items")  // <1>
     @Secured(SecurityRule.IS_AUTHENTICATED)  // <2>
     class ItemsController {
    
         @Get("/{id}")  // <3>
         public Item findById(int id) {
             return Item.items.stream()
                     .filter(it -> it.id().equals(id))
                     .findFirst().orElse(null);
         }
    
         @Get  // <4>
         public List<Item> getItems() {
             return Item.items;
         }
    
     }
    

    1 The class is defined as a controller with the @Controller annotation mapped to the path /items.

    2 Annotate with io.micronaut.security.Secured to configure secured access. The isAuthenticated() expression will allow access only to authenticated users.

    3 The @Get annotation maps the findById method to an HTTP GET request on /items/{id}.

    4 The @Get annotation maps the getItems method to an HTTP GET request on /items.

  3. Create the Order and Item objects, used by the OrdersController and ItemsController classes, to represent customer orders. Order is in the orders/src/main/java/com/example/models/Order.java file:

     package com.example.models;
    
     import com.fasterxml.jackson.annotation.JsonProperty;
     import io.micronaut.core.annotation.Nullable;
     import io.micronaut.serde.annotation.Serdeable;
     import jakarta.validation.constraints.Max;
     import jakarta.validation.constraints.NotBlank;
    
     import java.math.BigDecimal;
     import java.util.List;
    
     @Serdeable // <1>
     public record Order(
             @Max(10000) @Nullable Integer id, // <2>
             @NotBlank @JsonProperty("user_id") Integer userId,
             @Nullable List<Item> items, // <3>
             @NotBlank @JsonProperty("item_ids") @Nullable List<Integer> itemIds, // <4>
             @Nullable BigDecimal total
     ) {
     }
    

    1 Declare the @Serdeable annotation at the type level in your source code to allow the type to be serialized or deserialized.

    2 The ID will be generated by application.

    3 The List of Item class will be populated by the server and will be only visible in sever responses.

    4 List of itemIds will be provided by client requests.

  4. The Item record is in the orders/src/main/java/com/example/models/Item.java file:

     package com.example.models;
    
     import io.micronaut.serde.annotation.Serdeable;
    
     import java.math.BigDecimal;
     import java.util.List;
    
     @Serdeable // <1>
     public record Item(
             Integer id,
             String name,
             BigDecimal price
     ) {
         public static List<Item> items = List.of(
                 new Item(1, "Banana", new BigDecimal("1.5")),
                 new Item(2, "Kiwi", new BigDecimal("2.5")),
                 new Item(3, "Grape", new BigDecimal("1.25"))
             );
     }
    

    1 Declare the @Serdeable annotation at the type level in your source code to allow the type to be serialized or deserialized.

  5. Create the Credentials class to load and store credentials (username and password) from configuration files in a file named orders/src/main/java/com/example/auth/Credentials.java with the following contents:

     package com.example.auth;
    
     import io.micronaut.context.annotation.ConfigurationProperties;
    
     @ConfigurationProperties("authentication-credentials") // <1>
     public record Credentials (String username, String password) {}
    

    1 The @ConfigurationProperties annotation takes the configuration prefix.

  6. Create a CredentialsChecker class in a file named orders/src/main/java/com/example/auth/CredentialsChecker.java, which, as the name suggests, will check if the provided credentials inside the HTTP request’s Authorization header are the same as those that are stored inside the Credentials class created above:

     package com.example.auth;
    
     import io.micronaut.core.annotation.Nullable;
     import io.micronaut.http.HttpRequest;
     import io.micronaut.security.authentication.AuthenticationProvider;
     import io.micronaut.security.authentication.AuthenticationRequest;
     import io.micronaut.security.authentication.AuthenticationResponse;
     import jakarta.inject.Singleton;
     import org.reactivestreams.Publisher;
     import reactor.core.publisher.Mono;
    
     @Singleton // <1>
     class CredentialsChecker implements AuthenticationProvider<HttpRequest<?>> {
    
         private final Credentials credentials;
    
         CredentialsChecker(Credentials credentials) {
             this.credentials = credentials;
         }
    
         @Override
         public Publisher<AuthenticationResponse> authenticate(@Nullable HttpRequest<?> httpRequest,
                                                               AuthenticationRequest<?, ?> authenticationRequest) {
             return Mono.<AuthenticationResponse>create(emitter -> {
                 if ( authenticationRequest.getIdentity().equals(credentials.username()) &&
                         authenticationRequest.getSecret().equals(credentials.password()) ) {
                     emitter.success(AuthenticationResponse.success((String) authenticationRequest.getIdentity()));
                 } else {
                     emitter.error(AuthenticationResponse.exception());
                 }
             });
         }
     }
    

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

2.2. Tests to Verify Application Logic #

  1. Create an OrderItemClient Micronaut HTTP inline client for testing in a file named orders/src/test/java/com/example/OrderItemClient.java, as follows:

     package com.example;
    
     import com.example.models.Item;
     import com.example.models.Order;
     import io.micronaut.http.annotation.Body;
     import io.micronaut.http.annotation.Get;
     import io.micronaut.http.annotation.Header;
     import io.micronaut.http.annotation.Post;
     import io.micronaut.http.client.annotation.Client;
    
     import java.util.List;
    
     @Client("/") // <1>
     interface OrderItemClient {
    
         @Get("/orders/{id}")
         Order getOrderById(@Header String authorization, int id);
    
         @Post("/orders")
         Order createOrder(@Header String authorization, @Body Order order);
    
         @Get("/orders")
         List<Order> getOrders(@Header String authorization);
    
         @Get("/items")
         List<Item> getItems(@Header String authorization);
    
         @Get("/items/{id}")
         Item getItemsById(@Header String authorization, int id);
     }
    

    1 Use @Client to use declarative HTTP Clients. You can annotate interfaces or abstract classes. You can use the id member to provide a service identifier or specify the URL directly as the annotation’s value.

  2. Create a HealthTest class, in a file named orders/src/test/java/com/example/HealthTest.java, that checks if there is /health endpoint (required for service discovery), with the following contents:

     package com.example;
    
     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.test.extensions.junit5.annotation.MicronautTest;
     import org.junit.jupiter.api.Test;
    
     import jakarta.inject.Inject;
    
     import static org.junit.jupiter.api.Assertions.assertEquals;
    
     @MicronautTest // <1>
     class HealthTest {
    
         @Inject
         @Client("/")
         HttpClient client; // <2>
    
         @Test
         public void healthEndpointExposed() {
             HttpStatus status = client.toBlocking().retrieve(HttpRequest.GET("/health"), HttpStatus.class);
             assertEquals(HttpStatus.OK, status);
         }
     }
    

    1 Annotate the class with @MicronautTest so the Micronaut framework will initialize the application context and the embedded server. (For more information , see Micronaut Test.)

    2 Inject the HttpClient bean and point it to the embedded server.

  3. Create a ItemsControllerTest class, in the file named orders/src/test/java/com/example/ItemsControllerTest.java, to test endpoints inside the ItemController, with the following contents:

     package com.example;
    
     import com.example.auth.Credentials;
     import com.example.models.Item;
     import io.micronaut.http.client.exceptions.HttpClientException;
     import io.micronaut.test.extensions.junit5.annotation.MicronautTest;
     import jakarta.inject.Inject;
     import org.junit.jupiter.api.Test;
    
     import java.math.BigDecimal;
     import java.util.Base64;
     import java.util.List;
    
     import static org.junit.jupiter.api.Assertions.assertEquals;
     import static org.junit.jupiter.api.Assertions.assertNotNull;
     import static org.junit.jupiter.api.Assertions.assertThrows;
     import static org.junit.jupiter.api.Assertions.assertTrue;
    
     @MicronautTest // <1>
     class ItemsControllerTest {
    
         @Inject
         OrderItemClient orderItemClient;
    
         @Inject
         Credentials credentials;
    
         @Test
         void testUnauthorized() {
             HttpClientException exception = assertThrows(HttpClientException.class, () -> orderItemClient.getItems(""));
             assertTrue(exception.getMessage().contains("Unauthorized"));
         }
    
         @Test
         void getItem() {
    
             int itemId = 1;
    
             String authHeader = "Basic " + Base64.getEncoder().encodeToString((credentials.username() + ":" + credentials.password()).getBytes());
    
             Item item = orderItemClient.getItemsById(authHeader, itemId);
    
             assertEquals(itemId, item.id());
             assertEquals("Banana", item.name());
             assertEquals(new BigDecimal("1.5"), item.price());
    
         }
    
         @Test
         void getItems() {
             String authHeader = "Basic " + Base64.getEncoder().encodeToString((credentials.username() + ":" + credentials.password()).getBytes());
    
             List<Item> items = orderItemClient.getItems(authHeader);
    
             assertNotNull(items);
             List<String> existingItemNames = List.of("Kiwi", "Banana", "Grape");
             assertEquals(3, items.size());
             assertTrue(items.stream()
                     .map(Item::name)
                     .allMatch(name -> existingItemNames.stream().anyMatch(x -> x.equals(name))));
         }
    
     }
    

    1 Annotate the class with @MicronautTest so the Micronaut framework will initialize the application context and the embedded server. (For more information, see Micronaut Test.)

  4. Create an OrdersControllerTest class to test endpoints inside the OrdersController in a file named orders/src/test/java/com/example/OrdersControllerTest.java

     package com.example;
    
     import com.example.auth.Credentials;
     import com.example.models.Order;
     import io.micronaut.http.HttpStatus;
     import io.micronaut.http.client.exceptions.HttpClientException;
     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 java.math.BigDecimal;
     import java.util.Base64;
     import java.util.List;
    
     import static org.junit.jupiter.api.Assertions.assertEquals;
     import static org.junit.jupiter.api.Assertions.assertNotNull;
     import static org.junit.jupiter.api.Assertions.assertThrows;
     import static org.junit.jupiter.api.Assertions.assertTrue;
    
     @MicronautTest // <1>
     class OrdersControllerTest {
    
         @Inject
         OrderItemClient orderItemClient;
    
         @Inject
         Credentials credentials;
    
         @Test
         void testUnauthorized() {
             HttpClientException exception = assertThrows(HttpClientException.class, () -> orderItemClient.getOrders(""));
             assertTrue(exception.getMessage().contains("Unauthorized"));
         }
    
         @Test
         void multipleOrderInteraction() {
             String authHeader = "Basic " + Base64.getEncoder().encodeToString((credentials.username() + ":" + credentials.password()).getBytes());
    
             int userId = 1;
             List<Integer> itemIds = List.of(1, 1, 2, 3);
    
             Order order = new Order(0, userId, null, itemIds, null);
    
             Order createdOrder = orderItemClient.createOrder(authHeader, order);
    
             assertNotNull(createdOrder.items());
    
             assertEquals(4, createdOrder.items().size());
             assertEquals(new BigDecimal("6.75"), createdOrder.total());
             assertEquals(userId, createdOrder.userId());
    
             Order retrievedOrder = orderItemClient.getOrderById(authHeader, createdOrder.id());
    
             assertNotNull(retrievedOrder.items());
    
             assertEquals(4, retrievedOrder.items().size());
             assertEquals(new BigDecimal("6.75"), retrievedOrder.total());
             assertEquals(userId, retrievedOrder.userId());
    
             List<Order> orders = orderItemClient.getOrders(authHeader);
    
             assertNotNull(orders);
             assertTrue(orders.stream()
                     .map(Order::userId)
                     .anyMatch(id -> id.equals(userId)));
    
         }
    
         @Test
         void itemDoesntExists() {
             String authHeader = "Basic " + Base64.getEncoder().encodeToString((credentials.username() + ":" + credentials.password()).getBytes());
    
             int userId = 1;
             List<Integer> itemIds = List.of(5);
    
             Order order = new Order(0, userId, null, itemIds, null);
    
             HttpClientResponseException exception = assertThrows(HttpClientResponseException.class, () -> orderItemClient.createOrder(authHeader, order));
    
             assertEquals(HttpStatus.BAD_REQUEST, exception.getStatus());
             assertTrue(exception.getResponse().getBody(String.class).orElse("").contains("Item with id 5 doesn't exist"));
         }
    
         @Test
         void orderEmptyItems() {
             String authHeader = "Basic " + Base64.getEncoder().encodeToString((credentials.username() + ":" + credentials.password()).getBytes());
    
             int userId = 1;
             Order order = new Order(0, userId, null, null, null);
             HttpClientResponseException exception = assertThrows(HttpClientResponseException.class, () -> orderItemClient.createOrder(authHeader, order));
    
             assertEquals(HttpStatus.BAD_REQUEST, exception.getStatus());
             assertTrue(exception.getResponse().getBody(String.class).orElse("").contains("Items must be supplied"));
         }
    
     }
    

    1 Annotate the class with @MicronautTest so the Micronaut framework will initialize the application context and the embedded server. (For more information, see Micronaut Test.)

  5. Edit the orders/src/main/resources/application-k8s.properties file as follows:

     # <1>
     authentication-credentials.username=${username}
     # <2>
     authentication-credentials.password=${password}
    

    1 Placeholder for a username that will be populated by Kubernetes.

    2 Placeholder for a password that will be populated by Kubernetes.

  6. Edit the orders/src/main/resources/bootstrap-k8s.properties file to enable distributed configuration. Modify the contents so that it matches the following:

     micronaut.application.name=orders
     # <1>
     micronaut.config-client.enabled=true
     # <2>
     kubernetes.client.secrets.enabled=true
     # <3>
     kubernetes.client.secrets.use-api=true
    

    1 Set micronaut.config-client.enabled: true to read and resolve configuration from distributed sources.

    2 Set kubernetes.client.secrets.enabled: true to enable Kubernetes secrets as distributed source.

    3 Set kubernetes.client.secrets.use-api: true to use Kubernetes API to fetch configuration.

  7. Edit the orders/src/main/resources/application-dev.properties file, which is applied only for the dev environment, as follows:

     # <1>
     micronaut.server.port=8082
     # <2>
     authentication-credentials.username=gdkdemo
     # <3>
     authentication-credentials.password=example-password
    

    1 Configure the application to listen on port 8082.

    2 Username for development environment.

    3 Password for development environment.

  8. Create a file named orders/src/main/resources/bootstrap-dev.properties to disable distributed configuration in the dev environment:

     # <1>
     kubernetes.client.secrets.enabled=false
    

    1 Disable the Kubernetes secrets client.

  9. Create a file named orders/src/test/resources/application-test.properties (to be used in the test environment) with the following contents:

     micronaut.application.name=orders
     # <1>
     authentication-credentials.username=gdkdemo
     # <2>
     authentication-credentials.password=example-password
    

    1 Username for development environment.

    2 Password for development environment.

  10. Run the unit test, as follows:

    ./gradlew test
    ./mvnw test

2.3. Run the Orders Microservice #

Run the orders microservice as follows:

MICRONAUT_ENVIRONMENTS=dev ./gradlew run
Or if you use Windows:
cmd /C "set MICRONAUT_ENVIRONMENTS=dev && gradlew run"
MICRONAUT_ENVIRONMENTS=dev ./mvnw mn:run
Or if you use Windows:
cmd /C "set MICRONAUT_ENVIRONMENTS=dev && mvnw mn:run""

3. Create the API (Gateway) Microservice #

Create the api microservice using the GDK Launcher.

  1. Open the GDK Launcher in advanced mode.

  2. Create a new project using the following selections.
    • Project Type: Application (Default)
    • Project Name: api
    • Base Package: com.example (Default)
    • Clouds: None
    • Build Tool: Gradle (Groovy) or Maven
    • Language: Java (Default)
    • Test Framework: JUnit (Default)
    • Java Version: 17 (Default)
    • Micronaut Version: (Default)
    • Cloud Services: Kubernetes
    • Features: GraalVM Native Image, Kubernetes Service Discovery, Kubernetes Support, Micronaut Management, Micronaut Serialization Jackson Core, and Mockito Framework
    • Sample Code: No
  3. Click Generate Project, then click Download Zip. GDK Launcher creates a directory named api containing a Micronaut application with a package named com.example. The application ZIP file will be downloaded to your default downloads directory. Unzip it, open it in your code editor, and proceed to the next steps.

Alternatively, use the GDK CLI as follows:

gdk create-app com.example.api \
    --services=k8s \
    --features=discovery-kubernetes,management,kubernetes,serialization-jackson,mockito,graalvm \
    --example-code=false \
    --build=gradle \
    --jdk=17 \
    --lang=java
gdk create-app com.example.api \
    --services=k8s \
    --features=discovery-kubernetes,management,kubernetes,serialization-jackson,mockito,graalvm \
    --example-code=false \
    --build=maven \
    --jdk=17 \
    --lang=java

3.1. Controllers in the API (Gateway) Microservice #

  1. Create a GatewayController class to handle incoming HTTP requests to the api microservice in a file named api/src/main/java/com/example/controllers/GatewayController.java as follows:

     package com.example.controllers;
    
     import com.example.clients.OrdersClient;
     import com.example.clients.UsersClient;
     import com.example.models.Item;
     import com.example.models.Order;
     import com.example.models.User;
     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.Get;
     import io.micronaut.http.annotation.Post;
     import io.micronaut.http.exceptions.HttpStatusException;
     import io.micronaut.scheduling.TaskExecutors;
     import io.micronaut.scheduling.annotation.ExecuteOn;
     import io.micronaut.validation.Validated;
     import jakarta.validation.Valid;
    
     import java.util.ArrayList;
     import java.util.List;
    
     @Controller("/api") // <1>
     @Validated
     @ExecuteOn(TaskExecutors.IO) // <2>
     class GatewayController {
    
         private final OrdersClient orderClient;
         private final UsersClient userClient;
    
         GatewayController(OrdersClient orderClient, UsersClient userClient) {
             this.orderClient = orderClient;
             this.userClient = userClient;
         }
    
         @Get("/users/{id}") // <3>
         User getUserById(int id) {
             return userClient.getById(id);
         }
    
         @Get("/orders/{id}") // <4>
         Order getOrdersById(int id) {
             Order order = orderClient.getOrderById(id);
             return new Order(order.id(), null, getUserById(order.userId()), order.items(), order.itemIds(), order.total());
         }
    
         @Get("/items/{id}") // <5>
         Item getItemsById(int id) {
             return orderClient.getItemsById(id);
         }
    
         @Get("/users") // <6>
         List<User> getUsers() {
             return userClient.getUsers();
         }
    
         @Get("/items") // <7>
         List<Item> getItems() {
             return orderClient.getItems();
         }
    
         @Get("/orders") // <8>
         List<Order> getOrders() {
             List<Order> orders = new ArrayList<>();
             orderClient.getOrders().forEach(x-> orders.add(new Order(x.id(), null, getUserById(x.userId()), x.items(), x.itemIds(), x.total())));
             return orders;
         }
    
         @Post("/orders") // <9>
         Order createOrder(@Body @Valid Order order) {
             User user = getUserById(order.userId());
             if (user == null) {
                 throw new HttpStatusException(HttpStatus.BAD_REQUEST, String.format("User with id %s doesn't exist", order.userId()));
             }
             Order createdOrder = orderClient.createOrder(order);
             return new Order(createdOrder.id(), null, user, createdOrder.items(), createdOrder.itemIds(), createdOrder.total());
         }
    
         @Post("/users")  // <10>
         User createUser(@Body @NonNull User user) {
             return userClient.createUser(user);
         }
    
     }
    

    1 The class is defined as a controller with the @Controller annotation mapped to the path /api.

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

    3 The @Get annotation maps the getUserById method to an HTTP GET request on /users/{id}.

    4 The @Get annotation maps the getOrdersById method to an HTTP GET request on /orders/{id}.

    5 The @Get annotation maps the getItemsById method to an HTTP GET request on /items/{id}.

    6 The @Get annotation maps the getUsers method to an HTTP GET request on /users.

    7 The @Get annotation maps the getItems method to an HTTP GET request on /items.

    8 The @Get annotation maps the getOrders method to an HTTP GET request on /orders.

    9 The @Post annotation maps the createUser method to an HTTP POST request on /users.

    10 The @Post annotation maps the createOrder method to an HTTP POST request on /orders.

  2. Create the User, Order, and Item objects to represent customers, orders, and items. The User record is in a file named api/src/main/java/com/example/models/User.java, as follows:

     package com.example.models;
    
     import com.fasterxml.jackson.annotation.JsonProperty;
     import io.micronaut.core.annotation.Nullable;
     import io.micronaut.serde.annotation.Serdeable;
     import jakarta.validation.constraints.Max;
     import jakarta.validation.constraints.NotBlank;
    
     @Serdeable // <1>
     public record User(
             @Nullable @Max(10000) Integer id,
             @NotBlank @JsonProperty("first_name") String firstName,
             @NotBlank @JsonProperty("last_name") String lastName,
             String username
     ) {
     }
    

    1 Declare the @Serdeable annotation at the type level in your source code to allow the type to be serialized or deserialized.

  3. The Order record is in a file named api/src/main/java/com/example/models/Order.java, as follows:

     package com.example.models;
    
     import com.fasterxml.jackson.annotation.JsonProperty;
     import io.micronaut.core.annotation.Nullable;
     import io.micronaut.serde.annotation.Serdeable;
     import jakarta.validation.constraints.Max;
     import jakarta.validation.constraints.NotBlank;
    
     import java.math.BigDecimal;
     import java.util.List;
    
     @Serdeable // <1>
     public record Order(
             @Nullable @Max(10000) Integer id,
             @NotBlank @Nullable @JsonProperty("user_id") Integer userId,
             @Nullable User user,
             @Nullable List<Item> items,
             @NotBlank @Nullable @JsonProperty("item_ids") List<Integer> itemIds,
             @Nullable BigDecimal total
     ) {
     }
    

    1 Declare the @Serdeable annotation at the type level in your source code to allow the type to be serialized or deserialized.

  4. The Item record is in a file named orders/src/main/java/com/example/models/Item.java, as follows:

     package com.example.models;
    
     import io.micronaut.serde.annotation.Serdeable;
    
     import java.math.BigDecimal;
    
     @Serdeable // <1>
     public record Item(
             Integer id,
             String name,
             BigDecimal price
     ) {
     }
    

    1 Declare the @Serdeable annotation at the type level in your source code to allow the type to be serialized or deserialized.

  5. Create a UserClient for the users microservice in a file named api/src/main/java/com/example/clients/UsersClient.java, as follows:

     package com.example.clients;
    
     import com.example.models.User;
     import io.micronaut.http.annotation.Body;
     import io.micronaut.http.annotation.Get;
     import io.micronaut.http.annotation.Post;
     import io.micronaut.http.client.annotation.Client;
    
     import java.util.List;
    
     @Client("users") // <1>
     public interface UsersClient {
         @Get("/users/{id}")
         User getById(int id);
    
         @Post("/users")
         User createUser(@Body User user);
    
         @Get("/users")
         List<User> getUsers();
     }
    

    1 Use @Client to use declarative HTTP Clients. You can annotate interfaces or abstract classes. You can use the id member to provide a service identifier or specify the URL directly as the annotation’s value.

  6. Create an OrdersClient for the orders microservice in a file named api/src/main/java/com/example/clients/OrdersClient.java, as follows:

     package com.example.clients;
    
     import com.example.models.Item;
     import com.example.models.Order;
     import io.micronaut.http.annotation.Body;
     import io.micronaut.http.annotation.Get;
     import io.micronaut.http.annotation.Post;
     import io.micronaut.http.client.annotation.Client;
    
     import java.util.List;
    
     @Client("orders") // <1>
     public interface OrdersClient {
         @Get("/orders/{id}")
         Order getOrderById(int id);
    
         @Post("/orders")
         Order createOrder(@Body Order order);
    
         @Get("/orders")
         List<Order> getOrders();
    
         @Get("/items")
         List<Item> getItems();
    
         @Get("/items/{id}")
         Item getItemsById(int id);
     }
    

    1 Use @Client to use declarative HTTP Clients. You can annotate interfaces or abstract classes. You can use the id member to provide a service identifier or specify the URL directly as the annotation’s value.

  7. Create a Credentials class, will load the username and password from configuration that will be needed for comparison when checking HTTP basic authentication credentials, in a file named api/src/main/java/com/example/auth/Credentials.java with the following contents:

     package com.example.auth;
    
     import io.micronaut.context.annotation.ConfigurationProperties;
    
     @ConfigurationProperties("authentication-credentials") // <1>
     public record Credentials (String username, String password) {}
    

    1 The @ConfigurationProperties annotation takes the configuration prefix.

  8. Create an AuthClientFilter class that acts as a client filter applied to every client. It adds a HTTP basic authentication header with credentials that are stored in the Credentials class, in a file named api/src/main/java/com/example/auth/AuthClientFilter.java with the following contents:

     package com.example.auth;
    
     import io.micronaut.http.HttpResponse;
     import io.micronaut.http.MutableHttpRequest;
     import io.micronaut.http.annotation.Filter;
     import io.micronaut.http.filter.ClientFilterChain;
     import io.micronaut.http.filter.HttpClientFilter;
     import org.reactivestreams.Publisher;
    
     @Filter(Filter.MATCH_ALL_PATTERN)
     class AuthClientFilter implements HttpClientFilter {
    
         private final Credentials credentials;
    
         AuthClientFilter(Credentials credentials) {
             this.credentials = credentials;
         }
    
         @Override
         public Publisher<? extends HttpResponse<?>> doFilter(MutableHttpRequest<?> request, ClientFilterChain chain) {
             return chain.proceed(request.basicAuth(credentials.username(), credentials.password()));
         }
     }
    
  9. Create an ErrorExceptionHandler class that will propagate errors from the orders and users microservices in a file named api/src/main/java/com/example/ErrorExceptionHandler.java with the following contents:

     package com.example;
    
     import io.micronaut.http.HttpRequest;
     import io.micronaut.http.HttpResponse;
     import io.micronaut.http.client.exceptions.HttpClientResponseException;
     import io.micronaut.http.server.exceptions.ExceptionHandler;
     import jakarta.inject.Singleton;
    
     @Singleton // <1>
     public class ErrorExceptionHandler implements ExceptionHandler<HttpClientResponseException, HttpResponse<?>> {
    
         @Override
         public HttpResponse<?> handle(HttpRequest request, HttpClientResponseException exception) {
             return HttpResponse
                     .status(exception.getResponse().status())
                     .body(exception.getResponse().getBody(String.class).orElse(null));
         }
     }
    

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

3.2. Write Tests to Verify Application Logic #

  1. Create a GatewayClient Micronaut HTTP inline client for testing in a file named api/src/test/java/com/example/GatewayClient.java

     package com.example;
    
     import com.example.models.Item;
     import com.example.models.Order;
     import com.example.models.User;
     import io.micronaut.http.annotation.Body;
     import io.micronaut.http.annotation.Get;
     import io.micronaut.http.annotation.Post;
     import io.micronaut.http.client.annotation.Client;
    
     import java.util.List;
    
     @Client("/") // <1>
     public interface GatewayClient {
    
         @Get("/api/items/{id}")
         Item getItemById(int id);
    
         @Get("/api/orders/{id}")
         Order getOrderById(int id);
    
         @Get("/api/users/{id}")
         User getUsersById(int id);
    
         @Get("/api/users")
         List<User> getUsers();
    
         @Get("/api/items")
         List<Item> getItems();
    
         @Get("/api/orders")
         List<Order> getOrders();
    
         @Post("/api/orders")
         Order createOrder(@Body Order order);
    
         @Post("/api/users")
         User createUser(@Body User user);
     }
    

    1 Use @Client to use declarative HTTP Clients. You can annotate interfaces or abstract classes. You can use the id member to provide a service identifier or specify the URL directly as the annotation’s value.

  2. Create a HealthTest class to check that there is /health endpoint (required for service discovery) in a file named api/src/test/java/com/example/HealthTest.java with the following contents:

     package com.example;
    
     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.test.extensions.junit5.annotation.MicronautTest;
     import org.junit.jupiter.api.Test;
    
     import jakarta.inject.Inject;
    
     import static org.junit.jupiter.api.Assertions.assertEquals;
    
     @MicronautTest // <1>
     class HealthTest {
    
         @Inject
         @Client("/")
         HttpClient client; // <2>
    
         @Test
         public void healthEndpointExposed() {
             HttpStatus status = client.toBlocking().retrieve(HttpRequest.GET("/health"), HttpStatus.class);
             assertEquals(HttpStatus.OK, status);
         }
     }
    

    1 Annotate the class with @MicronautTest so the Micronaut framework will initialize the application context and the embedded server. (For more information , see Micronaut Test.)

    2 Inject the HttpClient bean and point it to the embedded server.

  3. Create a GatewayControllerTest class to test endpoints inside the GatewayController in a file named api/src/test/java/com/example/GatewayControllerTest.java with the following contents:

     package com.example;
    
     import com.example.clients.OrdersClient;
     import com.example.clients.UsersClient;
     import com.example.models.Item;
     import com.example.models.Order;
     import com.example.models.User;
     import io.micronaut.http.HttpResponse;
     import io.micronaut.http.HttpStatus;
     import io.micronaut.http.client.exceptions.HttpClientResponseException;
     import io.micronaut.test.annotation.MockBean;
     import io.micronaut.test.extensions.junit5.annotation.MicronautTest;
     import jakarta.inject.Inject;
     import org.junit.jupiter.api.Test;
    
     import java.math.BigDecimal;
     import java.util.ArrayList;
     import java.util.List;
    
     import static org.junit.jupiter.api.Assertions.assertEquals;
     import static org.junit.jupiter.api.Assertions.assertNotNull;
     import static org.junit.jupiter.api.Assertions.assertNull;
     import static org.junit.jupiter.api.Assertions.assertThrows;
     import static org.junit.jupiter.api.Assertions.assertTrue;
     import static org.mockito.ArgumentMatchers.any;
     import static org.mockito.Mockito.mock;
     import static org.mockito.Mockito.when;
    
     @MicronautTest // <1>
     class GatewayControllerTest {
    
         @Inject
         OrdersClient ordersClient;
    
         @Inject
         UsersClient usersClient;
    
         @Inject
         GatewayClient gatewayClient;
    
         @MockBean(OrdersClient.class)
         OrdersClient ordersClient() {
             return mock(OrdersClient.class);
         }
    
         @MockBean(UsersClient.class)
         UsersClient usersClient() {
             return mock(UsersClient.class);
         }
    
         @Test
         void getItemById() {
             int itemId = 1;
             Item item = new Item(itemId, "test", BigDecimal.ONE);
    
             when(ordersClient.getItemsById(1)).thenReturn(item);
    
             Item retrievedItem = gatewayClient.getItemById(item.id());
    
             assertEquals(item.id(), retrievedItem.id());
             assertEquals(item.name(), retrievedItem. name());
             assertEquals(item.price(), retrievedItem.price());
    
         }
    
         @Test
         void getOrderById() {
    
             Order order = new Order(1, 2, null, null, new ArrayList<>(), null);
             User user = new User(order.userId(), "firstName", "lastName", "test");
    
             when(ordersClient.getOrderById(1)).thenReturn(order);
             when(usersClient.getById(user.id())).thenReturn(user);
    
             Order retrievedOrder = gatewayClient.getOrderById(order.id());
    
             assertEquals(order.id(), retrievedOrder.id());
             assertEquals(order.userId(), retrievedOrder.user().id());
             assertNull(retrievedOrder.userId());
             assertEquals(user.username(), retrievedOrder.user().username());
         }
    
         @Test
         void getUserById() {
             User user = new User(1, "firstName", "lastName", "test");
    
             when(usersClient.getById(1)).thenReturn(user);
    
             User retrievedUser = gatewayClient.getUsersById(user.id());
    
             assertEquals(user.id(), retrievedUser.id());
             assertEquals(user.username(), retrievedUser.username());
         }
    
         @Test
         void getUsers() {
             User user = new User(1, "firstName", "lastName", "test");
    
             when(usersClient.getUsers()).thenReturn(List.of(user));
    
             List<User> users = gatewayClient.getUsers();
    
             assertNotNull(users);
             assertEquals(1, users.size());
             assertEquals(user.id(), users.get(0).id());
             assertEquals(user.username(), users.get(0).username());
         }
    
         @Test
         void getItems() {
    
             Item item = new Item(1, "test", BigDecimal.ONE);
    
             when(ordersClient.getItems()).thenReturn(List.of(item));
    
             List<Item> items = gatewayClient.getItems();
    
             assertNotNull(items);
             assertEquals(1, items.size());
             assertEquals(item.name(), items.get(0).name());
             assertEquals(item.price(), items.get(0).price());
         }
    
         @Test
         void getOrders() {
             Order order = new Order(1, 2, null, null, new ArrayList<>(), null);
             User user = new User(order.userId(), "firstName", "lastName", "test");
    
             when(ordersClient.getOrders()).thenReturn(List.of(order));
             when(usersClient.getById(order.userId())).thenReturn(user);
    
             List<Order> orders = gatewayClient.getOrders();
    
             assertNotNull(orders);
             assertEquals(1, orders.size());
             assertNull(orders.get(0).userId());
             assertEquals(user.id(), orders.get(0).user().id());
    
             assertEquals(order.id(), orders.get(0).id());
             assertEquals(user.username(), orders.get(0).user().username());
         }
    
         @Test
         void createUser() {
             String firstName = "firstName";
             String lastName = "lastName";
             String username = "username";
    
             User user = new User(0, firstName, lastName, username);
    
             when(usersClient.createUser(any())).thenReturn(user);
    
             User createdUser = gatewayClient.createUser(user);
    
             assertEquals(firstName, createdUser.firstName());
             assertEquals(lastName, createdUser.lastName());
             assertEquals(username, createdUser.username());
         }
    
         @Test
         void createOrder() {
             Order order = new Order(1, 2, null, null, new ArrayList<>(), null);
             User user = new User(order.userId(), "firstName", "lastName", "test");
    
             when(usersClient.getById(user.id())).thenReturn(user);
    
             when(ordersClient.createOrder(any())).thenReturn(order);
    
             Order createdOrder = gatewayClient.createOrder(order);
    
             assertEquals(order.id(), createdOrder.id());
             assertNull(createdOrder.userId());
             assertEquals(order.userId(), createdOrder.user().id());
             assertEquals(user.username(), createdOrder.user().username());
         }
    
         @Test
         void createOrderUserDoesntExists() {
             Order order = new Order(1, 2, null, null, new ArrayList<>(), new BigDecimal(0));;
    
             when(ordersClient.createOrder(any())).thenReturn(order);
    
             when(usersClient.getById(order.userId())).thenReturn(null);
    
             HttpClientResponseException exception = assertThrows(HttpClientResponseException.class, () -> gatewayClient.createOrder(order));
    
             assertEquals(HttpStatus.BAD_REQUEST, exception.getStatus());
    
             assertTrue(exception.getResponse().getBody(String.class).orElse("").contains("User with id 2 doesn't exist"));
         }
    
         @Test
         void exceptionHandler() {
             User user = new User(1, "firstname", "lastname", "username");
    
             String message = "Test error message";
    
             when(usersClient.createUser(any())).thenThrow(new HttpClientResponseException("Test", HttpResponse.badRequest(message)));
    
             HttpClientResponseException exception = assertThrows(HttpClientResponseException.class, () -> gatewayClient.createUser(user));
    
             assertEquals(HttpStatus.BAD_REQUEST, exception.getStatus());
    
             assertTrue(exception.getResponse().getBody(String.class).orElse("").contains("Test error message"));
         }
    
     }
    

    1 Annotate the class with @MicronautTest so the Micronaut framework will initialize the application context and the embedded server. (For more information, see Micronaut Test.)

  4. Edit the api/src/main/resources/application-k8s.properties file as follows:

     # <1>
     authentication-credentials.username=${username}
     # <2>
     authentication-credentials.password=${password}
    

    1 Placeholder for a username that will be populated by Kubernetes.

    2 Placeholder for a password that will be populated by Kubernetes.

  5. Edit the api/src/main/resources/bootstrap-k8s.properties file to enable distributed configuration. Modify the contents so that it matches the following:

     micronaut.application.name=api
     # <1>
     micronaut.config-client.enabled=true
     # <2>
     kubernetes.client.secrets.enabled=true
     # <3>
     kubernetes.client.secrets.use-api=true
    

    1 Set micronaut.config-client.enabled: true to read and resolve configuration from distributed sources.

    2 Set kubernetes.client.secrets.enabled: true to enable Kubernetes secrets as a distributed source.

    3 Set kubernetes.client.secrets.use-api: true to use the Kubernetes API to fetch configuration.

  6. Create a file named api/src/main/resources/application-dev.properties. This configuration file is applied only for the dev environment.

     # <1>
     authentication-credentials.username=gdkdemo
     # <2>
     authentication-credentials.password=example-password
    

    1 Username for development environment.

    2 Password for development environment.

  7. Create a file named api/src/main/resources/bootstrap-dev.properties to disable distributed configuration in the dev environment:

     # <1>
     micronaut.http.services.users.urls=http://localhost:8081
     # <2>
     micronaut.http.services.orders.urls=http://localhost:8082
     # <3>
     kubernetes.client.secrets.enabled=false
    

    1 URL of the users microservice.

    2 URL of the orders microservice.

    3 Disable the Kubernetes secrets client.

  8. Create a file named api/src/test/resources/application-test.properties (to be used in the test environment) with the following contents:

     # <1>
     authentication-credentials.username=gdkdemo
     # <2>
     authentication-credentials.password=example-password
    

    1 Username for development environment.

    2 Password for development environment.

  9. Run the unit test, as follows:

    ./gradlew test
    ./mvnw test

3.3. Run the api Microservice #

Run the api microservice as follows:

MICRONAUT_ENVIRONMENTS=dev ./gradlew run
Or if you use Windows:
cmd /C "set MICRONAUT_ENVIRONMENTS=dev && gradlew run"
MICRONAUT_ENVIRONMENTS=dev ./mvnw mn:run
Or if you use Windows:
cmd /C "set MICRONAUT_ENVIRONMENTS=dev && mvnw mn:run""

4. Test Integration Between Microservices #

  1. Run a curl command to create a new user via the api microservice:

     curl -X "POST" localhost:8080/api/users \
          -H 'Content-Type: application/json; charset=utf-8' \
          -d '{ "first_name": "Nemanja", "last_name": "Mikic", "username": "nmikic" }'
    

    Your output should look like:

     {
       "id":1,
       "username":"nmikic",
       "first_name":"Nemanja",
       "last_name":"Mikic"
     }
    
  2. Run a curl command to create a new order via the api microservice:

     curl -X "POST" localhost:8080/api/orders \
          -H 'Content-Type: application/json; charset=utf-8' \
          -d '{ "user_id": 1, "item_ids": [1,2] }'
    

    Your output should include details of the order, as follows:

     {
       "id": 1,
       "user": {
         "first_name": "Nemanja",
         "last_name": "Mikic",
         "id": 1,
         "username": "nmikic"
       },
       "items": [
         {
           "id": 1,
           "name": "Banana",
           "price": 1.5
         },
         {
           "id": 2,
           "name": "Kiwi",
           "price": 2.5
         }
       ],
       "total": 4.0
     }
    
  3. Run a curl command to list the orders:

     curl localhost:8080/api/orders \
          -H 'Content-Type: application/json; charset=utf-8'
    

    You should see output that is similar to the following:

     [
       {
         "id": 1,
         "user": {
           "first_name": "Nemanja",
           "last_name": "Mikic",
           "id": 1,
           "username": "nmikic"
         },
         "items": [
           {
             "id": 1,
             "name": "Banana",
             "price": 1.5
           },
           {
             "id": 2,
             "name": "Kiwi",
             "price": 2.5
           }
         ],
         "total": 4.0
       }
     ]
    
  4. Try to place an order for a user who doesn’t exist (with id 100). Run the following curl command:

     curl -X "POST" localhost:8080/api/orders \
          -H 'Content-Type: application/json; charset=utf-8' \
          -d '{ "user_id": 100, "item_ids": [1,2] }'
    

    You should see the following error message:

     {
       "message": "Bad Request",
       "_links": {
         "self": [
           {
             "href": "/api/orders",
             "templated": false
           }
         ]
       },
       "_embedded": {
         "errors": [
           {
             "message": "User with id 100 doesn't exist"
           }
         ]
       }
     }
    

5. Deploy Microservices to a Local Kubernetes Cluster #

This section describes how to create the necessary Kubernetes resources for your microservices. It shows how to build container images and deploy each of the microservices on the local Kubernetes cluster.

If you are using Docker make sure that Kubernetes is enabled.

If you are using Rancher Desktop make sure that Kubernetes is enabled and that the virtual machines have 5GB of memory.

Note: If you are using Gradle to build your application on AArch64, add the following to your settings.gradle file:
buildscript {
    dependencies {
        classpath("com.github.docker-java:docker-java:3.3.1")
        classpath("com.github.docker-java:docker-java-transport-httpclient5:3.3.1")
    }
    repositories {
        mavenCentral()
    }
}

5.1. Configure Service Roles and Secrets #

  1. Create a filed named auth.yml that specifies a service role for microservices that have secret configurations.

     apiVersion: v1
     kind: Namespace # <1>
     metadata:
       name: gdk-k8s
     ---
     apiVersion: v1
     kind: ServiceAccount # <2>
     metadata:
       namespace: gdk-k8s
       name: gdk-service
     ---
     kind: Role # <3>
     apiVersion: rbac.authorization.k8s.io/v1
     metadata:
       namespace: gdk-k8s
       name: gdk_service_role
     rules:
       - apiGroups: [""]
         resources: ["services", "endpoints", "configmaps", "secrets", "pods"]
         verbs: ["get", "watch", "list"]
     ---
     apiVersion: rbac.authorization.k8s.io/v1
     kind: RoleBinding # <4>
     metadata:
       namespace: gdk-k8s
       name: gdk_service_role_bind
     subjects:
       - kind: ServiceAccount
         name: gdk-service
     roleRef:
       kind: Role
       name: gdk_service_role
       apiGroup: rbac.authorization.k8s.io
     ---
     apiVersion: v1
     kind: Secret # <5>
     metadata:
       namespace: gdk-k8s
       name: mysecret
     type: Opaque
     data:
       username: Z2NuZGVtbw== # <6>
       password: ZXhhbXBsZS1wYXNzd29yZA== # <7>
    

    1 Create a namespace named gdk-k8s.

    2 Create a service account named gdk-service.

    3 Create a role named gdk_service_role.

    4 Bind the gdk_service_role role to the gdk-service service account.

    5 Create a secret named mysecret.

    6 The base64 value of the username secret that will be used by the microservices.

    7 The base64 value of the password secret that will be used by the microservices.

  2. Run the next command to create the resources described above:

     kubectl apply -f auth.yml
    

5.2. Deploy the Users Microservice #

Build a container image of the users microservice named “users”.

From inside the users/ directory.

./gradlew dockerBuildNative
./mvnw package -Dpackaging=docker-native -Pgraalvm

Note: If you are having issues building a container image try:

  • Gradle, from inside the users/build/docker/native-main/ directory run docker build . -t users -f DockerfileNative.
  • Maven, from inside the user/target/ directory run docker build . -t users -f Dockerfile.

Next, edit the file named k8s.yml as follows:

apiVersion: apps/v1
kind: Deployment
metadata:
  namespace: gdk-k8s
  name: "users"
spec:
  selector:
    matchLabels:
      app: "users"
  template:
    metadata:
      labels:
        app: "users"
    spec:
      serviceAccountName: gdk-service # <1>
      containers:
        - name: "users"
          image: users # <2>
          imagePullPolicy: Never # <3>
          ports:
            - name: http
              containerPort: 8080
          readinessProbe:
            httpGet:
              path: /health/readiness
              port: 8080
            initialDelaySeconds: 5
            timeoutSeconds: 3
          livenessProbe:
            httpGet:
              path: /health/liveness
              port: 8080
            initialDelaySeconds: 5
            timeoutSeconds: 3
            failureThreshold: 10
---
apiVersion: v1
kind: Service
metadata:
  namespace: gdk-k8s
  name: "users" # <4>
spec:
  selector:
    app: "users"
  type: NodePort
  ports:
    - protocol: "TCP"
      port: 8080 # <5>

1 The service name that you created in the auth.yaml file.

2 The name of the container image for deployment.

3 The imagePullPolicy is set to Never. You will always use the local one that you built in previous step.

4 The name of the microservice, required for service discovery.

5 Micronaut’s default port on which the application is running.


Then run the next command to create the resources described above:

kubectl apply -f k8s.yml

5.3. Deploy the Orders Microservice #

Build a container image of the orders microservice named “orders”.

From inside the orders/ directory.

./gradlew dockerBuildNative
./mvnw package -Dpackaging=docker-native -Pgraalvm

Note: If you are having issues building a container image try:

  • Gradle, from inside the orders/build/docker/native-main/ directory run docker build . -t orders -f DockerfileNative.
  • Maven, from inside the orders/target/ directory run docker build . -t orders -f Dockerfile.

Next, edit the file named k8s.yml as follows:

apiVersion: apps/v1
kind: Deployment
metadata:
  namespace: gdk-k8s
  name: "orders"
spec:
  selector:
    matchLabels:
      app: "orders"
  template:
    metadata:
      labels:
        app: "orders"
    spec:
      serviceAccountName: gdk-service # <1>
      containers:
        - name: "orders"
          image: orders # <2>
          imagePullPolicy: Never # <3>
          ports:
            - name: http
              containerPort: 8080
          readinessProbe:
            httpGet:
              path: /health/readiness
              port: 8080
            initialDelaySeconds: 5
            timeoutSeconds: 3
          livenessProbe:
            httpGet:
              path: /health/liveness
              port: 8080
            initialDelaySeconds: 5
            timeoutSeconds: 3
            failureThreshold: 10
---
apiVersion: v1
kind: Service
metadata:
  namespace: gdk-k8s
  name: "orders" # <4>
spec:
  selector:
    app: "orders"
  type: NodePort
  ports:
    - protocol: "TCP"
      port: 8080 # <5>

1 The service name that you created in the auth.yaml file.

2 The name of the container image for deployment.

3 The imagePullPolicy is set to Never. You will always use local one that you built in previous step.

4 The name of the microservice, required for service discovery.

5 Micronaut’s default port on which application is running.


Then run the next command to create the resources described above:

kubectl apply -f k8s.yml

5.4. Deploy the API (Gateway) Microservice #

Build a container image of the api microservice named “api”.

From inside the api/ directory.

./gradlew dockerBuildNative
./mvnw package -Dpackaging=docker-native -Pgraalvm

Note: If you are having issues building a container image try:

  • Gradle, from inside the api/build/docker/native-main/ directory run docker build . -t api -f DockerfileNative.
  • Maven, from inside the api/target/ directory run docker build . -t api -f Dockerfile.

Next, edit the file named k8s.yml as follows:

apiVersion: apps/v1
kind: Deployment
metadata:
  namespace: gdk-k8s
  name: "api"
spec:
  selector:
    matchLabels:
      app: "api"
  template:
    metadata:
      labels:
        app: "api"
    spec:
      serviceAccountName: gdk-service # <1>
      containers:
        - name: "api"
          image: api # <2>
          imagePullPolicy: Never # <3>
          ports:
            - name: http
              containerPort: 8080
          readinessProbe:
            httpGet:
              path: /health/readiness
              port: 8080
            initialDelaySeconds: 5
            timeoutSeconds: 3
          livenessProbe:
            httpGet:
              path: /health/liveness
              port: 8080
            initialDelaySeconds: 5
            timeoutSeconds: 3
            failureThreshold: 10
---
apiVersion: v1
kind: Service
metadata:
  namespace: gdk-k8s
  name: "api" # <4>
spec:
  selector:
    app: "api"
  type: LoadBalancer
  ports:
    - protocol: "TCP"
      port: 8080 # <5>

1 The service name that you created in the auth.yaml file.

2 The name of the container image for deployment.

3 The imagePullPolicy is set to Never. You will always use local one that you built in previous step.

4 The name of the microservice, required for service discovery.

5 Micronaut’s default port on which application is running.


Then run the next command to create the resources described above:

kubectl apply -f k8s.yml

6. Test Integration Between Microservices Deployed to Kubernetes #

  1. Run the next command to check status of the pods and make sure that all of them have the status “Running”:

     kubectl get pods -n=gdk-k8s
    
     NAME                      READY   STATUS    RESTARTS   AGE
     api-774fd667b9-dmws4      1/1     Running   0          24s
     orders-74ff4fcbc4-dnfbw   1/1     Running   0          19s
     users-9f46dd7c6-vs8z7     1/1     Running   0          13s
     
  2. Run the next command to check the status of the microservices:

     kubectl get services -n=gdk-k8s
    
     NAME     TYPE           CLUSTER-IP       EXTERNAL-IP   PORT(S)          AGE
     api      LoadBalancer   10.110.42.201    192.168.0.106 8080:32601/TCP   18s
     orders   NodePort       10.105.43.19     <none>        8080:31033/TCP   21s
     users    NodePort       10.104.130.114   <none>        8080:31482/TCP   26s
     
  3. Run a curl command to create a new user via the api microservice:

     curl -X "POST" localhost:8080/api/users \
          -H 'Content-Type: application/json; charset=utf-8' \
          -d '{ "first_name": "Nemanja", "last_name": "Mikic", "username": "nmikic" }'
    

    Your output should look like:

     {
       "id":1,
       "username":"nmikic",
       "first_name":"Nemanja",
       "last_name":"Mikic"
     }
    
  4. Run a curl command to create a new order via the api microservice:

     curl -X "POST" localhost:8080/api/orders \
          -H 'Content-Type: application/json; charset=utf-8' \
          -d '{ "user_id": 1, "item_ids": [1,2] }'
    

    Your output should include details of the order, as follows:

     {
       "id": 1,
       "user": {
         "first_name": "Nemanja",
         "last_name": "Mikic",
         "id": 1,
         "username": "nmikic"
       },
       "items": [
         {
           "id": 1,
           "name": "Banana",
           "price": 1.5
         },
         {
           "id": 2,
           "name": "Kiwi",
           "price": 2.5
         }
       ],
       "total": 4.0
     }
    
  5. Run a curl command to list the orders:

     curl localhost:8080/api/orders \
          -H 'Content-Type: application/json; charset=utf-8'
    

    You should see output that is similar to the following:

     [
       {
         "id": 1,
         "user": {
           "first_name": "Nemanja",
           "last_name": "Mikic",
           "id": 1,
           "username": "nmikic"
         },
         "items": [
           {
             "id": 1,
             "name": "Banana",
             "price": 1.5
           },
           {
             "id": 2,
             "name": "Kiwi",
             "price": 2.5
           }
         ],
         "total": 4.0
       }
     ]
    
  6. Try to place an order for a user who doesn’t exist (with id 100). Run a curl command:

     curl -X "POST" localhost:8080/api/orders \
          -H 'Content-Type: application/json; charset=utf-8' \
          -d '{ "user_id": 100, "item_ids": [1,2] }'
    

    You should see the following error message:

     {
       "message": "Bad Request",
       "_links": {
         "self": [
           {
             "href": "/api/orders",
             "templated": false
           }
         ]
       },
       "_embedded": {
         "errors": [
           {
             "message": "User with id 100 doesn't exist"
           }
         ]
       }
     }
    

7. Clean Up Resources #

To delete all resources that you created in this guide run the next command:

kubectl delete namespaces gdk-k8s

To delete the Minikube local Kubernetes cluster run:

minikube delete

Summary #

This guide demonstrated how to create a microservices application, build a containerized version of the microservices, and deploy each to a local Kubernetes cluster. Thanks to the Micronaut Kubernetes module, you can connect the microservices using Kubernetes Service Discovery and Distributed Configuration.