In the landscape of 2025, building microservices in Java has matured from an experimental architectural style to the de facto standard for large-scale enterprise applications. However, the complexity of distributed systems remains the primary challenge. Breaking a monolith into smaller services is the easy part; ensuring those services can find each other, communicate reliably, and withstand partial failures is where the real engineering happens.
With the release of Java 21 LTS (and the upcoming Java 25) and the Spring Boot 3.4+ ecosystem, the tools at our disposal have become significantly more powerful and developer-friendly. Virtual threads have revolutionized concurrency, and declarative HTTP interfaces have simplified inter-service communication.
This article provides a deep dive into the three pillars of a robust microservices architecture:
- Service Discovery: How services dynamically locate each other without hardcoded URLs.
- Communication: Modern synchronous communication using Spring 6 HTTP Interfaces.
- Resilience: Implementing Circuit Breakers, Retries, and Bulkheads to prevent cascading failures.
We will build a realistic scenario involving an Order Service talking to an Inventory Service, complete with production-grade configuration.
Prerequisites and Environment #
To follow this tutorial, ensure your environment is set up with the latest stable toolchain:
- JDK 21 LTS (Amazon Corretto, Eclipse Temurin, or Oracle).
- Maven 3.9+ or Gradle 8.5+.
- IDE: IntelliJ IDEA (Ultimate recommended) or VS Code with Java extensions.
- Spring Boot: Version 3.4.x.
- Spring Cloud: Version 2024.0.0 (or latest compatible release train).
- Docker: For running supporting infrastructure (optional but recommended).
1. Architectural Overview #
Before writing code, we must understand the flow. In a distributed environment, services are ephemeral. They scale up and down, and their IP addresses change. Hardcoding URLs (e.g., http://localhost:8081) is an anti-pattern that leads to brittle systems.
We will implement the following architecture:
- Eureka Server: The phonebook registry where all services register themselves.
- Inventory Service: A RESTful service that manages product stock.
- Order Service: The client service that calls the Inventory Service to verify stock before placing an order.
The Order Service will not know the IP of the Inventory Service. It will ask Eureka, load balance the request, and wrap the call in a Circuit Breaker.
2. Project Setup and Dependencies #
For all microservices in this guide, we will use a common parent BOM (Bill of Materials) to manage versions. If you are using Maven, your pom.xml should include the Spring Cloud dependency management.
<properties>
<java.version>21</java.version>
<spring-cloud.version>2024.0.0</spring-cloud.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring-cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>3. Pillar One: Service Discovery (Netflix Eureka) #
While Kubernetes Service Discovery is prevalent in cloud-native deployments, client-side discovery using Netflix Eureka remains a highly popular, platform-agnostic solution for pure Java environments or hybrid clouds.
3.1 Setting up the Eureka Server #
Create a new Spring Boot application named discovery-server.
Dependencies:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-server</artifactId>
</dependency>Main Class: Annotate your main class to enable the registry.
package com.javadevpro.discovery;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.netflix.eureka.server.EnableEurekaServer;
@SpringBootApplication
@EnableEurekaServer
public class DiscoveryServerApplication {
public static void main(String[] args) {
SpringApplication.run(DiscoveryServerApplication.class, args);
}
}Configuration (application.yml):
The server needs to know it shouldn’t register with itself.
server:
port: 8761
spring:
application:
name: discovery-server
eureka:
client:
register-with-eureka: false
fetch-registry: false
server:
wait-time-in-ms-when-sync-empty: 0 # Development only to speed up startupStart this application. You can access the dashboard at http://localhost:8761.
3.2 Creating the Inventory Service (The Provider) #
Create a standard Spring Boot Web application.
Dependencies:
spring-boot-starter-webspring-cloud-starter-netflix-eureka-client
Configuration (application.yml):
We configure the application name, which acts as the ID for discovery.
server:
port: 0 # Random port to simulate dynamic scaling
spring:
application:
name: inventory-service
eureka:
client:
service-url:
defaultZone: http://localhost:8761/eureka/
instance:
instance-id: ${spring.application.name}:${random.uuid}The Controller:
package com.javadevpro.inventory.controller;
import org.springframework.web.bind.annotation.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@RestController
@RequestMapping("/api/inventory")
public class InventoryController {
private static final Logger log = LoggerFactory.getLogger(InventoryController.class);
@GetMapping("/{skuCode}")
public boolean isInStock(@PathVariable String skuCode) {
log.info("Checking stock for SKU: {}", skuCode);
// Simulate a database check or processing delay
try { Thread.sleep(100); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
// Simulating logic: iPhone_15 is out of stock, others are in stock
return !"iphone_15".equalsIgnoreCase(skuCode);
}
}When you run multiple instances of this service, they will appear in the Eureka Dashboard under INVENTORY-SERVICE.
4. Pillar Two: Communication (Modern Declarative Clients) #
In the past, RestTemplate and OpenFeign were the standards. However, since Spring Framework 6, the recommended approach for modern Java applications is the HTTP Interface Client. It combines the declarative style of Feign with the modern, non-blocking foundation of WebClient or the newer RestClient.
We will implement the Order Service using this modern approach.
4.1 Order Service Setup #
Dependencies:
spring-boot-starter-webspring-cloud-starter-netflix-eureka-clientspring-cloud-starter-circuitbreaker-resilience4jspring-boot-starter-aop(Required for Resilience4j annotations)
4.2 Defining the Declarative Client #
Instead of writing implementation code to call HTTP endpoints, we define an interface.
package com.javadevpro.order.client;
import org.springframework.web.service.annotation.GetExchange;
import org.springframework.web.service.annotation.HttpExchange;
// Identify the base path
@HttpExchange("/api/inventory")
public interface InventoryClient {
// Maps to GET /api/inventory/{skuCode}
@GetExchange("/{skuCode}")
boolean checkStock(String skuCode);
}4.3 Configuring the Proxy Factory with Load Balancing #
We need to wire up the InventoryClient so Spring can inject it. This is where we integrate the Load Balancer.
package com.javadevpro.order.config;
import com.javadevpro.order.client.InventoryClient;
import org.springframework.cloud.client.loadbalancer.LoadBalanced;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.support.RestClientAdapter;
import org.springframework.web.service.invoker.HttpServiceProxyFactory;
@Configuration
public class ClientConfig {
@Bean
@LoadBalanced // Essential: Makes RestClient resolve service names via Eureka
public RestClient.Builder restClientBuilder() {
return RestClient.builder();
}
@Bean
public InventoryClient inventoryClient(RestClient.Builder builder) {
// Use the service name (inventory-service) instead of localhost
RestClient restClient = builder
.baseUrl("http://inventory-service")
.build();
RestClientAdapter adapter = RestClientAdapter.create(restClient);
HttpServiceProxyFactory factory = HttpServiceProxyFactory.builderFor(adapter).build();
return factory.createClient(InventoryClient.class);
}
}Communication Method Comparison #
Why choose HTTP Interfaces over the alternatives?
| Feature | RestTemplate | OpenFeign | WebClient | Spring 6 HTTP Interfaces |
|---|---|---|---|---|
| Status | Maintenance Mode | Mature/Standard | Reactive Standard | Modern Standard (2025) |
| Style | Imperative | Declarative | Functional/Reactive | Declarative |
| Dependency | spring-web |
spring-cloud-starter-openfeign |
spring-webflux |
spring-web (uses RestClient) |
| Performance | Blocking I/O | Blocking (mostly) | Non-blocking | Flexible (Blocking or Non-blocking) |
| Verdict | Avoid for new projects | Good, but adds overhead | Best for high concurrency | Best Developer Experience |
5. Pillar Three: Resilience (Resilience4j) #
In a distributed system, failure is inevitable. If the Inventory Service is slow or down, the Order Service should not hang indefinitely. It should fail fast and gracefully.
We will implement a Circuit Breaker. The concept acts like an electrical circuit breaker:
- Closed: Traffic flows normally.
- Open: Errors exceeded a threshold; traffic is blocked immediately (fail fast).
- Half-Open: After a wait duration, a few requests are let through to test if the service has recovered.
5.1 Implementing the Circuit Breaker #
We will modify the OrderService logic to use the InventoryClient guarded by a Circuit Breaker.
package com.javadevpro.order.service;
import com.javadevpro.order.client.InventoryClient;
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
@Service
public class OrderService {
private static final Logger log = LoggerFactory.getLogger(OrderService.class);
private final InventoryClient inventoryClient;
public OrderService(InventoryClient inventoryClient) {
this.inventoryClient = inventoryClient;
}
@CircuitBreaker(name = "inventory", fallbackMethod = "fallbackStockCheck")
public String placeOrder(String skuCode) {
boolean inStock = inventoryClient.checkStock(skuCode);
if (inStock) {
return "Order Placed Successfully for " + skuCode;
} else {
return "Product " + skuCode + " is out of stock.";
}
}
// Fallback method signature must match the original method + Exception
public String fallbackStockCheck(String skuCode, Throwable t) {
log.error("Inventory Service is unavailable. Reason: {}", t.getMessage());
return "Order failed. Please try again later (Service Unavailable).";
}
}5.2 Configuring Resilience4j #
Resilience logic should never be hardcoded. It belongs in your configuration.
resilience4j:
circuitbreaker:
instances:
inventory:
registerHealthIndicator: true
eventConsumerBufferSize: 10
slidingWindowType: COUNT_BASED
slidingWindowSize: 5
failureRateThreshold: 50 # If 50% of requests fail, open circuit
waitDurationInOpenState: 5s # Wait 5 seconds before trying again
permittedNumberOfCallsInHalfOpenState: 3
automaticTransitionFromOpenToHalfOpenEnabled: true
timelimiter:
instances:
inventory:
timeoutDuration: 3s # Fail if request takes longer than 3s5.3 Visualizing the Failure Scenario #
What happens when the Inventory Service goes down?
6. Performance Considerations and Best Practices #
Building these systems requires attention to detail. Here are critical performance tips for 2025.
1. Enable Virtual Threads (Java 21+) #
If you are running on Java 21, you should enable virtual threads in Spring Boot 3.2+. This allows your application to handle thousands of concurrent requests without the overhead of OS threads, which is particularly beneficial for I/O-heavy microservices (like our Order Service waiting for Inventory).
Add this to application.yml:
spring:
threads:
virtual:
enabled: true2. Timeouts are Mandatory #
Never rely on default HTTP timeouts.
- Connection Timeout: How long to wait to establish a TCP handshake. (Short, e.g., 2s).
- Read Timeout: How long to wait for data packets. (Context dependent, e.g., 5s).
In RestClient, configure these via the underlying request factory (e.g., Apache HttpClient or JDK Client).
3. Bulkhead Pattern #
While Circuit Breakers stop cascading failures, Bulkheads prevent resource exhaustion. If the Inventory Service is slow, it shouldn’t consume all threads/resources in the Order Service, preventing the Order Service from handling other tasks (like querying order history). Resilience4j supports Bulkhead configuration to limit concurrent calls to specific downstream services.
7. Common Pitfalls #
- Distributed Monolith: If Service A needs Service B to do anything, they are tight-coupled. Consider Event-Driven Architecture (Kafka/RabbitMQ) for non-critical updates.
- Config Management: Moving config to
application.ymlis fine for demos. In production, use Spring Cloud Config Server to manage configuration centrally and refresh beans dynamically without restarts. - Observability Gap: Implementing the code above without tracing is dangerous. You must add Micrometer Tracing and an exporter (like Zipkin or OTLP) to visualize the request hopping from Order to Inventory.
Conclusion #
We have successfully built a resilient microservices communication flow. We moved away from hardcoded URLs using Eureka, modernized our HTTP calls with Spring 6 Interfaces, and protected our system using Resilience4j.
Key Takeaways:
- Decoupling: Service Discovery removes IP dependency.
- Declarative Code: HTTP Interfaces make client code clean and testable.
- Safety Nets: Circuit Breakers are not optional in distributed systems; they are a requirement.
Next Steps #
To take this to production level:
- Implement Spring Cloud Gateway as the single entry point.
- Add Keycloak or OAuth2 for securing service-to-service communication.
- Integrate OpenTelemetry for full distributed tracing.
Did you find this deep dive helpful? Check out our related articles on Spring Boot 3.4 Optimizations and Event-Driven Microservices with Kafka to continue your journey.