Building Microservices with Java: Communication, Discovery, and Resilience

Splitting a monolith into microservices does not automatically make your system better. Done carelessly, it makes things worse: you trade one hard problem (a large codebase) for several harder ones (distributed state, network failures, partial availability). This post is for developers who have heard the pitch and are now asking how to actually build this thing properly. We will cover the three areas you need to get right: inter-service communication, service discovery, and resilience.


Setting Up the Service Chassis with Spring Boot

Each microservice is an independent Spring Boot application. You do not need a special framework. Spring Boot 3.x with the right dependencies gives you everything to get started.

A minimal pom.xml for a microservice:

Code
```xml <dependencies>     <dependency>         <groupId>org.springframework.boot</groupId>         <artifactId>spring-boot-starter-web</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-openfeign</artifactId>     </dependency>     <dependency>         <groupId>io.github.resilience4j</groupId>         <artifactId>resilience4j-spring-boot3</artifactId>     </dependency> </dependencies> ```

Declare the Spring Cloud BOM in dependencyManagement and it handles version alignment across all the cloud libraries, which prevents subtle dependency conflicts.

Each service should have a clear bounded context. An order-service handles orders. It does not directly query the inventory-service database; it calls the inventory-service API. That boundary is what makes the services independently deployable.


Inter-Service Communication: REST vs Messaging

You have two broad choices for how services talk to each other: synchronous (HTTP/REST) or asynchronous (message queues). Neither is universally correct.

Use synchronous REST when the caller needs an immediate response, for example when checking stock availability before confirming an order. Use asynchronous messaging (Kafka, RabbitMQ) when the result is not needed immediately, or when you want to decouple producers from consumers entirely, such as publishing an OrderPlaced event that multiple downstream services consume independently.

For synchronous communication, OpenFeign is the cleanest option in the Spring ecosystem. It turns an interface definition into an HTTP client.

Code
```java @FeignClient(name = "inventory-service") public interface InventoryClient {     @GetMapping("/api/inventory/{productId}")     InventoryResponse checkStock(@PathVariable String productId); } ```

The name attribute matches the application name registered in the service registry. Feign handles the HTTP call, serialisation, and fault tolerance (once you wire in Resilience4j). No boilerplate RestTemplate code, no manual URL construction.

In order-service, you inject it like any Spring bean:

Code
```java @Service @RequiredArgsConstructor public class OrderService {     private final InventoryClient inventoryClient;     public OrderConfirmation placeOrder(OrderRequest request) {         InventoryResponse stock = inventoryClient.checkStock(request.productId());         if (!stock.isAvailable()) {             throw new InsufficientStockException(request.productId());         }         // persist and return confirmation     } } ```

This is readable and easy to test. Mock the InventoryClient interface in unit tests and you do not need to spin up any HTTP servers.


Service Discovery with Eureka

Hard-coding service URLs into your configuration is a trap. The moment a service scales out or moves to a different host, every caller breaks. Service discovery solves this.

With Spring Cloud Netflix Eureka, you run a central registry. Services register with it at startup and query it when they need to call another service.

Setting up the Eureka server:

Code
```java @SpringBootApplication @EnableEurekaServer public class ServiceRegistryApplication {     public static void main(String[] args) {         SpringApplication.run(ServiceRegistryApplication.class, args);     } } ``` ```yaml # application.yml for the registry itself server:   port: 8761 eureka:   client:     register-with-eureka: false     fetch-registry: false ```

Registering a service:

Code
```yaml # application.yml for order-service spring:   application:     name: order-service eureka:   client:     service-url:       defaultZone: http://localhost:8761/eureka/ ```

That is all. When order-service starts, it registers itself. When it calls inventory-service via Feign, Spring Cloud uses the registry to resolve the actual host and port, including picking one instance from several if you have scaled out.

In production, you would swap Eureka for Kubernetes service discovery or a service mesh like Istio. The Spring Cloud abstraction means your service code does not change. You swap the discovery mechanism under the hood.


Resilience Patterns with Resilience4j

This is where most teams underinvest early and pay for it later. When inventory-service is slow or down, order-service should not cascade into failure. You need explicit resilience at every call boundary.

Resilience4j integrates cleanly with Spring Boot 3 and works as annotations or a programmatic API. The three patterns you will use most:

Circuit Breaker

Opens when error rates cross a threshold, stops forwarding calls to a broken downstream service, and periodically probes to see if it has recovered.

Code
```java @CircuitBreaker(name = "inventoryService", fallbackMethod = "stockCheckFallback") public InventoryResponse checkStock(String productId) {     return inventoryClient.checkStock(productId); } public InventoryResponse stockCheckFallback(String productId, Throwable t) {     log.warn("Circuit open for inventory-service, product: {}", productId, t);     return InventoryResponse.unavailable(); } ```

Configuration in application.yml:

Code
```yaml resilience4j:   circuitbreaker:     instances:       inventoryService:         sliding-window-size: 10         failure-rate-threshold: 50         wait-duration-in-open-state: 10s         permitted-number-of-calls-in-half-open-state: 3 ```

The circuit opens after 50% of the last 10 calls fail. It waits 10 seconds, then allows 3 test calls through. If those succeed, it closes again.

Retry

For transient failures (a brief network blip or a service restarting):

Code
```java @Retry(name = "inventoryService", fallbackMethod = "stockCheckFallback") @CircuitBreaker(name = "inventoryService", fallbackMethod = "stockCheckFallback") public InventoryResponse checkStock(String productId) {     return inventoryClient.checkStock(productId); } ``` ```yaml resilience4j:   retry:     instances:       inventoryService:         max-attempts: 3         wait-duration: 500ms         exponential-backoff-multiplier: 2 ```

Layer retry inside the circuit breaker. Retries should count against the breaker's failure window, not bypass it.

Timeout

Do not let a slow downstream service hold your thread pool hostage:

Code
```yaml resilience4j:   timelimiter:     instances:       inventoryService:         timeout-duration: 2s ```

A call that does not complete in 2 seconds is treated as a failure. Combined with the circuit breaker, repeated timeouts will open the circuit and route to the fallback immediately.


Distributed Tracing

Once you have more than two services, debugging a failed request across service boundaries without tracing is painful. Add Micrometer with Zipkin or a compatible backend:

Code
```xml <dependency>     <groupId>io.micrometer</groupId>     <artifactId>micrometer-tracing-bridge-brave</artifactId> </dependency> <dependency>     <groupId>io.zipkin.reporter2</groupId>     <artifactId>zipkin-reporter-brave</artifactId> </dependency> ```

Spring Boot auto-configures trace and span IDs that propagate through HTTP headers. Every log line gets a traceId, so you can follow a single request across api-gateway -> order-service -> inventory-service in Zipkin's UI without manually correlating logs.

Code
```yaml management:   tracing:     sampling:       probability: 1.0  # 100% in dev; reduce in prod ```

The Trade-offs You Need to Accept

Microservices come with costs that are real and non-negotiable.

Operational complexity. You now have N services to deploy, monitor, and version. If your team is small, this overhead is not free. A well-structured monolith is a legitimate choice for many products.

Distributed transactions are hard. You cannot call @Transactional across service boundaries. If order-service creates an order and inventory-service fails to decrement stock, you have a consistency problem. The standard answer is the Saga pattern (a sequence of local transactions with compensating steps), but it adds significant complexity. Know this going in.

Network is not reliable. Every inter-service call can fail. Every fallback path needs a test. Timeouts and circuit breakers are not optional enhancements. They are required for a production system.

Contracts between services must be managed. A change to the InventoryResponse schema that breaks order-service is a deployment problem, not just a code problem. Consumer-driven contract testing (Pact) is worth learning once your service count grows beyond a handful.


Key Takeaways

  • Use OpenFeign for synchronous inter-service calls. It removes boilerplate and integrates cleanly with service discovery.

  • Service discovery (Eureka, Kubernetes DNS) lets you scale services without reconfiguring callers.

  • Circuit breakers, retries, and timeouts are not optional. Add them to every external call boundary from the start.

  • Distributed tracing with Micrometer makes debugging across services tractable. Add it early, before you need it.

  • The decision to go microservices is an organisational and operational one as much as a technical one. The code is not the hard part.

If you are thinking about how failure spreads in distributed systems more broadly, the circuit breaker pattern is one piece of a larger picture. Resilience patterns like bulkheads and rate limiting complete the story, and are worth reading alongside this.

Share this post

You might also like