Spring Cloud & Frameworks — Architect-Level Interview Guide
Target: Senior Engineer · Engineering Lead · Pre-Architect Focus: Spring Boot, Feign, WebClient, Spring Cloud Config, Actuator
Q: Why is RestTemplate deprecated? How does WebClient differ?
Why interviewers ask this: Tests understanding of reactive programming, blocking vs non-blocking I/O, and why this matters at scale.
Answer
RestTemplate — synchronous, blocking. Each HTTP call blocks its thread until a response arrives. Under high load this exhausts the thread pool.
WebClient — asynchronous, non-blocking. Based on Project Reactor. A small number of threads handle thousands of concurrent requests using event-loop I/O.
Comparison:
| RestTemplate | WebClient | |
|---|---|---|
| I/O Model | Blocking | Non-blocking |
| Threading | 1 thread per request | Event-loop threads |
| Concurrency at scale | Poor (thread exhaustion) | Excellent |
| Reactive streams | ❌ | ✅ |
| Error handling | Try/catch | .onErrorResume() |
| Streaming responses | ❌ | ✅ |
| Status | Deprecated (Spring 6) | Recommended |
RestTemplate (avoid):
// Blocks the calling thread until response arrives
ResponseEntity<Order> response = restTemplate.getForEntity(
"http://order-service/orders/{id}", Order.class, orderId
);
WebClient (recommended):
@Bean
public WebClient orderClient(WebClient.Builder builder) {
return builder
.baseUrl("http://order-service")
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.filter(ExchangeFilterFunctions.retry(3))
.build();
}
// Non-blocking — returns Mono immediately, subscribes lazily
public Mono<Order> getOrder(String orderId) {
return orderClient.get()
.uri("/orders/{id}", orderId)
.retrieve()
.onStatus(HttpStatusCode::is5xxServerError, response ->
Mono.error(new ServiceUnavailableException("order-service"))
)
.bodyToMono(Order.class)
.timeout(Duration.ofSeconds(2))
.onErrorReturn(Order.empty());
}
graph LR
subgraph RestTemplate["RestTemplate · Blocking"]
T1["Thread 1 → blocked waiting"]
T2["Thread 2 → blocked waiting"]
T3["Thread 3 → blocked waiting"]
end
subgraph WebClient["WebClient · Non-blocking"]
E1["Event Loop Thread 1"]
R1["Request A · in-flight"]
R2["Request B · in-flight"]
R3["Request C · in-flight"]
E1 -.->|Handles all| R1
E1 -.->|Handles all| R2
E1 -.->|Handles all| R3
end
Architect Insight
If your entire stack is blocking (JPA, JDBC), switching to WebClient alone won't give you reactive benefits — the blocking DB call still ties up the thread. Consider R2DBC for fully reactive stacks, or keep RestTemplate in simple CRUD services. WebClient shines in I/O-heavy service aggregators and API gateways.
Q: How do Feign clients simplify HTTP communication? What are the pitfalls?
Answer
Feign generates HTTP client implementations from interface definitions, eliminating boilerplate.
Setup:
@FeignClient(name = "inventory-service", fallback = InventoryFallback.class)
public interface InventoryClient {
@GetMapping("/inventory/{productId}")
InventoryResponse getInventory(@PathVariable String productId);
@PostMapping("/inventory/reserve")
ReservationResponse reserve(@RequestBody ReservationRequest request);
}
Fallback implementation:
@Component
public class InventoryFallback implements InventoryClient {
@Override
public InventoryResponse getInventory(String productId) {
return InventoryResponse.unavailable(productId);
}
@Override
public ReservationResponse reserve(ReservationRequest request) {
throw new ServiceUnavailableException("Inventory service down — cannot reserve");
}
}
Common pitfalls and fixes:
| Pitfall | Problem | Fix |
|---|---|---|
| No timeout configured | Hangs indefinitely if downstream is slow | Set feign.client.config.default.connectTimeout and readTimeout |
| No circuit breaker | Cascading failures when service is down | Enable feign.circuitbreaker.enabled=true + Resilience4j |
| Exception not mapped | HTTP errors silently swallowed | Implement ErrorDecoder to map HTTP status to exceptions |
| Retry on non-idempotent calls | Duplicate writes on POST retry | Configure retry only for GET; use idempotency keys for POST |
| Load balancer not active | Always hits same instance | Ensure spring-cloud-starter-loadbalancer is on classpath |
Error decoder:
@Component
public class FeignErrorDecoder implements ErrorDecoder {
@Override
public Exception decode(String methodKey, Response response) {
return switch (response.status()) {
case 404 -> new ResourceNotFoundException("Not found: " + methodKey);
case 503 -> new ServiceUnavailableException("Service unavailable: " + methodKey);
default -> new Default().decode(methodKey, response);
};
}
}
Q: How does Spring Cloud Config centralize configuration across microservices?
Why interviewers ask this: Configuration sprawl is a major operational issue at scale. Tests understanding of externalized configuration management.
Answer
Problem: 50 services each have their own application.yml — different environments (dev, staging, prod) multiply this. How do you manage, audit, and update config safely?
Solution: Spring Cloud Config Server — single source of truth backed by Git.
graph LR
Git["Git Repo · configs/\norder-service-prod.yml\ninventory-service-prod.yml"]
ConfigServer["Spring Cloud\nConfig Server\n:8888"]
OrderSvc["Order Service · :8081"]
InventorySvc["Inventory Service · :8082"]
PaymentSvc["Payment Service · :8083"]
Git -->|Pull on startup| ConfigServer
ConfigServer -->|GET /order-service/prod| OrderSvc
ConfigServer -->|GET /inventory-service/prod| InventorySvc
ConfigServer -->|GET /payment-service/prod| PaymentSvc
Config server setup:
# Config server application.yml
spring:
cloud:
config:
server:
git:
uri: https://github.com/myorg/microservices-config
search-paths: '{application}'
default-label: main
clone-on-start: true
Client service bootstrap:
# bootstrap.yml in each microservice
spring:
application:
name: order-service
config:
import: "configserver:http://config-server:8888"
profiles:
active: prod
Dynamic refresh without restart:
@RefreshScope // Bean is re-created on /actuator/refresh
@RestController
public class FeatureFlagController {
@Value("${feature.new-checkout-flow:false}")
private boolean newCheckoutFlow;
}
Then trigger: POST /actuator/refresh → or use Spring Cloud Bus (Kafka/RabbitMQ) to broadcast refresh to all instances simultaneously.
Configuration hierarchy (later overrides earlier):
Security Note
Encrypt sensitive config values (DB passwords, API keys) in Git using Spring Cloud Config's symmetric or asymmetric encryption. Never commit plaintext secrets.
Q: What are Spring Boot Actuator readiness and liveness probes? How do they work with Kubernetes?
Answer
Kubernetes uses two probes to manage container lifecycle:
| Probe | Question | Action on failure |
|---|---|---|
| Liveness | Is the app alive (not deadlocked)? | Kill and restart container |
| Readiness | Is the app ready to serve traffic? | Remove from load balancer pool |
| Startup | Has the app finished starting? | Block liveness/readiness until done |
Spring Boot Actuator auto-exposes these:
management:
endpoint:
health:
probes:
enabled: true
show-details: always
health:
livenessState:
enabled: true
readinessState:
enabled: true
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
Endpoints:
GET /actuator/health/liveness→{"status": "UP"}GET /actuator/health/readiness→{"status": "UP"}
Kubernetes deployment configuration:
apiVersion: apps/v1
kind: Deployment
metadata:
name: order-service
spec:
template:
spec:
containers:
- name: order-service
image: myrepo/order-service:1.2.0
ports:
- containerPort: 8080
startupProbe:
httpGet:
path: /actuator/health/liveness
port: 8080
failureThreshold: 30 # Allow 60s to start (30 x 2s)
periodSeconds: 2
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: 8080
initialDelaySeconds: 10
periodSeconds: 10
failureThreshold: 3 # Restart after 3 failures
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8080
initialDelaySeconds: 5
periodSeconds: 5
failureThreshold: 3 # Remove from pool after 3 failures
Custom readiness indicator:
@Component
public class DatabaseReadinessIndicator implements HealthIndicator {
private final DataSource dataSource;
@Override
public Health health() {
try (Connection conn = dataSource.getConnection()) {
conn.isValid(1);
return Health.up().withDetail("db", "reachable").build();
} catch (SQLException e) {
return Health.down()
.withDetail("db", "unreachable")
.withException(e)
.build();
}
}
}
Architect Insight
Set readiness to fail during rolling deployments until the new instance has warmed its caches and established DB connections. This prevents Kubernetes from routing traffic to instances that aren't truly ready, avoiding startup-time errors users see.
Q: How do you manage configuration for a fleet of microservices across environments?
Answer
Strategy:
- Externalize all config — no hardcoded values in code; use environment variables or Config Server
- Layered config — common defaults → service-specific → environment-specific
- Secret separation — config in Git, secrets in Kubernetes Secrets or HashiCorp Vault
- Audit trail — Git history gives full config change history with author and timestamp
- Dynamic refresh — change config without redeployment using
@RefreshScope+ Spring Cloud Bus
Tool comparison:
| Tool | Best For | Trade-offs |
|---|---|---|
| Spring Cloud Config | Spring-native, Git-backed | Requires running config server |
| Kubernetes ConfigMaps | K8s-native, simple values | No encryption, no dynamic refresh |
| Kubernetes Secrets | Credentials in K8s | Base64 only (not truly encrypted without KMS) |
| HashiCorp Vault | Secrets with dynamic rotation | More complex setup |
| AWS Parameter Store | AWS-native secrets | Cloud vendor lock-in |
Concurrency & Async
Q: What is Structured Concurrency in Java 21? How does it improve async code compared to manual thread management?
Why interviewers ask this: Java 21 made virtual threads and structured concurrency generally available. Tests whether a candidate understands how these change concurrent programming and when to apply them in a Spring Boot service.
Answer
Structured Concurrency (JEP 453, Java 21) ensures that when a task spawns sub-tasks, all sub-tasks complete (or are cancelled) before the parent task exits — enforcing a tree-shaped lifetime for concurrent work.
The problem it solves:
// Traditional approach — error-prone:
ExecutorService exec = Executors.newFixedThreadPool(2);
Future<User> userFuture = exec.submit(() -> userService.getUser(userId));
Future<Order> orderFuture = exec.submit(() -> orderService.getOrders(userId));
try {
User user = userFuture.get(2, SECONDS);
List<Order> orders = orderFuture.get(2, SECONDS);
} catch (Exception e) {
// Cancelling the other future is manual — easy to forget
userFuture.cancel(true);
orderFuture.cancel(true);
// If getUser() throws, getOrders() may still be running, leaking resources
}
Structured Concurrency — Java 21:
// StructuredTaskScope guarantees: if one subtask fails, the other is cancelled
public UserDashboard getUserDashboard(String userId) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
// Fork sub-tasks (run in virtual threads automatically)
StructuredTaskScope.Subtask<User> userTask =
scope.fork(() -> userService.getUser(userId));
StructuredTaskScope.Subtask<List<Order>> ordersTask =
scope.fork(() -> orderService.getOrders(userId));
StructuredTaskScope.Subtask<List<Notification>> notifTask =
scope.fork(() -> notificationService.getUnread(userId));
scope.join(); // Wait for all
scope.throwIfFailed(); // Propagate any exception
// All sub-tasks completed successfully
return new UserDashboard(
userTask.get(),
ordersTask.get(),
notifTask.get()
);
} // Scope exit: all sub-tasks guaranteed done or cancelled
}
Two built-in shutdown policies:
// ShutdownOnFailure — if any subtask fails, cancel all others
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
// Use when ALL results are required
}
// ShutdownOnSuccess — as soon as one subtask succeeds, cancel all others
try (var scope = new StructuredTaskScope.ShutdownOnSuccess<String>()) {
scope.fork(() -> primaryDataSource.fetch(id));
scope.fork(() -> fallbackDataSource.fetch(id)); // Race — fastest wins
scope.join();
return scope.result();
}
Virtual Threads + Structured Concurrency together:
// Spring Boot 3.2+ — enable virtual threads globally
@SpringBootApplication
public class OrderServiceApp {
public static void main(String[] args) {
SpringApplication app = new SpringApplication(OrderServiceApp.class);
app.setDefaultProperties(Map.of(
"spring.threads.virtual.enabled", "true" // All @Async, Tomcat threads = virtual
));
app.run(args);
}
}
Why this matters for microservices — fan-out calls:
Traditional (sequential):
getUser() → 300ms
getOrders() → 200ms
getNotifications() → 150ms
Total: 650ms
Virtual threads + StructuredTaskScope (parallel):
All three run concurrently in virtual threads
Total: max(300, 200, 150) = 300ms
Same code style as blocking, no reactive operators needed
Architect Insight
Structured Concurrency with Virtual Threads is the Java 21 answer to reactive programming for I/O-bound fan-out calls. It gives you the performance of async without the complexity of Project Reactor's flatMap/zip chains. Consider it for API aggregation layers that call 3–5 downstream services in parallel. Keep using reactive (WebFlux) where you need streaming, backpressure, or a fully reactive stack with R2DBC.