Skip to main content
Innovation|Innovation

Microservices with Spring Cloud: Breaking Big Apps into Small Pieces

A beginner-friendly guide to building microservices with Spring Cloud — covering API Gateway, Eureka service discovery, Config Server, Resilience4j circuit breakers, load balancing, Feign clients, WebClient, distributed tracing with Micrometer and Zipkin, and inter-service authentication.

April 8, 202614 min read3 views0 comments
Share:

What Are Microservices and Why Should You Care?

Imagine you run a huge restaurant where one giant kitchen handles everything: appetizers, main courses, desserts, drinks, and billing. If the dessert chef calls in sick, the entire restaurant shuts down. That is a monolith — one big application that does everything.

Now imagine a food court instead. There is a pizza counter, a burger counter, a juice bar, and a billing kiosk. Each counter runs independently. If the juice bar breaks down, everyone else keeps serving. That is microservices — many small, independent services that each do one thing well.

Spring Cloud gives Java developers a toolbox to build, connect, and manage these small services. Let us walk through every tool in the box.

Monolith vs. Microservices: A Side-by-Side Look

AspectMonolith (Big Restaurant)Microservices (Food Court)
DeploymentOne big JAR — deploy the whole thingEach service deploys independently
ScalingScale everything even if only one part is busyScale only the busy counter (service)
FailureOne bug can crash the whole appOne service fails, others keep running
Team ownershipEveryone works in the same codebaseEach team owns its own service
Tech stackLocked into one language/frameworkEach service can use different tech
ComplexitySimple at first, painful when it growsMore moving parts from day one

Rule of thumb: Start with a monolith for a small project. Move to microservices when your team and traffic grow big enough that the monolith becomes a bottleneck.

API Gateway (Spring Cloud Gateway)

Think of the API Gateway as the front desk of a hotel. Guests (clients) do not walk directly to the kitchen or housekeeping. They go to the front desk, which routes them to the right department. The gateway does the same thing for HTTP requests.

Spring Cloud Gateway sits in front of all your microservices. It handles:

  • Routing — Send /api/orders/** to the order-service, /api/users/** to the user-service
  • Load balancing — Spread requests across multiple instances of a service
  • Filters — Add headers, rate-limit, log, or authenticate before forwarding
  • Single entry point — Clients only need to know one URL

Gateway Configuration (application.yml)

spring:
  cloud:
    gateway:
      routes:
        - id: order-service
          uri: lb://ORDER-SERVICE
          predicates:
            - Path=/api/orders/**
          filters:
            - StripPrefix=1

        - id: inventory-service
          uri: lb://INVENTORY-SERVICE
          predicates:
            - Path=/api/inventory/**
          filters:
            - StripPrefix=1

        - id: user-service
          uri: lb://USER-SERVICE
          predicates:
            - Path=/api/users/**
          filters:
            - StripPrefix=1

        - id: notification-service
          uri: lb://NOTIFICATION-SERVICE
          predicates:
            - Path=/api/notifications/**
          filters:
            - StripPrefix=1

The lb:// prefix tells the gateway to use load balancing and look up the service by name from the service registry (Eureka). StripPrefix=1 removes /api before forwarding, so /api/orders/123 becomes /orders/123 when it reaches the order-service.

Custom Global Filter (Java)

You can add cross-cutting logic — like logging every request — using a global filter:

@Component
public class LoggingFilter implements GlobalFilter, Ordered {

    private static final Logger log = LoggerFactory.getLogger(LoggingFilter.class);

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        String path = exchange.getRequest().getURI().getPath();
        String method = exchange.getRequest().getMethod().name();
        log.info("Incoming request: {} {}", method, path);

        return chain.filter(exchange).then(Mono.fromRunnable(() ->
            log.info("Response status: {}", exchange.getResponse().getStatusCode())
        ));
    }

    @Override
    public int getOrder() {
        return -1; // Run before other filters
    }
}

Service Discovery (Eureka)

In a food court, how does the front desk know which counters are open today? Someone keeps a live list. That is exactly what Eureka does. Every microservice registers itself with Eureka when it starts up, and Eureka keeps track of which services are alive and where they live (IP + port).

Without service discovery, you would have to hardcode URLs like http://192.168.1.10:8080. If that server moves or you add a second instance, everything breaks. With Eureka, services just say "I am ORDER-SERVICE" and other services find them by name.

Setting Up the Eureka Server

@SpringBootApplication
@EnableEurekaServer
public class DiscoveryServerApplication {
    public static void main(String[] args) {
        SpringApplication.run(DiscoveryServerApplication.class, args);
    }
}
# Eureka Server application.yml
server:
  port: 8761

eureka:
  client:
    register-with-eureka: false   # Server does not register with itself
    fetch-registry: false

Registering a Service (Eureka Client)

Each microservice adds the Eureka client dependency and a few lines of config:

# order-service application.yml
spring:
  application:
    name: ORDER-SERVICE

eureka:
  client:
    service-url:
      defaultZone: http://localhost:8761/eureka/
  instance:
    prefer-ip-address: true

Now Eureka knows: "ORDER-SERVICE is running at 192.168.1.10:8081, and also at 192.168.1.11:8081." Other services can find it by name.

Centralized Configuration (Spring Cloud Config Server)

Imagine every food court counter has its own recipe book. If you need to change the salt level across all recipes, you have to visit every counter one by one. That is painful.

Spring Cloud Config Server is like a shared recipe book stored in one place (usually a Git repository). All services pull their configuration from this central location. Change it once, and every service picks it up.

Config Server Setup

@SpringBootApplication
@EnableConfigServer
public class ConfigServerApplication {
    public static void main(String[] args) {
        SpringApplication.run(ConfigServerApplication.class, args);
    }
}
# Config Server application.yml
server:
  port: 8888

spring:
  cloud:
    config:
      server:
        git:
          uri: https://github.com/your-org/config-repo
          default-label: main
          search-paths: '{application}'

Client-Side Config (order-service)

# order-service application.yml
spring:
  application:
    name: order-service
  config:
    import: optional:configserver:http://localhost:8888

The config server looks for a file named order-service.yml in the Git repo and serves it. You can use profiles too: order-service-dev.yml, order-service-prod.yml.

Refreshing Config at Runtime

Mark beans with @RefreshScope and hit POST /actuator/refresh — the bean re-reads its config without restarting the service:

@RestController
@RefreshScope
public class OrderController {

    @Value("${order.max-items:50}")
    private int maxItems;

    @GetMapping("/orders/max-items")
    public int getMaxItems() {
        return maxItems;
    }
}

Circuit Breaker (Resilience4j)

A circuit breaker is like a fuse in your house. If too much electricity flows through a wire, the fuse breaks to protect your appliances from catching fire. Once you fix the problem, you flip the fuse back on.

In microservices, if the inventory-service is down, the order-service should not keep hammering it with requests. That would waste resources and make things worse. Instead, the circuit breaker "trips" and returns a fallback response immediately.

The Three States

StateWhat HappensAnalogy
CLOSEDEverything is normal. Requests flow through to the service.Fuse is intact. Electricity flows normally.
OPENToo many failures! Requests are immediately rejected with a fallback.Fuse has blown. No electricity flows. Appliances are safe.
HALF-OPENLet a few test requests through to see if the service recovered.You flip the fuse back on and plug in one appliance to test.

Flow: CLOSED (normal) → failures pile up → OPEN (block requests) → wait timer expires → HALF-OPEN (test a few) → if tests pass → back to CLOSED. If tests fail → back to OPEN.

Resilience4j Configuration

# application.yml
resilience4j:
  circuitbreaker:
    instances:
      inventoryService:
        sliding-window-size: 10            # Look at the last 10 calls
        failure-rate-threshold: 50         # Trip if 50% of calls fail
        wait-duration-in-open-state: 30s   # Stay OPEN for 30 seconds
        permitted-number-of-calls-in-half-open-state: 5  # Test with 5 calls
        automatic-transition-from-open-to-half-open-enabled: true

  retry:
    instances:
      inventoryService:
        max-attempts: 3
        wait-duration: 2s

  timelimiter:
    instances:
      inventoryService:
        timeout-duration: 5s              # Give up after 5 seconds

Circuit Breaker in Java Code

@Service
public class OrderService {

    private final InventoryClient inventoryClient;

    public OrderService(InventoryClient inventoryClient) {
        this.inventoryClient = inventoryClient;
    }

    @CircuitBreaker(name = "inventoryService", fallbackMethod = "inventoryFallback")
    @Retry(name = "inventoryService")
    @TimeLimiter(name = "inventoryService")
    public CompletableFuture<Boolean> checkStock(String sku, int quantity) {
        return CompletableFuture.supplyAsync(() ->
            inventoryClient.isInStock(sku, quantity)
        );
    }

    // Fallback: runs when circuit is OPEN or call fails after retries
    public CompletableFuture<Boolean> inventoryFallback(String sku, int quantity, Throwable t) {
        // Log the error and return a safe default
        log.warn("Inventory service unavailable for SKU {}. Assuming out of stock.", sku);
        return CompletableFuture.completedFuture(false);
    }
}

When the circuit is OPEN, the inventoryFallback method runs instantly without even trying to call the inventory-service. This protects both your service and the struggling downstream service.

Client-Side Load Balancing

If the pizza counter in your food court is super popular, you open a second pizza counter. Now the front desk needs to decide: "Send this customer to counter A or counter B?" That is load balancing.

Spring Cloud uses Spring Cloud LoadBalancer (the successor to Ribbon) to distribute requests across multiple instances of a service. It works automatically with Eureka — just use the service name instead of a hardcoded URL.

@Configuration
public class WebClientConfig {

    @Bean
    @LoadBalanced   // This annotation enables client-side load balancing
    public WebClient.Builder webClientBuilder() {
        return WebClient.builder();
    }
}

@Service
public class OrderService {

    private final WebClient.Builder webClientBuilder;

    public OrderService(WebClient.Builder webClientBuilder) {
        this.webClientBuilder = webClientBuilder;
    }

    public Mono<InventoryResponse> checkInventory(String sku) {
        // "INVENTORY-SERVICE" is looked up from Eureka, load-balanced
        return webClientBuilder.build()
            .get()
            .uri("http://INVENTORY-SERVICE/inventory/{sku}", sku)
            .retrieve()
            .bodyToMono(InventoryResponse.class);
    }
}

The @LoadBalanced annotation tells Spring to intercept the request, look up all instances of INVENTORY-SERVICE from Eureka, and pick one using a strategy (round-robin by default).

Inter-Service Communication

In a food court, counters need to talk to each other. The burger counter might ask the inventory counter: "Do we still have buns?" There are two main ways services communicate:

1. Feign Clients (Declarative REST)

Feign lets you call another service as if you are calling a local Java method. You write an interface, and Feign builds the HTTP client for you.

// Define the client — looks like a regular Java interface
@FeignClient(name = "INVENTORY-SERVICE")
public interface InventoryClient {

    @GetMapping("/inventory/{sku}")
    InventoryResponse getInventory(@PathVariable String sku);

    @PostMapping("/inventory/reserve")
    ReservationResponse reserveStock(@RequestBody ReservationRequest request);
}

// Use it — just inject and call like any Spring bean
@Service
public class OrderService {

    private final InventoryClient inventoryClient;

    public OrderService(InventoryClient inventoryClient) {
        this.inventoryClient = inventoryClient;
    }

    public OrderResponse placeOrder(OrderRequest request) {
        // This looks like a local method call, but it is an HTTP GET to inventory-service
        InventoryResponse stock = inventoryClient.getInventory(request.getSku());

        if (stock.getQuantity() < request.getQuantity()) {
            throw new InsufficientStockException("Not enough stock for: " + request.getSku());
        }

        // Reserve the stock
        inventoryClient.reserveStock(new ReservationRequest(
            request.getSku(), request.getQuantity()
        ));

        return new OrderResponse("Order placed successfully");
    }
}

Enable Feign in your main class:

@SpringBootApplication
@EnableFeignClients
public class OrderServiceApplication {
    public static void main(String[] args) {
        SpringApplication.run(OrderServiceApplication.class, args);
    }
}

2. WebClient (Reactive/Non-Blocking)

WebClient is the modern, non-blocking alternative. Use it when you need reactive streams or want to make multiple calls in parallel:

@Service
public class OrderService {

    private final WebClient.Builder webClientBuilder;

    public OrderService(@LoadBalanced WebClient.Builder webClientBuilder) {
        this.webClientBuilder = webClientBuilder;
    }

    // Sequential call
    public Mono<InventoryResponse> checkInventory(String sku) {
        return webClientBuilder.build()
            .get()
            .uri("http://INVENTORY-SERVICE/inventory/{sku}", sku)
            .retrieve()
            .bodyToMono(InventoryResponse.class);
    }

    // Parallel calls — check inventory AND get user info at the same time
    public Mono<OrderSummary> getOrderSummary(String sku, String userId) {
        Mono<InventoryResponse> inventory = webClientBuilder.build()
            .get()
            .uri("http://INVENTORY-SERVICE/inventory/{sku}", sku)
            .retrieve()
            .bodyToMono(InventoryResponse.class);

        Mono<UserResponse> user = webClientBuilder.build()
            .get()
            .uri("http://USER-SERVICE/users/{id}", userId)
            .retrieve()
            .bodyToMono(UserResponse.class);

        // Both calls happen at the same time!
        return Mono.zip(inventory, user, (inv, usr) ->
            new OrderSummary(usr.getName(), inv.getQuantity(), inv.getPrice())
        );
    }
}

When to Use Which?

FeatureFeign ClientWebClient
StyleDeclarative (interface-based)Programmatic (builder-based)
Blocking?Yes (by default)No (non-blocking/reactive)
Parallel callsHarder (need threads)Easy with Mono.zip()
Best forSimple service-to-service REST callsHigh-throughput reactive systems

Distributed Tracing (Micrometer + Zipkin)

When a customer at a food court complains about a bad meal, how do you trace which counter made it? You need a receipt number that follows the order across every counter it touches.

In microservices, a single user request might hop through 5 services. If something goes wrong, you need to know: "Where exactly did it fail?" Distributed tracing assigns a unique Trace ID to each request and tracks it across every service hop.

Key Concepts

  • Trace — The entire journey of a request (like the full receipt). Has one Trace ID.
  • Span — One step in the journey (like one counter's work). Each span has a Span ID.
  • Parent-Child — If order-service calls inventory-service, the inventory span is a child of the order span.

Setting Up Tracing

Add these dependencies to every service:

<!-- Micrometer Tracing + Brave bridge -->
<dependency>
    <groupId>io.micrometer</groupId>
    <artifactId>micrometer-tracing-bridge-brave</artifactId>
</dependency>

<!-- Send traces to Zipkin -->
<dependency>
    <groupId>io.zipkin.reporter2</groupId>
    <artifactId>zipkin-reporter-brave</artifactId>
</dependency>
# application.yml (add to every service)
management:
  tracing:
    sampling:
      probability: 1.0   # Sample 100% of requests (use 0.1 for 10% in production)
  zipkin:
    tracing:
      endpoint: http://localhost:9411/api/v2/spans

logging:
  pattern:
    level: "%5p [${spring.application.name},%X{traceId},%X{spanId}]"

With this logging pattern, every log line includes the service name, trace ID, and span ID:

INFO [order-service,abc123def456,span789] Placing order for SKU: WIDGET-001
INFO [inventory-service,abc123def456,span012] Checking stock for SKU: WIDGET-001
INFO [notification-service,abc123def456,span345] Sending confirmation email

Notice the trace ID abc123def456 is the same across all three services. You can search for it in Zipkin and see the entire request journey visualized as a timeline.

Inter-Service Authentication

In a food court, not just anyone can walk into the kitchen. Staff wear ID badges to prove who they are. Microservices need the same thing — when order-service calls inventory-service, inventory-service needs to verify that the caller is legitimate and not some random outsider.

Approach 1: JWT Token Propagation

The user's JWT token is passed from service to service. Each service validates it.

// Feign RequestInterceptor — automatically forward the JWT
@Component
public class AuthFeignInterceptor implements RequestInterceptor {

    @Override
    public void apply(RequestTemplate template) {
        // Get the current request's Authorization header
        ServletRequestAttributes attributes =
            (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();

        if (attributes != null) {
            String authHeader = attributes.getRequest().getHeader("Authorization");
            if (authHeader != null) {
                template.header("Authorization", authHeader);
            }
        }
    }
}

Approach 2: API Key for Internal Services

For service-to-service calls that do not originate from a user (e.g., scheduled jobs), use a shared API key:

// Sending side — add API key to outgoing requests
@Component
public class InternalApiKeyInterceptor implements RequestInterceptor {

    @Value("${internal.api-key}")
    private String apiKey;

    @Override
    public void apply(RequestTemplate template) {
        template.header("X-Internal-API-Key", apiKey);
    }
}

// Receiving side — validate API key on incoming requests
@Component
public class ApiKeyFilter extends OncePerRequestFilter {

    @Value("${internal.api-key}")
    private String expectedApiKey;

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {

        String apiKey = request.getHeader("X-Internal-API-Key");

        // Allow if valid API key OR if already authenticated via JWT
        if (expectedApiKey.equals(apiKey) || isAlreadyAuthenticated()) {
            filterChain.doFilter(request, response);
        } else {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.getWriter().write("{\"error\": \"Invalid or missing API key\"}");
        }
    }

    private boolean isAlreadyAuthenticated() {
        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
        return auth != null && auth.isAuthenticated()
               && !(auth instanceof AnonymousAuthenticationToken);
    }
}

Spring Security Config for Internal Endpoints

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
            .csrf(csrf -> csrf.disable())
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/actuator/**").permitAll()
                .requestMatchers("/internal/**").hasRole("SERVICE")
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
            .build();
    }
}

Putting It All Together: The Full Architecture

Here is how all the pieces fit together in a typical microservices setup:

                          [Client / Browser]
                                |
                         [API Gateway :8080]
                          /     |     \
                         /      |      \
              [Order :8081] [User :8082] [Notification :8083]
                  |              |
           [Inventory :8084]    |
                  |              |
              [Eureka :8761]  (Service Discovery)
              [Config :8888]  (Central Config)
              [Zipkin :9411]  (Distributed Tracing)
  1. Client sends POST /api/orders to the API Gateway
  2. Gateway looks up ORDER-SERVICE in Eureka, routes to an instance
  3. Order-service calls INVENTORY-SERVICE via Feign (load-balanced, circuit-breaker protected)
  4. Order-service calls NOTIFICATION-SERVICE to send a confirmation
  5. Every hop is traced by Micrometer + Zipkin with the same Trace ID
  6. All services pull config from Config Server

Best Practices for Microservices

  • One database per service — Never share a database between services. Each service owns its data.
  • API-first design — Define your REST contracts (OpenAPI/Swagger) before writing code.
  • Health checks — Use Spring Boot Actuator's /actuator/health endpoint. Eureka uses it to know if your service is alive.
  • Graceful degradation — Always have a fallback. If a downstream service is down, return cached data or a safe default.
  • Centralized logging — Use the Trace ID in every log line. Ship logs to a central place (ELK stack, Grafana Loki).
  • Containerize everything — Each service gets its own Docker image. Use Kubernetes or Docker Compose to orchestrate.
  • Automate testing — Unit tests + integration tests + contract tests (Spring Cloud Contract).

Starter Dependencies (pom.xml)

Here are the key dependencies you need for a Spring Cloud microservice:

<properties>
    <java.version>21</java.version>
    <spring-cloud.version>2024.0.1</spring-cloud.version>
</properties>

<dependencies>
    <!-- Spring Boot Web -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <!-- Eureka Client (Service Discovery) -->
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
    </dependency>

    <!-- Config Client -->
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-config</artifactId>
    </dependency>

    <!-- Feign Client -->
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-openfeign</artifactId>
    </dependency>

    <!-- Resilience4j Circuit Breaker -->
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-circuitbreaker-resilience4j</artifactId>
    </dependency>

    <!-- Micrometer Tracing -->
    <dependency>
        <groupId>io.micrometer</groupId>
        <artifactId>micrometer-tracing-bridge-brave</artifactId>
    </dependency>

    <!-- Zipkin Reporter -->
    <dependency>
        <groupId>io.zipkin.reporter2</groupId>
        <artifactId>zipkin-reporter-brave</artifactId>
    </dependency>

    <!-- Actuator (health, metrics) -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-actuator</artifactId>
    </dependency>
</dependencies>

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

Frequently Asked Questions

When should I switch from a monolith to microservices?

Do not start with microservices for a new project. Start with a well-structured monolith. Switch when you hit real pain points: the codebase is too large for one team, deployments take too long because one change requires redeploying everything, or different parts of the app need to scale independently. If a team of 3 developers is building a new product, a monolith is almost always the right choice.

What happens if the Eureka server goes down?

Services cache the registry locally. If Eureka goes down temporarily, services still know about each other from the cached copy. However, no new services can register and no health updates happen. In production, run multiple Eureka instances (a cluster) so that if one goes down, the others keep working. You can configure peer awareness with eureka.client.service-url.defaultZone pointing to multiple Eureka URLs.

How is a circuit breaker different from a retry?

A retry says: "That failed? Try again." A circuit breaker says: "Too many things have failed recently. Stop trying for a while." They work together. You typically retry 2-3 times first. If the failures keep happening across many calls, the circuit breaker trips open and stops all calls to the failing service. Think of it this way: retry handles occasional hiccups (network blip), circuit breaker handles sustained outages (service is down for minutes).

Can I use Kafka or RabbitMQ instead of REST for inter-service communication?

Yes, and you often should. REST (synchronous) is good for "I need an answer right now" — like checking if an item is in stock before placing an order. Message queues (asynchronous) are better for "do this whenever you get a chance" — like sending a confirmation email or updating analytics. Most real systems use both: REST for queries and commands that need immediate responses, and message queues for events and notifications. Spring Cloud Stream makes it easy to use Kafka or RabbitMQ with minimal code changes.

How do I handle database transactions across multiple microservices?

This is one of the hardest problems in microservices. You cannot use a traditional database transaction across services because each service has its own database. Instead, use the Saga pattern. A saga is a sequence of local transactions. If step 3 fails, you run "compensating transactions" to undo steps 1 and 2. For example: Order-service creates an order (step 1), inventory-service reserves stock (step 2). If payment fails (step 3), inventory-service releases the stock (compensate step 2) and order-service cancels the order (compensate step 1). Spring Cloud does not provide a built-in saga framework, but libraries like Axon Framework or Eventuate Tram can help.


Comments


Login to join the conversation.

Loading comments…

More from Innovation