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
| Factor | Eureka | K8s Native (Services + DNS) |
|---|---|---|
| Setup | Extra Eureka service to run | Built into K8s |
| Health-aware | Yes (unhealthy instances removed) | Liveness/readiness probes |
| Cross-cluster | Works across clusters | Needs extra setup |
| Non-K8s services | Yes | No |
| Recommended for | Hybrid/multi-cloud, non-K8s | Pure 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.
