Spring Boot Microservices Full Stack: Eureka + Gateway + Config Server + Resilience4j

Building microservices requires more than splitting a monolith into services. You need service discovery, a gateway to route traffic, centralised configuration management, and resilience patterns to handle inevitable failures. Spring Cloud provides all of this as a cohesive stack.

This guide builds a complete microservices architecture from scratch: Eureka for service discovery, Spring Cloud Gateway for routing, Spring Cloud Config Server for centralised config, and Resilience4j for circuit breakers and retry.


Architecture Overview

                         ┌─────────────────┐
                         │   Config Server  │
                         │  (Git / Vault)   │
                         └────────┬────────┘
                                  │ config
          ┌───────────────────────┼───────────────────────┐
          │                       │                       │
          ▼                       ▼                       ▼
┌─────────────────┐    ┌──────────────────┐    ┌──────────────────┐
│   Order Service  │    │ Payment Service  │    │ Customer Service │
│   :8081          │    │   :8082          │    │   :8083          │
└────────┬────────┘    └────────┬─────────┘    └────────┬─────────┘
         │                      │                       │
         └──────────────────────┼───────────────────────┘
                                │ register
                                ▼
                    ┌──────────────────────┐
                    │   Eureka Server      │
                    │   Service Registry   │
                    └──────────┬───────────┘
                               │ discover
                               ▼
                    ┌──────────────────────┐
                    │  Spring Cloud        │
                    │  Gateway  :8080      │
                    └──────────────────────┘
                               │
                    External clients

1. Config Server

All services pull their configuration from Config Server at startup. No more changing environment variables per service.

Setup

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-config-server</artifactId>
</dependency>
@SpringBootApplication
@EnableConfigServer
public class ConfigServerApplication {
    public static void main(String[] args) {
        SpringApplication.run(ConfigServerApplication.class, args);
    }
}
# application.yaml — config-server
server:
  port: 8888

spring:
  cloud:
    config:
      server:
        git:
          uri: https://github.com/myorg/config-repo
          default-label: main
          search-paths: '{application}'  # look in config-repo/{service-name}/
          clone-on-start: true

Config repo structure

config-repo/
├── order-service/
│   ├── application.yaml         # all environments
│   ├── application-dev.yaml     # dev only
│   └── application-prod.yaml    # prod only
├── payment-service/
│   └── application.yaml
└── application.yaml             # shared by all services

Service configuration (client side)

<!-- Every microservice needs this -->
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-config</artifactId>
</dependency>
# bootstrap.yaml (or application.yaml with spring.config.import)
spring:
  application:
    name: order-service      # maps to config-repo/order-service/
  config:
    import: optional:configserver:http://config-server:8888
  profiles:
    active: ${SPRING_PROFILES_ACTIVE:dev}

Refresh config without restart

# Trigger config refresh on a running service (requires spring-boot-starter-actuator)
curl -X POST http://order-service:8081/actuator/refresh

Add @RefreshScope to beans that hold config values:

@RefreshScope
@RestController
public class FeatureFlagController {

    @Value("${features.new-checkout-enabled:false}")
    private boolean newCheckoutEnabled;

    @GetMapping("/checkout-version")
    public String checkoutVersion() {
        return newCheckoutEnabled ? "v2" : "v1";
    }
}

2. Eureka Service Discovery

Each service registers itself with Eureka. The Gateway and other services look up instances by name instead of hardcoded URLs.

Eureka Server

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-netflix-eureka-server</artifactId>
</dependency>
@SpringBootApplication
@EnableEurekaServer
public class EurekaServerApplication {
    public static void main(String[] args) {
        SpringApplication.run(EurekaServerApplication.class, args);
    }
}
# application.yaml — eureka-server
server:
  port: 8761

eureka:
  instance:
    hostname: eureka-server
  client:
    register-with-eureka: false  # Eureka server doesn't register itself
    fetch-registry: false

Eureka Client (every microservice)

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
# order-service application.yaml
eureka:
  client:
    service-url:
      defaultZone: http://eureka-server:8761/eureka/
  instance:
    prefer-ip-address: true
    lease-renewal-interval-in-seconds: 10
    lease-expiration-duration-in-seconds: 30

No @EnableEurekaClient annotation needed — the dependency auto-configures registration.

Load-balanced inter-service calls

@Configuration
public class WebClientConfig {

    @Bean
    @LoadBalanced  // resolves service names via Eureka
    public WebClient.Builder webClientBuilder() {
        return WebClient.builder();
    }
}

@Service
public class OrderService {

    private final WebClient webClient;

    public OrderService(WebClient.Builder builder) {
        this.webClient = builder.baseUrl("http://payment-service").build();
        //                                  ^^ Eureka service name, not a URL
    }

    public PaymentResult requestPayment(PaymentRequest request) {
        return webClient.post()
            .uri("/payments")
            .bodyValue(request)
            .retrieve()
            .bodyToMono(PaymentResult.class)
            .block();
    }
}

@LoadBalanced intercepts the call, looks up payment-service instances in Eureka, and picks one using round-robin by default.


3. Spring Cloud Gateway

Spring Cloud Gateway replaces Zuul. It’s reactive, handles routing, rate limiting, circuit breaking, and authentication at the edge.

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

Route configuration

# gateway application.yaml
spring:
  cloud:
    gateway:
      discovery:
        locator:
          enabled: true            # auto-create routes from Eureka registry
          lower-case-service-id: true
      routes:
        - id: order-service
          uri: lb://order-service  # lb:// = load-balanced via Eureka
          predicates:
            - Path=/api/orders/**
          filters:
            - StripPrefix=1        # removes /api prefix before forwarding
            - name: CircuitBreaker
              args:
                name: orderCircuitBreaker
                fallbackUri: forward:/fallback/orders
            - name: RequestRateLimiter
              args:
                redis-rate-limiter.replenishRate: 100
                redis-rate-limiter.burstCapacity: 200

        - id: payment-service
          uri: lb://payment-service
          predicates:
            - Path=/api/payments/**
          filters:
            - StripPrefix=1

Fallback controller

@RestController
public class FallbackController {

    @GetMapping("/fallback/orders")
    public ResponseEntity<Map<String, String>> ordersFallback() {
        return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
            .body(Map.of(
                "error", "Order service is temporarily unavailable",
                "retryAfter", "30"
            ));
    }
}

Adding authentication at the Gateway

@Component
public class JwtAuthenticationFilter implements GlobalFilter, Ordered {

    private final JwtValidator jwtValidator;

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        String token = extractToken(exchange.getRequest());

        if (token == null) {
            exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
            return exchange.getResponse().setComplete();
        }

        return jwtValidator.validate(token)
            .flatMap(claims -> {
                // Add user info as headers for downstream services
                ServerHttpRequest modifiedRequest = exchange.getRequest().mutate()
                    .header("X-User-Id", claims.getSubject())
                    .header("X-User-Role", claims.get("role", String.class))
                    .build();
                return chain.filter(exchange.mutate().request(modifiedRequest).build());
            })
            .onErrorResume(e -> {
                exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
                return exchange.getResponse().setComplete();
            });
    }

    @Override
    public int getOrder() { return -1; }
}

4. Resilience4j: Circuit Breaker, Retry, Rate Limiter

Resilience4j is the replacement for Hystrix. It’s lightweight, modular, and integrates with Spring Boot via AOP.

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-circuitbreaker-resilience4j</artifactId>
</dependency>

Circuit Breaker

@Service
public class PaymentService {

    @CircuitBreaker(name = "paymentService", fallbackMethod = "paymentFallback")
    public PaymentResult processPayment(PaymentRequest request) {
        return paymentGateway.charge(request);  // might fail
    }

    public PaymentResult paymentFallback(PaymentRequest request, Exception e) {
        // Called when circuit is open or on failure
        log.warn("Payment service unavailable, queuing payment: {}", e.getMessage());
        paymentQueue.enqueue(request);
        return PaymentResult.queued(request.getOrderId());
    }
}
# Circuit breaker configuration
resilience4j:
  circuitbreaker:
    instances:
      paymentService:
        sliding-window-size: 10          # evaluate last 10 calls
        minimum-number-of-calls: 5       # need at least 5 calls before tripping
        failure-rate-threshold: 50       # open if >50% fail
        wait-duration-in-open-state: 30s # stay open 30s before half-open
        permitted-number-of-calls-in-half-open-state: 3
        slow-call-duration-threshold: 3s
        slow-call-rate-threshold: 80     # open if >80% of calls take >3s

Circuit Breaker states:

CLOSED (normal) ──── failure rate > 50% ──► OPEN (rejects calls)
                                                     │
                              wait-duration expires  │
                                                     ▼
CLOSED ◄── all probe calls pass ── HALF-OPEN (probe 3 calls)

Retry

@Retry(name = "paymentService", fallbackMethod = "paymentFallback")
@CircuitBreaker(name = "paymentService", fallbackMethod = "paymentFallback")
public PaymentResult processPayment(PaymentRequest request) {
    // Retry fires first, then Circuit Breaker counts the final result
}
resilience4j:
  retry:
    instances:
      paymentService:
        max-attempts: 3
        wait-duration: 500ms
        enable-exponential-backoff: true
        exponential-backoff-multiplier: 2   # 500ms, 1000ms, 2000ms
        retry-exceptions:
          - java.net.ConnectException
          - java.util.concurrent.TimeoutException
        ignore-exceptions:
          - com.example.exception.ValidationException  # don't retry business errors

Critical rule: Always combine Retry with Circuit Breaker. Retry without Circuit Breaker during an outage floods the failing service with traffic and makes the outage worse (cascading failure).

Rate Limiter (client-side)

@RateLimiter(name = "paymentService", fallbackMethod = "rateLimitFallback")
public PaymentResult processPayment(PaymentRequest request) {
    return paymentGateway.charge(request);
}
resilience4j:
  ratelimiter:
    instances:
      paymentService:
        limit-for-period: 10          # 10 requests
        limit-refresh-period: 1s      # per second
        timeout-duration: 100ms       # wait up to 100ms for a permit

Bulkhead (concurrent call limit)

@Bulkhead(name = "paymentService", type = Bulkhead.Type.SEMAPHORE)
public PaymentResult processPayment(PaymentRequest request) {
    return paymentGateway.charge(request);
}
resilience4j:
  bulkhead:
    instances:
      paymentService:
        max-concurrent-calls: 20    # max 20 concurrent calls to payment service
        max-wait-duration: 10ms

Running the Full Stack Locally

Docker Compose

version: '3.8'
services:
  config-server:
    image: myorg/config-server:latest
    ports: ["8888:8888"]

  eureka-server:
    image: myorg/eureka-server:latest
    ports: ["8761:8761"]
    depends_on: [config-server]

  gateway:
    image: myorg/gateway:latest
    ports: ["8080:8080"]
    depends_on: [eureka-server]

  order-service:
    image: myorg/order-service:latest
    ports: ["8081:8081"]
    depends_on: [eureka-server, config-server]
    environment:
      SPRING_PROFILES_ACTIVE: dev

  payment-service:
    image: myorg/payment-service:latest
    ports: ["8082:8082"]
    depends_on: [eureka-server, config-server]

Startup order

Services need Config Server to be available before they start (to fetch config). Use Spring Boot’s retry on config server connection:

spring:
  config:
    import: optional:configserver:http://config-server:8888
  cloud:
    config:
      fail-fast: true     # fail if config server unreachable
      retry:
        initial-interval: 1000
        max-attempts: 6
        max-interval: 2000

When to Use Eureka vs Kubernetes Native Discovery

FactorEurekaK8s Native (Services + DNS)
SetupExtra Eureka service to runBuilt into K8s
Health-awareYes (unhealthy instances removed)Liveness/readiness probes
Cross-clusterWorks across clustersNeeds extra setup
Non-K8s servicesYesNo
Recommended forHybrid/multi-cloud, non-K8sPure Kubernetes deployments

On Kubernetes: use K8s Service objects and DNS (http://payment-service.default.svc.cluster.local) instead of Eureka. Eureka adds operational overhead when K8s already handles service discovery natively.


Summary

A production Spring Boot microservices stack needs four layers: Config Server for centralised configuration, Eureka for service registration and discovery, Spring Cloud Gateway for routing and edge authentication, and Resilience4j for circuit breakers and retry. Always combine Retry with Circuit Breaker — retry without a circuit breaker during an outage amplifies the problem. On pure Kubernetes, replace Eureka with K8s native service discovery to reduce operational overhead.

Abhay

Abhay Pratap Singh

DevOps Engineer passionate about automation, cloud infrastructure, and self-hosted tools. I write about Kubernetes, Terraform, DNS, and everything in between.