Spring BootMicroservicesArchitectureSystem Design

Microservices in Spring Boot: Architecture, Design Patterns, and Production Practices

April 10, 2024
12 min read
Technical Guide

Introduction

Microservices architecture has become the de facto standard for building scalable, resilient, and maintainable applications. In this comprehensive guide, we'll explore how to leverage the power of Spring Boot and Spring Cloud to build a robust microservices ecosystem.

Monolith vs. Microservices

FeatureMonolithic ArchitectureMicroservices Architecture
DeploymentSingle unitIndependent services
ScalingScale everythingScale individual components
ComplexityLow initially, high over timeHigh initially, managed over time
Tech StackSingle stackPolyglot (can use best tool for job)

[!TIP] When to adopt Microservices? Don't start with microservices. Start with a modular monolith. Move to microservices only when specific domains need independent scaling or team velocity is hindered by the monolith.


High-Level Architecture Overview

Before diving into each component, here's how all the pieces fit together in a typical Spring Boot microservices ecosystem:


Core Microservices Components

A robust microservices architecture relies on several key components working in harmony.

1. Service Discovery (Eureka)

In a dynamic environment where service instances come and go, hardcoding IP addresses is impossible. Spring Cloud Netflix Eureka acts as a phone book — services register themselves on startup, and other services look them up by name instead of address.

How Eureka Works

Eureka follows a client-server model:

  1. Eureka Server: A standalone registry that maintains a list of all available service instances.
  2. Eureka Clients: Each microservice registers itself with the server on startup and sends periodic heartbeats (every 30 seconds by default) to signal it's still alive.
  3. Discovery: When Service A needs to call Service B, it asks the Eureka Server for the current list of healthy Service B instances.

Setting Up the Eureka Server

First, add the dependency to your pom.xml:

java
1// pom.xml (Eureka Server)
2<dependency>
3    <groupId>org.springframework.cloud</groupId>
4    <artifactId>spring-cloud-starter-netflix-eureka-server</artifactId>
5</dependency>

Then, enable the server with a single annotation:

java
1@SpringBootApplication
2@EnableEurekaServer
3public class ServiceRegistryApplication {
4    public static void main(String[] args) {
5        SpringApplication.run(ServiceRegistryApplication.class, args);
6    }
7}

Configure application.yml for the Eureka Server:

java
1// application.yml (Eureka Server)
2server:
3  port: 8761
4
5eureka:
6  client:
7    register-with-eureka: false   # Server doesn't register with itself
8    fetch-registry: false         # Server doesn't need to fetch its own registry
9  server:
10    enable-self-preservation: true
11    eviction-interval-timer-in-ms: 5000

[!NOTE] Self-Preservation Mode When Eureka detects that too many clients have stopped sending heartbeats (e.g., due to a network partition), it enters self-preservation mode and stops evicting instances. This prevents cascade failures from wiping the registry during temporary network issues.

Registering a Microservice as an Eureka Client

Each microservice needs the client dependency:

java
1// pom.xml (Eureka Client)
2<dependency>
3    <groupId>org.springframework.cloud</groupId>
4    <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
5</dependency>

And the configuration:

java
1// application.yml (e.g., Order Service)
2spring:
3  application:
4    name: order-service     # This is the name other services use to discover it
5
6eureka:
7  client:
8    service-url:
9      defaultZone: http://localhost:8761/eureka/
10  instance:
11    prefer-ip-address: true
12    instance-id: ${spring.application.name}:${random.value}

Service-to-Service Communication via Eureka

Once registered, services can call each other by name instead of hardcoded URLs. Use a load-balanced RestTemplate or WebClient:

java
1@Bean
2@LoadBalanced   // Enables service name resolution via Eureka
3public RestTemplate restTemplate() {
4    return new RestTemplate();
5}
6
7// Usage — "inventory-service" is the spring.application.name
8String url = "http://inventory-service/api/v1/stock/{productId}";
9StockResponse stock = restTemplate.getForObject(url, StockResponse.class, productId);

The @LoadBalanced annotation integrates with Spring Cloud LoadBalancer, which fetches the list of instances from Eureka and distributes requests using round-robin by default.

Eureka Dashboard

Once running, the Eureka Server provides a built-in dashboard at http://localhost:8761. It shows:

  • All registered services and their instances
  • Instance health status (UP, DOWN, OUT_OF_SERVICE)
  • Self-preservation mode status
  • Lease expiration and renewal information
Eureka Config PropertyDefaultPurpose
lease-renewal-interval-in-seconds30How often the client sends heartbeats
lease-expiration-duration-in-seconds90How long the server waits before evicting
registry-fetch-interval-seconds30How often clients refresh their local cache
enable-self-preservationtruePrevents mass eviction during network partitions

[!TIP] Production Best Practice Run at least two Eureka Server instances in a peer-aware setup for high availability. Configure them to register with each other so the registry survives a single-node failure.

2. API Gateway (Spring Cloud Gateway)

The Gateway is the single entry point for all client requests. It handles routing, security, and rate limiting.

Key responsibilities:

  • Routing: Forwarding requests to the correct service.
  • Security: Authentication and authorization (OAuth2).
  • Resilience: Retry logic and circuit breaking.

3. Inter-service Communication

Services often need to talk to each other.

  • Synchronous: REST (using RestClient or WebClient) or Feign Client.
  • Asynchronous: Event-driven architecture using Kafka or RabbitMQ.

OpenFeign — Declarative REST Client

While RestTemplate and WebClient work, they require boilerplate code for every service call. Spring Cloud OpenFeign takes a different approach — you declare an interface, and Feign generates the HTTP client for you. Combined with Eureka, it automatically resolves service names and load-balances requests.

Add the dependency:

java
1// pom.xml
2<dependency>
3    <groupId>org.springframework.cloud</groupId>
4    <artifactId>spring-cloud-starter-openfeign</artifactId>
5</dependency>

Enable Feign in your main application:

java
1@SpringBootApplication
2@EnableFeignClients   // Scans for @FeignClient interfaces
3public class OrderServiceApplication {
4    public static void main(String[] args) {
5        SpringApplication.run(OrderServiceApplication.class, args);
6    }
7}

Defining a Feign Client

Create an interface that mirrors the target service's API. Feign handles the rest:

java
1@FeignClient(name = "inventory-service")  // Matches the spring.application.name in Eureka
2public interface InventoryClient {
3
4    @GetMapping("/api/v1/stock/{productId}")
5    StockResponse getStock(@PathVariable("productId") String productId);
6
7    @PostMapping("/api/v1/stock/reserve")
8    ReservationResponse reserveStock(@RequestBody ReserveRequest request);
9
10    @GetMapping("/api/v1/stock/bulk")
11    List<StockResponse> getBulkStock(@RequestParam("ids") List<String> productIds);
12}

Usage in a service — just inject and call:

java
1@Service
2@RequiredArgsConstructor
3public class OrderService {
4
5    private final InventoryClient inventoryClient;
6
7    public OrderResponse createOrder(OrderRequest request) {
8        // Feign handles: Eureka lookup → load balancing → HTTP call → deserialization
9        StockResponse stock = inventoryClient.getStock(request.getProductId());
10
11        if (!stock.isAvailable()) {
12            throw new InsufficientStockException("Product out of stock");
13        }
14
15        inventoryClient.reserveStock(new ReserveRequest(request.getProductId(), request.getQuantity()));
16        return orderRepository.save(Order.from(request)).toResponse();
17    }
18}

[!TIP] Why Feign over RestTemplate?

  • No boilerplate — no URL construction, no getForObject() calls, no manual deserialization.
  • Type-safe — compile-time checking of request/response types.
  • Built-in integration — works seamlessly with Eureka, LoadBalancer, and Circuit Breakers.
  • Readable — the interface reads like API documentation.

Feign with Fallbacks (Resilience)

Combine Feign with Resilience4j to handle failures gracefully:

java
1@FeignClient(
2    name = "inventory-service",
3    fallback = InventoryFallback.class
4)
5public interface InventoryClient {
6    @GetMapping("/api/v1/stock/{productId}")
7    StockResponse getStock(@PathVariable("productId") String productId);
8}
9
10@Component
11public class InventoryFallback implements InventoryClient {
12    @Override
13    public StockResponse getStock(String productId) {
14        // Return a safe default when inventory-service is down
15        return StockResponse.builder()
16            .productId(productId)
17            .available(false)
18            .message("Inventory service temporarily unavailable")
19            .build();
20    }
21}

Feign Configuration

Customize timeouts, logging, and retry behavior per client:

java
1// application.yml
2spring:
3  cloud:
4    openfeign:
5      client:
6        config:
7          default:                    # Applies to all Feign clients
8            connect-timeout: 5000
9            read-timeout: 5000
10            logger-level: basic
11          inventory-service:          # Override for a specific client
12            connect-timeout: 3000
13            read-timeout: 10000
14            logger-level: full        # Logs headers, body, and metadata
Feign Logger LevelWhat It Logs
NONENo logging (default)
BASICRequest method, URL, response status, and execution time
HEADERSBasic + request/response headers
FULLHeaders + request/response body (use only in development)

Feign with Request Interceptors

Pass authentication tokens or custom headers automatically to all downstream calls:

java
1@Bean
2public RequestInterceptor authInterceptor() {
3    return template -> {
4        // Forward the JWT token from the current request context
5        String token = SecurityContextHolder.getContext()
6            .getAuthentication().getCredentials().toString();
7        template.header("Authorization", "Bearer " + token);
8    };
9}

[!WARNING] Feign is Synchronous Feign clients are blocking by default. For high-throughput scenarios where you don't need the response immediately, prefer asynchronous communication via message queues (Kafka, RabbitMQ). Use Feign for request-reply patterns where you need the result right away.

[!WARNING] Avoid Distributed Transactions Trying to maintain ACID properties across services is a recipe for disaster. Use Sagas or Eventual Consistency patterns instead.


Resilience & Observability

Distributed systems fail. It's not a matter of if, but when.

Circuit Breaker (Resilience4j)

Prevent cascading failures by failing fast when a dependent service is down.

  • CLOSED: All requests pass through normally. Failures are counted.
  • OPEN: All requests are immediately rejected and routed to the fallback. No calls hit the failing service.
  • HALF-OPEN: After a wait period, a few test requests are allowed through. If they succeed, the breaker closes. If they fail, it opens again.
java
1@CircuitBreaker(name = "inventory", fallbackMethod = "fallbackInventory")
2public String getInventoryStatus(String productId) {
3    return inventoryClient.getStatus(productId);
4}
5
6public String fallbackInventory(String productId, Throwable t) {
7    return "Inventory Temporarily Unavailable";
8}

Distributed Tracing

With requests jumping between multiple services, debugging is hard. Tools like Zipkin or Jaeger (via Micrometer Tracing) are essential to visualize the request path.

Each span represents a unit of work. The trace connects all spans for a single request, making it easy to pinpoint exactly where latency or failures occur.


Deployment & Scalability

Docker & Kubernetes

Containerization is non-negotiable. Each Spring Boot application should be packaged as a Docker container.

dockerfile
1FROM eclipse-temurin:17-jdk-alpine
2VOLUME /tmp
3COPY target/*.jar app.jar
4ENTRYPOINT ["java","-jar","/app.jar"]

Kubernetes (K8s) orchestrates these containers, handling:

  • Auto-scaling: Based on CPU/Memory usage.
  • Self-healing: Restarting crashed containers.
  • Load Balancing: Distributing traffic.

Conclusion

Migrating to microservices is a journey, not a destination. It requires a shift in mindset—from "building an app" to "building a distributed system."

Key Takeaways:

  1. Isolate domains carefully (Domain-Driven Design).
  2. Automate everything (CI/CD, Infrastructure as Code).
  3. Monitor aggressively (Logs, Metrics, Traces).

Start small, extract one service at a time, and validate your assumptions.

Enjoyed this article?

Check out my other projects or get in touch.

Microservices in Spring Boot: Architecture, Design Patterns, and Production Practices | Puspo