← Back to list

Combining Resilience4j Patterns in Spring Boot

In a previous article, we explored how to use the Circuit Breaker pattern with Resilience4j in a Spring Boot application.

Erkan Demirel · 2026-07-17 10:38 · 3 claps · 6.8 min read
#resilience4j #microservices #spring-boot #circuit-breaker #fault-tolerance
Open on Medium ↗
Wiki topics: 🚀 · Self Improvement

Combining Resilience4j Patterns in Spring Boot

In a previous article, we explored how to use the Circuit Breaker pattern with Resilience4j in a Spring Boot application.

Circuit Breaker is an important fault-tolerance pattern, but it does not solve every problem that may occur while communicating with an external service.

A downstream service may:

  • Receive too many requests
  • Process too many concurrent operations
  • Respond too slowly
  • Fail temporarily
  • Become completely unavailable

In this article, we will combine the following Resilience4j patterns in a single Spring Boot application:

Rate Limiter
→ Bulkhead
→ TimeLimiter
→ Circuit Breaker
→ Retry
→ Payment Provider
→ Fallback

The Payment Provider in this project is only a simulated downstream service. The main purpose of the project is to demonstrate how different fault-tolerance patterns can work together.

What Does Each Pattern Do?

Before starting the implementation, let’s briefly look at the responsibility of each pattern.

Rate Limiter

Rate Limiter controls how many requests can be processed during a specific period.

It protects the downstream service from receiving more traffic than it can handle.

Bulkhead

Bulkhead limits the number of concurrent calls.

It prevents one slow or overloaded downstream service from consuming all application resources.

TimeLimiter

TimeLimiter defines how long the application should wait for an asynchronous operation.

If the operation does not finish within the configured duration, a TimeoutException is generated.

Circuit Breaker

Circuit Breaker monitors successful and failed calls. When the failure rate exceeds the configured threshold, it opens the circuit and rejects new calls without executing the downstream operation.

Retry

Retry repeats a failed operation for a configured number of attempts. It is useful for temporary network or service errors that may disappear after a short period.

Fallback

Fallback returns an alternative response when the protected operation cannot be completed.

Prerequisites

  • Java 17 or later
  • Maven 3.9+
  • Spring Boot 3
  • Basic knowledge of Spring Boot
  • Basic knowledge of Resilience4j

Project Dependencies

We will use Spring Web, Spring Boot Actuator, Spring AOP and the Resilience4j Spring Boot starter.

Add the following dependencies to pom.xml:

<dependencies>
 <dependency>
 <groupId>org.springframework.boot</groupId>
 <artifactId>spring-boot-starter-web</artifactId>
 </dependency>
<dependency>
 <groupId>org.springframework.boot</groupId>
 <artifactId>spring-boot-starter-actuator</artifactId>
 </dependency>
<dependency>
 <groupId>org.springframework.boot</groupId>
 <artifactId>spring-boot-starter-aop</artifactId>
 </dependency>
<dependency>
 <groupId>io.github.resilience4j</groupId>
 <artifactId>resilience4j-spring-boot3</artifactId>
 <version>2.2.0</version>
 </dependency>
</dependencies>

Spring AOP is required because Resilience4j annotations are applied through aspects.

Payment Response

First, create a response model:

public record PaymentResponse(
 String orderId,
 String status,
 String message,
 String source
) {
}

This response will be returned for both successful provider calls and fallback operations.

Simulating the Payment Provider

The PaymentProviderClient simulates an external service.

It supports four different scenarios:

  • success
  • failure
  • slow
  • random
@Component
public class PaymentProviderClient {

    public PaymentResponse charge(String orderId, String scenario) {
        return switch (scenario.toLowerCase()) {
            case "success" -> success(orderId);
            case "failure" ->
                    throw new IllegalStateException(
                            "Payment provider returned an error"
                    );
            case "slow" -> slowResponse(orderId);
            case "random" -> randomResponse(orderId);
            default -> throw new IllegalArgumentException(
                    "Unknown scenario. Supported values: " +
                    "success, failure, slow, random"
            );
        };
    }

    private PaymentResponse success(String orderId) {
        return new PaymentResponse(
                orderId,
                "PAID",
                "Payment completed",
                "payment-provider"
        );
    }

    private PaymentResponse slowResponse(String orderId) {
        sleep(Duration.ofSeconds(3));
        return success(orderId);
    }

    private PaymentResponse randomResponse(String orderId) {
        if (ThreadLocalRandom.current().nextBoolean()) {
            throw new IllegalStateException(
                    "Random payment provider failure"
            );
        }

        return success(orderId);
    }

    private void sleep(Duration duration) {
        try {
            Thread.sleep(duration.toMillis());
        } catch (InterruptedException exception) {
            Thread.currentThread().interrupt();

            throw new IllegalStateException(
                    "Payment provider call was interrupted",
                    exception
            );
        }
    }
}

The slow scenario waits for three seconds. Later, we will configure TimeLimiter with a two-second timeout so that this call produces a fallback response.

Combining Resilience4j Annotations

The service method returns a CompletableFuture because TimeLimiter works with asynchronous return types.

@Service
public class PaymentService {

    private static final String BACKEND = "paymentProvider";

    private final PaymentProviderClient paymentProviderClient;

    public PaymentService(
            PaymentProviderClient paymentProviderClient
    ) {
        this.paymentProviderClient = paymentProviderClient;
    }

    @RateLimiter(
            name = BACKEND,
            fallbackMethod = "fallbackPayment"
    )
    @Bulkhead(
            name = BACKEND,
            type = Bulkhead.Type.SEMAPHORE,
            fallbackMethod = "fallbackPayment"
    )
    @TimeLimiter(
            name = BACKEND,
            fallbackMethod = "fallbackPayment"
    )
    @CircuitBreaker(
            name = BACKEND,
            fallbackMethod = "fallbackPayment"
    )
    @Retry(
            name = BACKEND,
            fallbackMethod = "fallbackPayment"
    )
    public CompletableFuture<PaymentResponse> processPayment(
            String orderId,
            String scenario
    ) {
        return CompletableFuture.supplyAsync(
                () -> paymentProviderClient.charge(orderId, scenario)
        );
    }

    private CompletableFuture<PaymentResponse> fallbackPayment(
            String orderId,
            String scenario,
            Throwable throwable
    ) {
        PaymentResponse response = new PaymentResponse(
                orderId,
                "PENDING_PAYMENT",
                "Fallback response: "
                        + throwable.getClass().getSimpleName(),
                "fallback"
        );

        return CompletableFuture.completedFuture(response);
    }
}

All annotations use the same instance name:

paymentProvider

This name connects the annotations to their corresponding configuration in application.yml.

The fallback method must:

  • Accept the original method parameters
  • Accept a Throwable as the final parameter
  • Return a compatible return type

Because processPayment returns CompletableFuture<PaymentResponse>, the fallback method must return the same type.

Resilience4j Configuration

The complete configuration is shown below:

server:
  port: 8080

management:
  endpoints:
    web:
      exposure:
        include: health,metrics,circuitbreakers,circuitbreakerevents,retries,retryevents,ratelimiters,ratelimiterevents,bulkheads,bulkheadevents
  endpoint:
    health:
      show-details: always
  health:
    circuitbreakers:
      enabled: true
    ratelimiters:
      enabled: true

resilience4j:
  ratelimiter:
    instances:
      paymentProvider:
        limitForPeriod: 5
        limitRefreshPeriod: 10s
        timeoutDuration: 0
        registerHealthIndicator: true

  bulkhead:
    instances:
      paymentProvider:
        maxConcurrentCalls: 3
        maxWaitDuration: 0

  timelimiter:
    instances:
      paymentProvider:
        timeoutDuration: 2s
        cancelRunningFuture: true

  circuitbreaker:
    instances:
      paymentProvider:
        registerHealthIndicator: true
        slidingWindowType: COUNT_BASED
        slidingWindowSize: 5
        minimumNumberOfCalls: 5
        failureRateThreshold: 50
        slowCallDurationThreshold: 1s
        slowCallRateThreshold: 50
        waitDurationInOpenState: 10s
        permittedNumberOfCallsInHalfOpenState: 2
        automaticTransitionFromOpenToHalfOpenEnabled: true

  retry:
    instances:
      paymentProvider:
        maxAttempts: 3
        waitDuration: 500ms
        enableExponentialBackoff: true
        exponentialBackoffMultiplier: 2

Let’s examine each configuration separately.

Rate Limiter Configuration

limitForPeriod: 5
limitRefreshPeriod: 10s
timeoutDuration: 0

The application permits five calls every ten seconds.

When the limit is exceeded, the request is rejected immediately because timeoutDuration is set to zero.

Bulkhead Configuration

maxConcurrentCalls: 3
maxWaitDuration: 0

Only three payment operations can run concurrently.

Additional calls are rejected immediately instead of waiting for an available slot.

This protects the application from resource exhaustion when the downstream service becomes slow.

TimeLimiter Configuration

timeoutDuration: 2s
cancelRunningFuture: true

The application waits for the asynchronous operation for a maximum of two seconds.

The simulated slow provider waits for three seconds, so TimeLimiter generates a TimeoutException.

Circuit Breaker Configuration

slidingWindowType: COUNT_BASED
slidingWindowSize: 5
minimumNumberOfCalls: 5
failureRateThreshold: 50

Circuit Breaker evaluates the latest five calls.

After at least five calls are recorded, the circuit opens if 50 percent or more of those calls fail.

waitDurationInOpenState: 10s
permittedNumberOfCallsInHalfOpenState: 2
automaticTransitionFromOpenToHalfOpenEnabled: true

After remaining open for ten seconds, Circuit Breaker moves to the HALF_OPEN state.

It permits two test calls to determine whether the downstream service has recovered.

Retry Configuration

maxAttempts: 3
waitDuration: 500ms
enableExponentialBackoff: true
exponentialBackoffMultiplier: 2

A failed operation can be attempted up to three times.

The waiting duration increases using exponential backoff:

First retry → 500 ms
Second retry → 1000 ms

Retry should be used carefully for operations that may create side effects. In real payment or order systems, retry operations should normally be combined with an idempotency mechanism.

Creating the Controller

The controller accepts the order ID as a path variable and the simulation scenario as a request parameter.

@RestController
@RequestMapping("/orders")
public class PaymentController {

    private final PaymentService paymentService;

    public PaymentController(PaymentService paymentService) {
        this.paymentService = paymentService;
    }

    @PostMapping("/{orderId}/payment")
    public CompletableFuture<PaymentResponse> processPayment(
            @PathVariable String orderId,
            @RequestParam(defaultValue = "success") String scenario
    ) {
        return paymentService.processPayment(
                orderId,
                scenario
        );
    }
}

Running the Application

Start the application:

mvn spring-boot:run

The application will run on port 8080.

Successful Call

Send a successful request:

curl -X POST \
"http://localhost:8080/orders/order-100/payment?scenario=success"

Response:

{
  "orderId": "order-100",
  "status": "PAID",
  "message": "Payment completed",
  "source": "payment-provider"
}

Provider Failure

Send a request that generates a provider error:

curl -X POST \
"http://localhost:8080/orders/order-101/payment?scenario=failure"

After the configured retry attempts are completed, the fallback response is returned:

{
  "orderId": "order-101",
  "status": "PENDING_PAYMENT",
  "message": "Fallback response: IllegalStateException",
  "source": "fallback"
}

Slow Provider and TimeLimiter

Send a slow request:

curl -X POST \
"http://localhost:8080/orders/order-102/payment?scenario=slow"

The provider waits for three seconds, but TimeLimiter allows only two seconds.

Response:

{
 "orderId": "order-102",
 "status": "PENDING_PAYMENT",
 "message": "Fallback response: TimeoutException",
 "source": "fallback"
}

Random Failure Scenario

The random scenario succeeds or fails with an approximately equal probability:

curl -X POST \
"http://localhost:8080/orders/order-103/payment?scenario=random"

Calling this endpoint several times helps demonstrate Retry and Circuit Breaker transitions.

Testing the Rate Limiter

Run the request more than five times within ten seconds:

for i in {1..7}
do
 curl -X POST \
 "http://localhost:8080/orders/order-$i/payment?scenario=success"
 echo
done

After the configured limit is reached, Rate Limiter rejects additional requests and fallback handles the result.

Testing the Bulkhead

The slow scenario can be used to keep concurrent operations busy.

Send more than three slow requests simultaneously:

for i in {1..5}
do
  curl -X POST \
  "http://localhost:8080/orders/order-$i/payment?scenario=slow" &
done

wait

Because maxConcurrentCalls is three, additional operations are rejected by Bulkhead.

Monitoring with Spring Boot Actuator

The project exposes several Actuator endpoints.

Check the overall health:

curl "http://localhost:8080/actuator/health"

Check the Circuit Breaker instances:

curl "http://localhost:8080/actuator/circuitbreakers"

Check Circuit Breaker events:

curl "http://localhost:8080/actuator/circuitbreakerevents"

Check Retry events:

curl "http://localhost:8080/actuator/retryevents"

Check Rate Limiter events:

curl "http://localhost:8080/actuator/ratelimiterevents"

Check Bulkhead events:

curl "http://localhost:8080/actuator/bulkheadevents"

These endpoints make it possible to observe successful calls, failed calls, rejected requests, retry attempts and Circuit Breaker state transitions.

Why Do We Need Multiple Patterns?

Each pattern protects the application from a different type of failure.

Rate Limiter → Protects against excessive request rates
Bulkhead → Protects application resources
TimeLimiter → Protects against slow operations
Retry → Handles temporary failures
Circuit Breaker → Prevents repeated calls to an unhealthy service
Fallback → Provides an alternative response

Circuit Breaker alone cannot restrict traffic, limit concurrent operations or stop an individual slow call.

Similarly, Retry alone may make an overloaded service even less stable by sending additional requests.

The patterns should therefore be selected and configured according to the behavior of the downstream service.

Important Considerations

Using every resilience pattern does not automatically make an application resilient.

Some important points should be considered:

  • Do not retry validation or business errors.
  • Use timeout values based on the expected service latency.
  • Avoid aggressive retry configurations.
  • Use idempotency for operations with side effects.
  • Configure separate Resilience4j instances for separate downstream services.
  • Monitor fallback and rejected call rates.
  • Test the configuration under concurrent load.
  • Keep fallback operations simple and reliable.

Conclusion

In this article, we combined Rate Limiter, Bulkhead, TimeLimiter, Circuit Breaker, Retry and Fallback in a Spring Boot application.

Together, these patterns provide protection against:

  • Excessive traffic
  • Too many concurrent operations
  • Slow downstream services
  • Temporary failures
  • Repeated calls to unavailable services

Resilience4j provides these patterns as independent modules, allowing us to select and combine only the mechanisms required by our application.

The complete source code is available on GitHub:

resilience4j-fault-tolerance-patterns-demo

You can also read the previous Circuit Breaker article:

Using Resilience4j Circuit Breaker in Spring Boot Microservices

Thanks for reading.

Happy coding!


메타데이터
post_id
a3a392b199f2
slug
combining-resilience4j-patterns-in-spring-boot-a3a392b199f2
url
https://medium.com/@erkndmrl/combining-resilience4j-patterns-in-spring-boot-a3a392b199f2
canonical_url
https://medium.com/@erkndmrl/combining-resilience4j-patterns-in-spring-boot-a3a392b199f2
author_url
https://medium.com/@erkndmrl
status
ok
fetched_at
2026-08-03 13:33:41