Microservices with Spring Boot 3 and Spring Cloud
This article will teach you how to build microservices with Spring Boot 3 and the Spring Cloud components. It’s a tradition that I describe this topic once a new major version of Spring Boot is released. As you probably know, Spring Boot 3.0 is generally available since the end of November 2022. In order to compare changes, you can read my article about microservices with Spring 2 written almost five years ago.
In general, we will cover the following topics in this article:
- Using Spring Boot 3 in cloud-native development
- Provide service discovery for all microservices with Spring Cloud Netflix Eureka. Anticipating your questions – yes, Eureka is still there. It’s the last of Netflix microservices components still available in Spring Cloud
- Spring Cloud OpenFeign in inter-service communication
- Distributed configuration with Spring Cloud Config
- API Gateway pattern with Spring Cloud Gateway including a global OpenAPI documentation with the Springdoc project
- Collecting traces with Micrometer OpenTelemetry and Zipkin
Fortunately, the migration from Spring Boot 2 to 3 is not a painful process. You can even check it out in my example repository, which was originally written in Spring Boot 2. The list of changes is not large. However, times have changed during the last five years… And we will begin our considerations from that point.
Running Environment
Here are the results of my quick 1-day voting poll run on Twitter. I assume that those results are meaningful since around 900 people voted. As you probably expect, currently, the first-choice platform for running your Spring Boot microservices is Kubernetes. I don’t have a survey conducted five years ago, but the results would probably be significantly different. Even if you had Kubernetes in your organization 5 years ago, you were probably starting a migration of your apps or at least it was in progress. Of course, there might be some exceptions, but I’m thinking about the vast majority.
You could migrate to Kubernetes during that time, but also Kubernetes ecosystem has changed a lot. There are many useful tools and platform services you may easily integrate with your apps. We can at least mention Kubernetes native solutions like service mesh (e.g. Istio) or serverless (e.g. Knative). The main question here is: if I’m running microservices on Kubernetes are Spring Cloud components still relevant? The answer is: in most cases no. Of course, you can still use Eureka for service discovery, Spring Cloud Config for a distributed configuration, or Spring Cloud Gateway for the API gateway pattern. However, you can easily replace them with Kubernetes built-in mechanisms and additional platform services.
To conclude, this article is not aimed at Kubernetes users. It shows how to easily run microservices architecture anywhere. If you are looking for staff mainly related to Kubernetes you can read my articles about the best practices for Java apps and microservices there.
Source Code
If you would like to try it by yourself, you may always take a look at my source code. In order to do that you need to clone my GitHub repository. Then you should just follow my instructions.
Before we proceed to the source code, let’s take a look at the following diagram. It illustrates the architecture of our sample system. We have three independent Spring Boot 3 microservices, which register themself in service discovery, fetch properties from the configuration service, and communicate with each other. The whole system is hidden behind the API gateway. Our Spring Boot 3 microservices send traces to the Zipkin instance using the Micrometer OTEL project.
Currently, the newest version of Spring Cloud is 2022.0.1
. This version of spring-cloud-dependencies
should be declared as a BOM for dependency management.
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>2022.0.1</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
Step 1: Configuration Server with Spring Cloud Config
To enable Spring Cloud Config feature for an application, we should first include spring-cloud-config-server
to your project dependencies.
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-config-server</artifactId>
</dependency>
Then enable running the embedded configuration server during application boot use @EnableConfigServer
annotation.
@SpringBootApplication
@EnableConfigServer
public class ConfigApplication {
public static void main(String[] args) {
new SpringApplicationBuilder(ConfigApplication.class).run(args);
}
}
By default Spring Cloud Config Server stores the configuration data inside the Git repository. We will change that behavior by activating the native
mode. In this mode, Spring Cloud Config Server reads property sources from the classpath. We place all the YAML property files inside src/main/resources/config
. Here’s the config server application.yml
file. It activates the native
mode and overrides a default port to 8088
.
server:
port: 8088
spring:
profiles:
active: native
The YAML filename will be the same as the name of the service. For example, the YAML file of discovery-service
is located here: src/main/resources/config/discovery-service.yml
. Besides a default profile, we will also define the custom docker
profile. Therefore the name of the config file will contain the docker
suffix. On the default profile, we are connecting services through localhost
with dynamically assigned ports. So, the typical configuration file for the default profile will look like that:
server:
port: 0
eureka:
client:
serviceUrl:
defaultZone: http://localhost:8061/eureka/
Here’s the typical configuration file for the default profile:
server:
port: 8080
eureka:
client:
serviceUrl:
defaultZone: http://discovery-service:8061/eureka/
In order to connect the config server on the client side we need to include the following module in Maven dependencies:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-config</artifactId>
</dependency>
Depending on the running environment (localhost
or docker) we need to provide different addresses for the config server:
spring:
config:
import: "optional:configserver:http://config-service:8088"
activate:
on-profile: docker
---
spring:
application:
name: discovery-service
config:
import: "optional:configserver:http://localhost:8088"
Step 2: Discovery Server with Spring Cloud Netflix Eureka
Of course, you can replace Eureka with any other discovery server supported by Spring Cloud. It can be Consul, Alibaba Nacos, or Zookeeper. The best way to run the Eureka server is just to embed it into the Spring Boot app. In order to do that, we first need to include the following Maven dependency:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-server</artifactId>
</dependency>
Then we need to set the @EnableEurekaServer
annotation on the main class.
@SpringBootApplication
@EnableEurekaServer
public class DiscoveryApplication {
public static void main(String[] args) {
new SpringApplicationBuilder(DiscoveryApplication.class).run(args);
}
}
There is nothing new with that. As I already mentioned, the configuration files, discovery-service.yml
or discovery-service-docker.yml
, should be placed inside config-service
module. We have changed Eureka’s running port from the default value (8761) to 8061. For the standalone Eureka instance, we have to disable registration and omit to fetch the registry. We just want to activate a single-node, demo discovery server.
server:
port: 8061
eureka:
instance:
hostname: localhost
client:
registerWithEureka: false
fetchRegistry: false
serviceUrl:
defaultZone: http://${eureka.instance.hostname}:${server.port}/eureka/
Once you have successfully started the application you may visit Eureka Dashboard available under the address http://localhost:8061/
.
Step 3: Build Apps with Spring Boot 3 and Spring Cloud
Let’s take a look at a list of required Maven modules for our microservices. Each app has to get a configuration from the config-service
and needs to register itself in the discovery-service
. It also exposes REST API, automatically generates API documentation, and export tracing info to the Zipkin instance. We use the springdoc-openapi
v2 library dedicated to Spring Boot 3. It generates documentation in both JSON and YAML formats available under the v3/api-docs
path (or /v3/api-docs.yaml
for the YAML format). In order to export traces to the Zipkin server, we will include the opentelemetry-exporter-zipkin
module.
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-config</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-tracing-bridge-otel</artifactId>
</dependency>
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-exporter-zipkin</artifactId>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-api</artifactId>
<version>2.0.2</version>
</dependency>
</dependencies>
For the apps that call other services, we also need to include a declarative REST client. We will use Spring Cloud OpenFeign.
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
OpenFeign client automatically integrates with the service discovery. We just to set the name under which it is registered in Eureka inside the @FeingClient
annotation. In order to create a client, we need to define an interface containing all the endpoints it has to call.
@FeignClient(name = "employee-service")
public interface EmployeeClient {
@GetMapping("/organization/{organizationId}")
List<Employee> findByOrganization(@PathVariable("organizationId") Long organizationId);
}
During the demo, we will send all the traces to Zipkin. It requires setting the value of the probability parameter to 1.0
. In order to override the default URL of Zipkin we need to use the management.zipkin.tracing.endpoint
property.
management:
tracing:
sampling:
probability: 1.0
zipkin:
tracing:
endpoint: http://zipkin:9411/api/v2/spans
Here’s the implementation of the @RestController
in department-service
. It injects the repository bean to interact with the database, and the Feign client bean to communicate with employee-service
. The rest of the code is pretty simple.
@RestController
public class DepartmentController {
private static final Logger LOGGER = LoggerFactory.getLogger(DepartmentController.class);
DepartmentRepository repository;
EmployeeClient employeeClient;
public DepartmentController(DepartmentRepository repository, EmployeeClient employeeClient) {
this.repository = repository;
this.employeeClient = employeeClient;
}
@PostMapping("/")
public Department add(@RequestBody Department department) {
LOGGER.info("Department add: {}", department);
return repository.add(department);
}
@GetMapping("/{id}")
public Department findById(@PathVariable("id") Long id) {
LOGGER.info("Department find: id={}", id);
return repository.findById(id);
}
@GetMapping("/")
public List<Department> findAll() {
LOGGER.info("Department find");
return repository.findAll();
}
@GetMapping("/organization/{organizationId}")
public List<Department> findByOrganization(@PathVariable("organizationId") Long organizationId) {
LOGGER.info("Department find: organizationId={}", organizationId);
return repository.findByOrganization(organizationId);
}
@GetMapping("/organization/{organizationId}/with-employees")
public List<Department> findByOrganizationWithEmployees(@PathVariable("organizationId") Long organizationId) {
LOGGER.info("Department find: organizationId={}", organizationId);
List<Department> departments = repository.findByOrganization(organizationId);
departments.forEach(d -> d.setEmployees(employeeClient.findByDepartment(d.getId())));
return departments;
}
}
As you see there are almost no differences in the app implementation between Spring Boot 2 and 3. The only thing you would have to do is to change all the javax.persistence
to the jakarta.persistance
.
Step 4: API Gateway with Spring Cloud Gateway
A gateway-service
is the last app in our microservices architecture with Spring Boot 3. Beginning from Spring Boot 2 Spring Cloud Gateway replaced Netflix Zuul. We can also install it on Kubernetes using, for example, the Helm chart provided by VMWare Tanzu.
We will create a separate application with the embedded gateway. In order to do that we need to include Spring Cloud Gateway Starter in the Maven dependencies. Since our gateway has to interact with discovery and config services, it also includes Eureka Client Starter and Spring Cloud Config Starter. We don’t want to use it just as a proxy to the downstream services, but also we expose there OpenAPI documentation generated by all the apps. Since Spring Cloud Gateway is built on top of Spring WebFlux, we need to include Springdoc starters dedicated to that project.
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-config</artifactId>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-tracing-bridge-otel</artifactId>
</dependency>
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-exporter-zipkin</artifactId>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webflux-api</artifactId>
<version>2.0.2</version>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webflux-ui</artifactId>
<version>2.0.2</version>
</dependency>
In order to expose OpenAPI documentation from multiple v3/api-docs
endpoints we need to use the GroupedOpenApi
object. It should provide a way to switch between documentation generated by employee-service
, department-service
and organization-service
. Those services run on dynamic addresses (or at least random ports). In that case, we will use the RouteDefinitionLocator
bean to grab the current URL of each service. Then we just need to filter a list of routes to find only those related to our three microservices. Finally, we create the GroupedOpenApi
containing a service name and path.
@SpringBootApplication
public class GatewayApplication {
private static final Logger LOGGER = LoggerFactory
.getLogger(GatewayApplication.class);
public static void main(String[] args) {
SpringApplication.run(GatewayApplication.class, args);
}
@Autowired
RouteDefinitionLocator locator;
@Bean
public List<GroupedOpenApi> apis() {
List<GroupedOpenApi> groups = new ArrayList<>();
List<RouteDefinition> definitions = locator
.getRouteDefinitions().collectList().block();
assert definitions != null;
definitions.stream().filter(routeDefinition -> routeDefinition
.getId()
.matches(".*-service"))
.forEach(routeDefinition -> {
String name = routeDefinition.getId()
.replaceAll("-service", "");
groups.add(GroupedOpenApi.builder()
.pathsToMatch("/" + name + "/**").group(name).build());
});
return groups;
}
}
Here’s the configuration of gateway-service
. We should enable integration with the discovery server by setting the property spring.cloud.gateway.discovery.locator.enabled
to true
. Then we may proceed to define the route rules. We use the Path Route Predicate Factory for matching the incoming requests, and the RewritePath GatewayFilter Factory for modifying the requested path to adapt it to the format exposed by downstream services. The uri
parameter specifies the name of the target service registered in the discovery server. For example, organization-service
is available on the gateway under the /organization/**
path thanks to the predicate Path=/organization/**
, and the rewrite path from /organization/**
to the /**
.
spring:
output:
ansi:
enabled: always
cloud:
gateway:
discovery:
locator:
enabled: true
routes:
- id: employee-service
uri: lb://employee-service
predicates:
- Path=/employee/**
filters:
- RewritePath=/employee/(?<path>.*), /$\{path}
- id: department-service
uri: lb://department-service
predicates:
- Path=/department/**
filters:
- RewritePath=/department/(?<path>.*), /$\{path}
- id: organization-service
uri: lb://organization-service
predicates:
- Path=/organization/**
filters:
- RewritePath=/organization/(?<path>.*), /$\{path}
- id: openapi
uri: http://localhost:${server.port}
predicates:
- Path=/v3/api-docs/**
filters:
- RewritePath=/v3/api-docs/(?<path>.*), /$\{path}/v3/api-docs
springdoc:
swagger-ui:
urls:
- name: employee
url: /v3/api-docs/employee
- name: department
url: /v3/api-docs/department
- name: organization
url: /v3/api-docs/organization
As you see above, we are also creating a dedicated route for Springdoc OpenAPI. It rewrites the path for the /v3/api-docs
context to serve it properly in the Swagger UI.
Step 5: Running Spring Boot 3 Microservices
Finally, we can run all our microservices. With the current configuration in the repository, you can start them directly on your laptop or with Docker containers.
Option 1: Starting directly on the laptop
In total, we have 6 apps to run: 3 microservices, a discovery server, a config server, and a gateway. We also need to run Zipkin to collect and store traces from communication between the services. In the first step, we should start the config-service
. We can use Spring Boot Maven plugin for that. Just go to the config-service
directory and the following command. It is exposed on the 8088
port.
$ mvn spring-boot:run
We should repeat the same step for all the other apps. The discovery-service
is listening on the 8061
port, while the gateway-service
on the 8060
port. Microservices will start on the dynamically generated port number thanks to the server.port=0
property in config. In the final step, we can run Zipkin using its Docker container with the following command:
$ docker run -d --name zipkin -p 9411:9411 openzipkin/zipkin
Option 2: Build images and run them with Docker Compose
In the first step, we will build the whole Maven project and Docker images for all the apps. I created a profile build-image
that needs to be activated to build images. It mostly uses the build-image
step provided by the Spring Boot Maven Plugin. However, for config-service
and discovery-service
I’m using Jib because it is built on top of the base image with curl
installed. For both these services Docker compose needs to verify health checks before starting other containers.
$ mvn clean package -Pbuild-image
The docker-compose.yml
is available in the repository root directory. The whole file is visible below. We need to run config-service
before all other apps since it provides property sources. Secondly, we should start discovery-service
. In both these cases, we are defining a health check that tests the HTTP endpoint using curl inside the container. Once we start and verify config-service
and discovery-service
we may run gateway-service
and all the microservices. All the apps are running with the docker Spring profile activated thanks to the SPRING_PROFILES_ACTIVE environment variable. It corresponds to the spring.profiles.active
param that may be defined in configuration properties.
version: "3.7"
services:
zipkin:
container_name: zipkin
image: openzipkin/zipkin
ports:
- "9411:9411"
config-service:
image: piomin/config-service:1.1-SNAPSHOT
ports:
- "8088:8088"
healthcheck:
test: curl --fail http://localhost:8088/employee/docker || exit 1
interval: 5s
timeout: 2s
retries: 3
discovery-service:
image: piomin/discovery-service:1.1-SNAPSHOT
ports:
- "8061:8061"
depends_on:
config-service:
condition: service_healthy
links:
- config-service
healthcheck:
test: curl --fail http://localhost:8061/eureka/v2/apps || exit 1
interval: 4s
timeout: 2s
retries: 3
environment:
SPRING_PROFILES_ACTIVE: docker
employee-service:
image: piomin/employee-service:1.2-SNAPSHOT
ports:
- "8080"
depends_on:
discovery-service:
condition: service_healthy
links:
- config-service
- discovery-service
- zipkin
environment:
SPRING_PROFILES_ACTIVE: docker
department-service:
image: piomin/department-service:1.2-SNAPSHOT
ports:
- "8080"
depends_on:
discovery-service:
condition: service_healthy
links:
- config-service
- discovery-service
- employee-service
- zipkin
environment:
SPRING_PROFILES_ACTIVE: docker
organization-service:
image: piomin/organization-service:1.2-SNAPSHOT
ports:
- "8080"
depends_on:
discovery-service:
condition: service_healthy
links:
- config-service
- discovery-service
- employee-service
- department-service
- zipkin
environment:
SPRING_PROFILES_ACTIVE: docker
gateway-service:
image: piomin/gateway-service:1.1-SNAPSHOT
ports:
- "8060:8060"
depends_on:
discovery-service:
condition: service_healthy
environment:
SPRING_PROFILES_ACTIVE: docker
links:
- config-service
- discovery-service
- employee-service
- department-service
- organization-service
- zipkin
Finally, let’s run all the apps using Docker Compose:
$ docker-compose up
Try it out
Once you start all the apps you can perform some test calls to the services through the gateway-service
. It listening on the 8060
port. There is some test data automatically generated during startup. You can call the following endpoint to test all the services and communication between them:
$ curl http://localhost:8060/employee/
$ curl http://localhost:8060/department/organization/1
$ curl http://localhost:8060/department/organization/1/with-employees
$ curl http://localhost:8060/organization/
$ curl http://localhost:8060/organization/1/with-departments
Here are the logs generated by the apps during the calls visible above:
Let’s display Swagger UI exposed on the gateway. You can easily switch between contexts for all three microservices as you see below:
We can go to the Zipkin dashboard to verify the collected traces:
Final Thoughts
Treat this article as a quick guide to the most common components related to microservices with Spring Boot 3. I focused on showing you some new features since my last article on this topic. You could read how to implement tracing with Micrometer OpenTelemetry, generate API docs with Springdoc, or build Docker images with Spring Boot Maven Plugin.
28 COMMENTS