Kubernetes service discovery and distributed configuration

How to use Kubernetes service discovery and distributed configuration in a Micronaut application

Authors: Nemanja Mikic

Micronaut Version: 4.6.3

1. Getting Started

In this guide, we will create three microservices, build containerized versions and deploy them with Kubernetes. We will use Kubernetes Service discovery and Distributed configuration to wire up our microservices.

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.

You will discover how the Micronaut framework eases Kubernetes integration.

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

  • Docker.

  • Local Kubernetes cluster. We will use Minikube in this guide.

3. Solution

We recommend that you follow the instructions in the next sections and create the application step by step. However, you can go right to the completed example.

4. Writing the Apps

Let’s describe the microservices you will build through the guide.

  • users - This microservice contains customers data that can place orders on items, also a new customer can be created. Microservice requires Basic authentication to access it.

  • orders - This microservice contains all orders that customers have created as well as available items that customers can order. Also this microservice enables the creation of new orders. Microservice requires Basic authentication to access it.

  • api - This microservice acts as a gateway to the orders and users services. It combines results from both services and checks data when customers create a new order.

Initially we will hard-code the URLs of the orders and users services in the api service. Additionally, we will hard-code credentials (username and password) into every microservice configuration that are required for Basic authentication.

In the second part of this guide we will use a Kubernetes discovery service and Kubernetes configuration maps to dynamically resolve the URLs of the orders and users microservices and get authentication credentials. The microservices call the Kubernetes API to register when they start up and then resolve placeholders inside the microservices' configurations.

4.1. Users Microservice

Create the users microservice using the Micronaut Command Line Interface or with Micronaut Launch.

mn create-app          \
    --features=yaml,discovery-kubernetes,management,security,kubernetes,serialization-jackson,validation,graalvm \
    --build=gradle             \
    --lang=java               \
    --jdk=21              \
    example.micronaut.users
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.

If you use Micronaut Launch, select Micronaut Application as application type and add the yaml, discovery-kubernetes, management, security, serialization-jackson, kubernetes and graalvm features.

The previous command creates a directory named users containing Micronaut application with a package named example.micronaut.

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.

Create a package named controllers and create a UsersController class to handle incoming HTTP requests for the users microservice:

users/src/main/java/example/micronaut/controllers/UsersController.java
package example.micronaut.controllers;

import example.micronaut.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 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)
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.

Create package named models where we will put our data beans.

The previous UsersController controller uses a User object to represent the customer. Create the User record

users/src/main/java/example/micronaut/models/User.java
package example.micronaut.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 in your source code to allow the type to be serialized or deserialized.
2 ID will be generated by application.

Create package named auth where you will check basic authentication credentials.

The Credentials class will load and store credentials (username and password) from a configuration file.

users/src/main/java/example/micronaut/auth/Credentials.java
package example.micronaut.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.

The CredentialsChecker class, 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 that we created above.

users/src/main/java/example/micronaut/auth/CredentialsChecker.java
package example.micronaut.auth;

import io.micronaut.core.annotation.NonNull;
import io.micronaut.core.annotation.Nullable;
import io.micronaut.http.HttpRequest;
import io.micronaut.security.authentication.AuthenticationFailureReason;
import io.micronaut.security.authentication.AuthenticationRequest;
import io.micronaut.security.authentication.AuthenticationResponse;
import io.micronaut.security.authentication.provider.HttpRequestAuthenticationProvider;
import jakarta.inject.Singleton;

@Singleton (1)
class CredentialsChecker<B> implements HttpRequestAuthenticationProvider<B> { (2)

    private final Credentials credentials;

    CredentialsChecker(Credentials credentials) {
        this.credentials = credentials;
    }

    public AuthenticationResponse authenticate(
            @Nullable HttpRequest<B> httpRequest,
            @NonNull AuthenticationRequest<String, String> authenticationRequest
    ) {
        return (authenticationRequest.getIdentity().equals(credentials.username()) && authenticationRequest.getSecret().equals(credentials.password()))
                ? AuthenticationResponse.success(authenticationRequest.getIdentity())
                : AuthenticationResponse.failure(AuthenticationFailureReason.CREDENTIALS_DO_NOT_MATCH);
    }
}
1 Use jakarta.inject.Singleton to designate a class as a singleton.


4.1.1. Write tests to verify application logic

Create the UsersClient, a declarative Micronaut HTTP Client for testing:

users/src/test/java/example/micronaut/UsersClient.java
package example.micronaut;

import example.micronaut.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.

HealthTest checks that there is /health endpoint that is required for service discovery.

users/src/test/java/example/micronaut/HealthTest.java
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.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. More info.
2 Inject the HttpClient bean and point it to the embedded server.

UsersControllerTest tests endpoints inside the UserController.

users/src/test/java/example/micronaut/UsersControllerTest.java
package example.micronaut;

import example.micronaut.auth.Credentials;
import example.micronaut.models.User;
import io.micronaut.http.HttpStatus;
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() {
        HttpClientResponseException exception = assertThrows(HttpClientResponseException.class, () -> usersClient.getUsers(""));
        assertEquals(HttpStatus.UNAUTHORIZED, exception.getStatus());
    }

    @Test
    void getUserThatDoesntExists() {
        String authHeader = basicAuth(credentials);
        User retriedUser = usersClient.getById(authHeader, 100);
        assertNull(retriedUser);
    }

    @Test
    void multipleUserInteraction() {
        String authHeader = basicAuth(credentials);

        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 = basicAuth(credentials);

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

    }
    private static String basicAuth(Credentials credentials) {
        return basicAuth(credentials.username(), credentials.password());
    }
    private static String basicAuth(String username, String password) {
        return "Basic " + Base64.getEncoder().encodeToString((username + ":" + password).getBytes());
    }
}
1 Annotate the class with @MicronautTest so the Micronaut framework will initialize the application context and the embedded server. More info.

Edit application.yml

users/src/main/resources/application.yml
micronaut:
  application:
    name: users
authentication-credentials:
  username: ${username} (1)
  password: ${password} (2)
1 Placeholder for username that will be populated by Kubernetes.
2 Placeholder for password that will be populated by Kubernetes.

Edit the bootstrap.yml file in the resources directory to enable distributed configuration. Change the default contents to the following:

users/src/main/resources/bootstrap.yml
micronaut:
  application:
    name: users
  config-client:
    enabled: true (1)
kubernetes:
  client:
    secrets:
      enabled: true (2)
      use-api: true (3)
1 Set microanut.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.

Create src/main/resources/application-dev.yml. The Micronaut framework applies this configuration file only for the dev environment.

users/src/main/resources/application-dev.yml
micronaut:
  server:
    port: 8081 (1)
authentication-credentials:
  username: "test_username" (2)
  password: "test_password" (3)
1 Configure the application to listen on port 8081.
2 Hardcoded username for the development environment.
3 Hardcoded password for the development environment.

Create a file named bootstrap-dev.yml to disable distributed configuration in the dev environment:

users/src/main/resources/bootstrap-dev.yml
kubernetes:
  client:
    secrets:
      enabled: false (1)
1 Disable the Kubernetes secrets client.

Create a file named application-test.yml for use in the test environment:

users/src/test/resources/application-test.yml
authentication-credentials:
  username: "test_username" (1)
  password: "test_password" (2)
1 Hardcoded username for the test environment.
2 Hardcoded password for the test environment.

Run the unit test:

users
./gradlew test


4.1.2. Running the application

Run the users microservice:

users
 MICRONAUT_ENVIRONMENTS=dev ./gradlew run
14:28:34.034 [main] INFO  io.micronaut.runtime.Micronaut - Startup completed in 499ms. Server Running: http://localhost:8081

4.2. Orders Microservice

Create the orders microservice using the Micronaut Command Line Interface or with Micronaut Launch.

mn create-app        \
  --features=yaml,discovery-kubernetes,management,security,kubernetes,serialization-jackson,validation,graalvm \
  --build=gradle              \
  --lang=java                \
  --jdk=21               \
example.micronaut.orders
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.

If you use Micronaut Launch, select Micronaut Application as application type and add the yaml, discovery-kubernetes, management, security, serialization-jackson, kubernetes and graalvm features.

The previous command creates a directory named orders containing a Micronaut application with a package named example.micronaut.

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.

Create package named controllers and create the OrdersController and ItemsController classes to handle incoming HTTP requests to the orders microservice:

orders/src/main/java/example/micronaut/controllers/OrdersController.java
package example.micronaut.controllers;

import example.micronaut.models.Item;
import example.micronaut.models.Order;
import io.micronaut.core.util.CollectionUtils;
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.Optional;
import java.util.List;

@Controller("/orders")  (1)
@Secured(SecurityRule.IS_AUTHENTICATED)  (2)
class OrdersController {

    private final List<Order> orders = new ArrayList<>();

    @Get("/{id}")  (3)
    public Optional<Order> findById(int id) {
        return orders.stream()
                .filter(it -> it.id().equals(id))
                .findFirst();
    }

    @Get  (4)
    public List<Order> getOrders() {
        return orders;
    }

    @Post  (5)
    public Order createOrder(@Body @Valid Order order) {
        if (CollectionUtils.isEmpty(order.itemIds())) {
            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))
                )

        ).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.
orders/src/main/java/example/micronaut/controllers/ItemsController.java
package example.micronaut.controllers;

import example.micronaut.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.Optional;
import java.util.List;

@Controller("/items")  (1)
@Secured(SecurityRule.IS_AUTHENTICATED)  (2)
class ItemsController {

    @Get("/{id}")  (3)
    public Optional<Item> findById(int id) {
        return Item.items.stream()
                .filter(it -> it.id().equals(id))
                .findFirst();
    }

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

Create package named models where you will put your data beans.

The OrdersController and ItemsController classes uses Order and Item objects to represent customer orders. Create the Order record:

orders/src/main/java/example/micronaut/models/Order.java
package example.micronaut.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 java.math.BigDecimal;
import java.util.List;

@Serdeable (1)
public record Order(
        @Max(10000) @Nullable Integer id, (2)
        @JsonProperty("user_id") Integer userId,
        @Nullable List<Item> items, (3)
        @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 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 item_ids will be provided by client requests.

Create the Item record:

orders/src/main/java/example/micronaut/models/Item.java
package example.micronaut.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.

Create package named auth where you will check basic authentication credentials.

The Credentials class will load and store credentials (username and password) from configuration files.

orders/src/main/java/example/micronaut/auth/Credentials.java
package example.micronaut.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.

The CredentialsChecker class, as name suggests, will check if provided credentials inside an HTTP request’s Authorization header are the same as those that are stored inside Credentials class that we created above.

orders/src/main/java/example/micronaut/auth/CredentialsChecker.java
package example.micronaut.auth;

import io.micronaut.core.annotation.NonNull;
import io.micronaut.core.annotation.Nullable;
import io.micronaut.http.HttpRequest;
import io.micronaut.security.authentication.AuthenticationFailureReason;
import io.micronaut.security.authentication.AuthenticationRequest;
import io.micronaut.security.authentication.AuthenticationResponse;
import io.micronaut.security.authentication.provider.HttpRequestAuthenticationProvider;
import jakarta.inject.Singleton;

@Singleton (1)
class CredentialsChecker<B> implements HttpRequestAuthenticationProvider<B> { (2)

    private final Credentials credentials;

    CredentialsChecker(Credentials credentials) {
        this.credentials = credentials;
    }

    public AuthenticationResponse authenticate(
            @Nullable HttpRequest<B> httpRequest,
            @NonNull AuthenticationRequest<String, String> authenticationRequest
    ) {
        return (authenticationRequest.getIdentity().equals(credentials.username()) && authenticationRequest.getSecret().equals(credentials.password()))
                ? AuthenticationResponse.success(authenticationRequest.getIdentity())
                : AuthenticationResponse.failure(AuthenticationFailureReason.CREDENTIALS_DO_NOT_MATCH);
    }
}
1 Use jakarta.inject.Singleton to designate a class as a singleton.


4.2.1. Write tests to verify application logic

Create the OrderItemClient, a declarative Micronaut HTTP Client for testing:

orders/src/test/java/example/micronaut/OrderItemClient.java
package example.micronaut;

import example.micronaut.models.Item;
import example.micronaut.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.

HealthTest checks that there is /health endpoint that is required for service discovery.

orders/src/test/java/example/micronaut/HealthTest.java
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.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. More info.
2 Inject the HttpClient bean and point it to the embedded server.

ItemsControllerTest tests endpoints inside the ItemController.

orders/src/test/java/example/micronaut/ItemsControllerTest.java
package example.micronaut;

import example.micronaut.auth.Credentials;
import example.micronaut.models.Item;
import io.micronaut.http.HttpStatus;
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 ItemsControllerTest {

    @Inject
    OrderItemClient orderItemClient;

    @Inject
    Credentials credentials;

    @Test
    void testUnauthorized() {
        HttpClientResponseException exception = assertThrows(HttpClientResponseException.class, () -> orderItemClient.getItems(""));
        assertEquals(HttpStatus.UNAUTHORIZED, exception.getStatus());
    }

    @Test
    void getItem() {

        int itemId = 1;


        String authHeader = basicAuth(credentials);

        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 = basicAuth(credentials);

        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))));
    }
    private static String basicAuth(Credentials credentials) {
        return basicAuth(credentials.username(), credentials.password());
    }
    private static String basicAuth(String username, String password) {
        return "Basic " + Base64.getEncoder().encodeToString((username + ":" + password).getBytes());
    }
}
1 Annotate the class with @MicronautTest so the Micronaut framework will initialize the application context and the embedded server. More info.

OrdersControllerTest tests endpoints inside the OrdersController.

orders/src/test/java/example/micronaut/OrdersControllerTest.java
package example.micronaut;

import example.micronaut.auth.Credentials;
import example.micronaut.models.Order;
import io.micronaut.http.HttpStatus;
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() {
        HttpClientResponseException exception = assertThrows(HttpClientResponseException.class, () -> orderItemClient.getOrders(""));
        assertEquals(HttpStatus.UNAUTHORIZED, exception.getStatus());
    }

    @Test
    void multipleOrderInteraction() {
        String authHeader = basicAuth(credentials);

        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 = basicAuth(credentials);

        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 = basicAuth(credentials);

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


    private static String basicAuth(Credentials credentials) {
        return basicAuth(credentials.username(), credentials.password());
    }
    private static String basicAuth(String username, String password) {
        return "Basic " + Base64.getEncoder().encodeToString((username + ":" + password).getBytes());
    }

}
1 Annotate the class with @MicronautTest so the Micronaut framework will initialize the application context and the embedded server. More info.

Edit application.yml so it contains:

orders/src/main/resources/application.yml
micronaut:
  application:
    name: orders
authentication-credentials:
  username: ${username} (1)
  password: ${password} (2)
1 Placeholder for username that will be populated by Kubernetes.
2 Placeholder for password that will be populated by Kubernetes.

Edit bootstrap.yml file in the resources directory to enable distributed configuration. Change it to the following:

orders/src/main/resources/bootstrap.yml
micronaut:
  application:
    name: orders
  config-client:
    enabled: true (1)
kubernetes:
  client:
    secrets:
      enabled: true (2)
      use-api: true (3)
1 Set microanut.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.

Create src/main/resources/application-dev.yml. The Micronaut framework applies this configuration file only for the dev environment.

orders/src/main/resources/application-dev.yml
micronaut:
  server:
    port: 8082 (1)
authentication-credentials:
  username: "test_username" (2)
  password: "test_password" (3)
1 Configure the application to listen on port 8082.
2 Hardcoded username for development environment.
3 Hardcoded password for development environment.

Create a file named bootstrap-dev.yml to disable distributed configuration in the dev environment:

orders/src/main/resources/bootstrap-dev.yml
kubernetes:
  client:
    secrets:
      enabled: false (1)
1 Disable Kubernetes secrets client.

Create a file named application-test.yml to be used in the test environment:

orders/src/test/resources/application-test.yml
micronaut:
  application:
    name: orders
authentication-credentials:
  username: "test_username" (1)
  password: "test_password" (2)
1 Hardcoded username for development environment.
2 Hardcoded password for development environment.

Run the unit test:

orders
./gradlew test


4.2.2. Running the application

Run the orders microservice:

orders
MICRONAUT_ENVIRONMENTS=dev ./gradlew run
14:28:34.034 [main] INFO  io.micronaut.runtime.Micronaut - Startup completed in 499ms. Server Running: http://localhost:8082

4.3. API (Gateway) Microservice

Create the api microservice using the Micronaut Command Line Interface or with Micronaut Launch.

mn create-app         \
   --features=yaml,discovery-kubernetes,management,kubernetes,serialization-jackson,http-client,mockito,graalvm \
   --build=gradle           \
   --lang=java             \
   --jdk=21            \
    example.micronaut.api
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.

If you use Micronaut Launch, select Micronaut Application as application type and add the yaml, discovery-kubernetes, management, kubernetes, serialization-jackson, mockito, graalvm and http-client features.

The previous command creates a directory named api containing a Micronaut application with a package named example.micronaut.

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.

Create a package named controllers and create a GatewayController class to handle incoming HTTP requests to the api microservice:

api/src/main/java/example/micronaut/controllers/GatewayController.java
package example.micronaut.controllers;

import example.micronaut.clients.OrdersClient;
import example.micronaut.clients.UsersClient;
import example.micronaut.models.Item;
import example.micronaut.models.Order;
import example.micronaut.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 jakarta.validation.Valid;
import java.util.List;

@Controller("/api") (1)
@ExecuteOn(TaskExecutors.BLOCKING) (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() {
        return orderClient.getOrders()
                .stream()
                .map(x -> new Order(x.id(), null, getUserById(x.userId()), x.items(), x.itemIds(), x.total()))
                .toList();
    }

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

Create package named models where you will put your data beans.

The GatewayController and ItemsController classes use User, Order, and Item to represent customer orders. Create the User record:

api/src/main/java/example/micronaut/models/User.java
package example.micronaut.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.

Create the Order record:

api/src/main/java/example/micronaut/models/Order.java
package example.micronaut.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.

Create the Item record:

api/src/main/java/example/micronaut/models/Item.java
package example.micronaut.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.

Create a package named clients where you will put the HTTP Clients to call the users and orders microservices.

Create a UsersClient for the users microservice.

api/src/main/java/example/micronaut/clients/UsersClient.java
package example.micronaut.clients;

import example.micronaut.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.

Create an OrdersClient for the orders microservice.

api/src/main/java/example/micronaut/clients/OrdersClient.java
package example.micronaut.clients;

import example.micronaut.models.Item;
import example.micronaut.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.

Create a package named auth where we will check basic authentication credentials.

Create a Credentials class that will load the username and password from configuration that will be needed for comparison.

api/src/main/java/example/micronaut/auth/Credentials.java
package example.micronaut.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.

Create an AuthClientFilter class that is a client filter applied to every client. It adds basic authentication header with credentials that are stored in the Credentials class.

api/src/main/java/example/micronaut/auth/AuthClientFilter.java
package example.micronaut.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()));
    }
}

Create a class named ErrorExceptionHandler in the example.micronaut package. ErrorExceptionHandler will propagate errors from the orders and users microservices.

api/src/main/java/example/micronaut/ErrorExceptionHandler.java
package example.micronaut;

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 io.micronaut.http.server.exceptions.response.ErrorContext;
import io.micronaut.http.server.exceptions.response.ErrorResponseProcessor;
import jakarta.inject.Singleton;

@Singleton (1)
class ErrorExceptionHandler implements ExceptionHandler<HttpClientResponseException, HttpResponse<?>> {
    private final ErrorResponseProcessor<?> errorResponseProcessor;

    public ErrorExceptionHandler(ErrorResponseProcessor<?> errorResponseProcessor) {
        this.errorResponseProcessor = errorResponseProcessor;
    }

    @Override
    public HttpResponse handle(HttpRequest request, HttpClientResponseException e) {
        return errorResponseProcessor.processResponse(ErrorContext.builder(request)
                .cause(e)
                .errorMessage(e.getResponse().getBody(String.class).orElse(null))
                .build(), HttpResponse.status(e.getStatus()));
    }
}
1 Use jakarta.inject.Singleton to designate a class as a singleton.


4.3.1. Write tests to verify application logic

Create a GatewayClient, a declarative Micronaut HTTP Client for testing:

api/src/test/java/example/micronaut/GatewayClient.java
package example.micronaut;

import example.micronaut.models.Item;
import example.micronaut.models.Order;
import example.micronaut.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.

HealthTest checks that there is /health endpoint that is required for service discovery.

api/src/test/java/example/micronaut/HealthTest.java
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.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. More info.
2 Inject the HttpClient bean and point it to the embedded server.

GatewayControllerTest tests endpoints inside the GatewayController.

api/src/test/java/example/micronaut/GatewayControllerTest.java
package example.micronaut;

import example.micronaut.clients.OrdersClient;
import example.micronaut.clients.UsersClient;
import example.micronaut.models.Item;
import example.micronaut.models.Order;
import example.micronaut.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 reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Arrays;
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. More info.

Edit application.yml

api/src/main/resources/application.yml
micronaut:
  application:
    name: api
authentication-credentials:
  username: ${username} (1)
  password: ${password} (2)
1 Placeholder for username that will be populated by Kubernetes.
2 Placeholder for password that will be populated by Kubernetes.

Edit the bootstrap.yml file in the resources directory to enable distributed configuration so it looks like the following:

api/src/main/resources/bootstrap.yml
micronaut:
  application:
    name: api
  config-client:
    enabled: true (1)
kubernetes:
  client:
    secrets:
      enabled: true (2)
      use-api: true (3)
1 Set microanut.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.

Create src/main/resources/application-dev.yml. The Micronaut framework applies this configuration file only for the dev environment.

api/src/main/resources/application-dev.yml
authentication-credentials:
  username: "test_username" (1)
  password: "test_password" (2)
1 Hardcoded username for development environment.
2 Hardcoded password for development environment.

Create a file named bootstrap-dev.yml to disable distributed configuration in the dev environment:

api/src/main/resources/bootstrap-dev.yml
micronaut:
  http:
    services:
      users:
        urls:
          - http://localhost:8081 (1)
      orders:
        urls:
          - http://localhost:8082 (2)
kubernetes:
  client:
    secrets:
      enabled: false (3)
1 URL of the users microservice
2 URL of the orders microservice
3 Disable Kubernetes secrets client.

Create a file named application-test.yml to be used in the test environment:

api/src/test/resources/application-test.yml
authentication-credentials:
  username: "test_username" (1)
  password: "test_password" (2)
1 Hardcoded username for development environment.
2 Hardcoded password for development environment.

Run the unit test:

api
./gradlew test


4.3.2. Running the application

Run api microservice:

api
MICRONAUT_ENVIRONMENTS=dev ./gradlew run
14:28:34.034 [main] INFO  io.micronaut.runtime.Micronaut - Startup completed in 499ms. Server Running: http://localhost:8080

4.4. Test integration between applications

Store the URL of the api microservice in the API_URL environment variable.

export API_URL=http://localhost:8080

Run a cURL command to create a new user via the api microservice:

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

Run a cURL command to a new order via the api microservice:

curl -X "POST" "$API_URL/api/orders" -H 'Content-Type: application/json; charset=utf-8' -d '{ "user_id": 1, "item_ids": [1,2] }'
{"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}

Run a cURL command to list created orders:

curl "$API_URL/api/orders" -H 'Content-Type: application/json; charset=utf-8'
[{"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}]

We can try to place an order for a user who doesn’t exist (with id 100). Run a cURL command:

curl -X "POST" "$API_URL/api/orders" -H 'Content-Type: application/json; charset=utf-8' -d '{ "user_id": 100, "item_ids": [1,2] }'
{"message":"Bad Request","_links":{"self":[{"href":"/api/orders","templated":false}]},"_embedded":{"errors":[{"message":"User with id 100 doesn't exist"}]}}

5. Kubernetes and the Micronaut framework

In this chapter we will first create the necessary Kubernetes resources for our microservices that will make them work properly then we will configure build container images and deploy each of the microservices that we created on the local Kubernetes cluster.

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

auth.yml
apiVersion: v1
kind: Namespace (1)
metadata:
  name: micronaut-k8s
---
apiVersion: v1
kind: ServiceAccount (2)
metadata:
  namespace: micronaut-k8s
  name: micronaut-service
---
kind: Role (3)
apiVersion: rbac.authorization.k8s.io/v1
metadata:
  namespace: micronaut-k8s
  name: micronaut_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: micronaut-k8s
  name: micronaut_service_role_bind
subjects:
  - kind: ServiceAccount
    name: micronaut-service
roleRef:
  kind: Role
  name: micronaut_service_role
  apiGroup: rbac.authorization.k8s.io
---
apiVersion: v1
kind: Secret (5)
metadata:
  namespace: micronaut-k8s
  name: mysecret
type: Opaque
data:
  username: YWRtaW4= (6)
  password: bWljcm9uYXV0aXNhd2Vzb21l (7)
1 We create a namespace named micronaut-k8s.
2 We create a service account named micronaut-service.
3 We create a role named micronaut_service_role.
4 We bind the micronaut_service_role role to the micronaut-service service account.
5 We create a secret named mysecret.
6 Base64 value of the username secret that will be used by the microservices.
7 Base64 value of the password secret that will be used by the microservices.

Run the next command to create the resources described above:

kubectl apply -f auth.yml

Before we start deploying each service, ensure that Docker daemon is configured to use Kubernetes. If you are using Minikube run the next command to switch the docker daemon to use Minikube.

eval $(minikube docker-env)

5.1. Users Microservice

Build a docker image of the users service with the name users.

If you want to build a docker Native image with dockerBuildNative and you are using Minikube make sure that it is configured with enough memory. If you keep getting "Read timeout error" try to build image using docker build . -t users -f DockerfileNative inside users/build/docker/native-main directory.

Edit the file named k8s.yml inside the users microservice.

/users/k8s.yml
apiVersion: apps/v1
kind: Deployment
metadata:
  namespace: micronaut-k8s
  name: "users"
spec:
  selector:
    matchLabels:
      app: "users"
  template:
    metadata:
      labels:
        app: "users"
    spec:
      serviceAccountName: micronaut-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: micronaut-k8s
  name: "users" (4)
spec:
  selector:
    app: "users"
  type: NodePort
  ports:
    - protocol: "TCP"
      port: 8080  (5)
1 The service name that we created in the auth.yaml file.
2 The name of the container image for deployment.
3 The imagePullPolicy is set to Never. We will always use local one that we built in previous step.
4 Name of a service, required for service discovery.
5 Micronaut default port on which application is running.

Run the next command to create the resources described above:

kubectl apply -f users/k8s.yml
deployment.apps/users created
service/users created

5.2. Orders Microservice

Build a docker image of the orders service with the name orders.

If you want to build a docker Native image with dockerBuildNative and you are using Minikube make sure that it is configured with enough memory. If you keep getting "Read timeout error" try to build image using docker build . -t orders -f DockerfileNative inside orders/build/docker/native-main directory.

Edit the file named k8s.yml inside the orders microservice.

/orders/k8s.yml
apiVersion: apps/v1
kind: Deployment
metadata:
  namespace: micronaut-k8s
  name: "orders"
spec:
  selector:
    matchLabels:
      app: "orders"
  template:
    metadata:
      labels:
        app: "orders"
    spec:
      serviceAccountName: micronaut-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: micronaut-k8s
  name: "orders"  (4)
spec:
  selector:
    app: "orders"
  type: NodePort
  ports:
    - protocol: "TCP"
      port: 8080  (5)
1 The service name that we created in the auth.yaml file.
2 The name of the container image for deployment.
3 The imagePullPolicy is set to Never. We will always use local one that we built in previous step.
4 Name of a service, required for service discovery.
5 Micronaut default port on which application is running.

Run the next command to create the resources described above:

kubectl apply -f orders/k8s.yml

5.3. API (Gateway) Microservice

Build a docker image of the api service with the name api.

If you want to build a docker Native image with dockerBuildNative and you are using Minikube make sure that it is configured with enough memory. If you keep getting "Read timeout error" try to build image using docker build . -t api -f DockerfileNative inside api/build/docker/native-main directory.

Edit the file named k8s.yml inside the api microservice.

/api/k8s.yml
apiVersion: apps/v1
kind: Deployment
metadata:
  namespace: micronaut-k8s
  name: "api"
spec:
  selector:
    matchLabels:
      app: "api"
  template:
    metadata:
      labels:
        app: "api"
    spec:
      serviceAccountName: micronaut-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: micronaut-k8s
  name: "api"  (4)
spec:
  selector:
    app: "api"
  type: LoadBalancer
  ports:
    - protocol: "TCP"
      port: 8080  (5)
1 The service name that we created in the auth.yaml file.
2 The name of the container image for deployment.
3 The imagePullPolicy is set to Never. We will always use local one that we built in previous step.
4 Name of a service, required for service discovery.
5 Micronaut default port on which application is running.

Run the next command to create the resources described above:

kubectl apply -f api/k8s.yml

5.4. Test integration between applications deployed on Kubernetes

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=micronaut-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

Run the next command to check the status of the microservices:

kubectl get services -n=micronaut-k8s

5.4.1. Minikube

For Minikube the output should be similar to the following:

NAME     TYPE           CLUSTER-IP       EXTERNAL-IP   PORT(S)          AGE
api      LoadBalancer   10.110.42.201    <pending>     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
By default, the EXTERNAL-IP address of the LoadBalancer service inside Minikube will be in the <pending> state. If you want to assign an external ip you have to run the minikube tunnel command.

Run the next command to retrieve the URL of the api microservice:

export API_URL=$(minikube service api -n=micronaut-k8s --url)

5.4.2. Docker Desktop

For Docker Desktop’s Kubernetes integration the output should be similar to the following. Notice the external-ip is localhost:

NAME     TYPE           CLUSTER-IP       EXTERNAL-IP   PORT(S)          AGE
api      LoadBalancer   10.108.205.248   localhost     8080:31516/TCP   9m23s
orders   NodePort       10.98.120.224    <none>        8080:31566/TCP   9m39s
users    NodePort       10.109.155.86    <none>        8080:30545/TCP   10m

So for Docker Desktop the API_URL should be set to http://localhost:8080.

Run a cURL command to create a new user via the api microservice:

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

Run a cURL command to a new order via the api microservice:

curl -X "POST" "$API_URL/api/orders" -H 'Content-Type: application/json; charset=utf-8' -d '{ "user_id": 1, "item_ids": [1,2] }'
{"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}

Run a cURL command to list created orders:

curl "$API_URL/api/orders" -H 'Content-Type: application/json; charset=utf-8'
[{"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}]

We can try to place an order for a user who doesn’t exist (with id 100). Run a cURL command:

curl -X "POST" "$API_URL/api/orders" -H 'Content-Type: application/json; charset=utf-8' -d '{ "user_id": 100, "item_ids": [1,2] }'
{"message":"Bad Request","_links":{"self":[{"href":"/api/orders","templated":false}]},"_embedded":{"errors":[{"message":"User with id 100 doesn't exist"}]}}

6. Cleaning Up

To delete all resources that were created in this guide run next command.

kubectl delete namespaces micronaut-k8s

7. Next steps

Read more about Kubernetes.

Read more about Micronaut Kubernetes module.

8. 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…​).