The API Gateway pattern (purpose, security and performance benefits, gateway vs direct client calls, BFF, challenges), the Circuit Breaker pattern (purpose, closed/open/half-open states, when to use it, how it differs from Retry), and the Retry pattern (when to retry, fault tolerance, exponential backoff with jitter, retry budgets, idempotency, and how Retry and Circuit Breaker work together) — with Spring Cloud Gateway and Resilience4j examples.
Published September 25, 2026
These three patterns form the edge and resilience toolkit. Interviewers probe:
Answer with Spring Cloud Gateway and Resilience4j specifics.
Short answer: A gateway is a single entry point for all client requests to a microservices system. It:
Example: a product page needs product details, reviews and recommendations. The gateway (or a BFF) calls the three services in parallel, and returns one response, so the mobile client makes one round trip instead of three.
Learn it in depth → API Gateway
Short answer: It stops a service from repeatedly calling a dependency that's failing or too slow:
Example: the payment gateway is down, so the breaker opens, and checkout queues payments for later, instead of hanging every request.
Learn it in depth → Circuit Breaker Pattern
Short answer: It automatically re-attempts a failed operation when the failure is likely transient: a network blip, a timeout, a 503 during a deployment, a lost connection, or a rate-limit response with a Retry-After. Example: the Inventory service briefly fails to respond while checking stock, so the call is retried twice with backoff before being treated as failed.
Learn it in depth → Retry & Backoff Strategies
Short answer: Without a gateway, clients must know every service's address, API and authentication scheme. Every service must re-implement the edge concerns, and internal refactoring breaks clients. The gateway:
Key points to cover:
spring:
cloud:
gateway:
routes:
- id: orders
uri: lb://order-service # discovery + load balancing
predicates: [ "Path=/api/orders/**" ]
filters:
- StripPrefix=1
- name: RequestRateLimiter # Redis token bucket per key
args: { redis-rate-limiter.replenishRate: 50, redis-rate-limiter.burstCapacity: 100, key-resolver: "#{@userKeyResolver}" }
- name: CircuitBreaker
args: { name: orders, fallbackUri: "forward:/fallback/orders" }
- TokenRelay= # forward the user's OAuth2 token downstream
Short answer:
Common trap: the gateway handles the coarse checks. Services must still authorise each request (object-level checks, zero trust), because internal traffic can bypass the gateway.
Short answer:
| Direct client → services | Through an API Gateway | |
|---|---|---|
| Client knowledge | Must know every service's address and API | One endpoint and a stable public API |
| Round trips | Many (one per service) | Fewer (aggregation, BFF) |
| Cross-cutting concerns | Duplicated in every service | Centralised |
| Refactoring services | Breaks clients | Hidden behind the routes |
| Attack surface | Every service exposed publicly | Only the gateway exposed |
| Protocols | Must be client-friendly everywhere | Can translate (REST ↔ gRPC, WebSocket) |
| Extra hop and component | None | An extra hop, and a component to run and scale |
Direct calls are acceptable for internal service-to-service traffic (usually through a mesh), or very small systems.
Short answer:
Short answer: The breaker wraps calls to a dependency, and tracks their outcomes (failures and slow calls) in a sliding window. When the failure rate crosses a threshold, it opens, rejecting calls immediately (CallNotPermittedException), which triggers a fallback. It improves resilience because:
Short answer:
waitDurationInOpenState. Then it moves to HALF_OPEN, automatically or on the next call.Key points to cover:
CircuitBreaker cb = CircuitBreaker.of("inventory", CircuitBreakerConfig.custom()
.slidingWindowSize(20).minimumNumberOfCalls(10)
.failureRateThreshold(50).slowCallRateThreshold(80).slowCallDurationThreshold(Duration.ofSeconds(1))
.waitDurationInOpenState(Duration.ofSeconds(15)).permittedNumberOfCallsInHalfOpenState(3)
.build());
cb.getEventPublisher().onStateTransition(e -> log.warn("inventory breaker {}", e.getStateTransition()));
Supplier<Stock> guarded = CircuitBreaker.decorateSupplier(cb, () -> inventoryClient.stock(sku));
Short answer: They handle different kinds of failure:
Retry on its own against a hard-down service multiplies the load: every request becomes 3–4 requests, which is a retry storm. The breaker on its own gives up on the first blip. Combined, they give quick recovery from blips, and fast failure during outages.
Short answer: On every remote call where a slow or failing dependency could hurt the caller:
It's not useful for local in-memory calls, and it's less relevant for asynchronous message consumption, where you pause consumers or use backoff instead. Business errors (validation failures, card declined) must not trip the breaker.
Short answer: Use it for transient failures on idempotent operations:
Retry-After, deadlocks or serialisation failures in the database, leader elections in a broker.RetryConfig config = RetryConfig.custom()
.maxAttempts(3) // 1 call + 2 retries
.intervalFunction(IntervalFunction.ofExponentialRandomBackoff(Duration.ofMillis(200), 2.0, 0.5)) // backoff + jitter
.retryOnException(ex -> ex instanceof IOException || ex instanceof HttpServerErrorException.ServiceUnavailable)
.ignoreExceptions(HttpClientErrorException.class)
.build();
Short answer: Many distributed failures are brief and self-healing: packet loss, a pod restarting during a rolling deployment, a leader failover, a momentary pool exhaustion. Retrying with a short backoff masks these blips from users and upstream services. That means:
Key points to cover:
Short answer: They're complementary. The usual composition is Retry outside, Circuit Breaker inside (Resilience4j's default aspect order):
CallNotPermittedException. Configure the retry to not retry that exception, so it stops immediately.Supplier<Stock> call = () -> inventoryClient.stock(sku);
Supplier<Stock> resilient = Decorators.ofSupplier(call)
.withCircuitBreaker(circuitBreaker) // inner: records every attempt
.withRetry(retry) // outer: re-invokes on transient failures
.withFallback(List.of(CallNotPermittedException.class, IOException.class), ex -> Stock.unknown(sku))
.decorate();
Short answer:
Retry-After and the server's back-pressure signals (429, 503).@RetryableTopic) with increasing delays, and finally a DLQ, instead of blocking.Q: Where should aggregation live: the gateway or a BFF? A: In a BFF owned by the client team (web BFF, mobile BFF), or a dedicated composition service. The shared gateway should stay thin (routing plus edge policies), so it doesn't become a coupled bottleneck.
Q: How do you rate-limit fairly at the gateway?
A: Key the limits by the authenticated user, API key or tenant, not only by IP. Use a distributed token bucket (Redis) for consistency across gateway instances, return 429 with Retry-After, and have separate tiers for different plans.
Q: What happens to in-flight retries when the circuit opens?
A: The next attempt gets CallNotPermittedException immediately. Configure the retry to ignore that exception, so the whole operation fails fast (or falls back), instead of sleeping through backoff delays pointlessly.
Q: How do you choose timeout values? A: From the dependency's observed latency (for example, a bit above its p99), and the caller's own SLO budget, minus the time needed for retries. Downstream timeouts must be shorter than upstream ones, so that failures propagate cleanly.