Hire Me
← All Writing Spring Boot

Spring Cloud Gateway — API Gateway Patterns

Building an API gateway with Spring Cloud Gateway — route configuration, predicates, filters, Redis rate limiting, circuit breaking, auth forwarding, and when a gateway adds value vs complexity.

An API gateway is one of those components that looks simple in architecture diagrams and reveals its complexity in production. Done well, it centralises cross-cutting concerns — authentication, rate limiting, circuit breaking, request routing — and keeps that logic out of your individual services. Done badly, it becomes a single point of failure that every service depends on, or a complexity trap where routing rules accumulate until nobody understands what request goes where.

I’ve deployed Spring Cloud Gateway on DWP Digital projects as the entry point for a set of backend microservices, and the pattern works well when you’re deliberate about what belongs in the gateway versus what belongs in the services. This is what a properly configured Spring Cloud Gateway setup looks like.

Dependencies and Baseline Configuration

Spring Cloud Gateway is reactive — it runs on Project Reactor and Netty, not Servlet containers. This is important: it means you cannot mix it with blocking Spring MVC code in the same application.

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>

Basic route configuration in application.yml:

spring:
  cloud:
    gateway:
      routes:
        - id: claims-service
          uri: lb://claims-service
          predicates:
            - Path=/api/claims/**
          filters:
            - StripPrefix=1

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

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

      default-filters:
        - DedupeResponseHeader=Access-Control-Allow-Credentials Access-Control-Allow-Origin

lb:// URIs are resolved via Spring Cloud LoadBalancer. StripPrefix=1 removes the first path segment before forwarding — so a request to /api/claims/123 becomes /claims/123 on the downstream service.

Programmatic Route Configuration

For dynamic routing or when you need Java-style type safety in route definitions, configure routes in code:

@Configuration
public class GatewayRoutesConfig {

    @Bean
    public RouteLocator routeLocator(RouteLocatorBuilder builder,
                                     AuthenticationFilter authFilter) {
        return builder.routes()

            .route("claims-service", r -> r
                .path("/api/claims/**")
                .filters(f -> f
                    .stripPrefix(1)
                    .filter(authFilter)
                    .addRequestHeader("X-Gateway-Source", "api-gateway")
                    .retry(config -> config
                        .setRetries(3)
                        .setMethods(HttpMethod.GET)
                        .setBackoff(Duration.ofMillis(100), Duration.ofSeconds(2), 2, true)))
                .uri("lb://claims-service"))

            .route("eligibility-service", r -> r
                .path("/api/eligibility/**")
                .filters(f -> f
                    .stripPrefix(1)
                    .filter(authFilter))
                .uri("lb://eligibility-service"))

            .build();
    }
}

The addRequestHeader filter propagates gateway-specific metadata to downstream services. This is useful for logging and request tracing — every downstream service can log X-Gateway-Source and you know the request came through the gateway, not a direct service-to-service call.

Authentication Forwarding Filter

One of the most common gateway responsibilities is validating a JWT or opaque token at the gateway and forwarding a verified identity header to downstream services — so the services themselves don’t need to repeat token validation:

@Component
@RequiredArgsConstructor
@Slf4j
public class AuthenticationFilter implements GatewayFilter, Ordered {

    private final JwtValidator jwtValidator;

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        String authHeader = exchange.getRequest().getHeaders()
            .getFirst(HttpHeaders.AUTHORIZATION);

        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
            return exchange.getResponse().setComplete();
        }

        String token = authHeader.substring(7);

        return jwtValidator.validate(token)
            .flatMap(claims -> {
                // Forward verified identity to downstream — services trust this header
                ServerHttpRequest mutated = exchange.getRequest().mutate()
                    .header("X-User-Id",    claims.subject())
                    .header("X-User-Roles", String.join(",", claims.roles()))
                    .build();

                return chain.filter(exchange.mutate().request(mutated).build());
            })
            .onErrorResume(e -> {
                log.warn("Token validation failed: {}", e.getMessage());
                exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
                return exchange.getResponse().setComplete();
            });
    }

    @Override
    public int getOrder() {
        return -100; // Run before other filters
    }
}

Downstream services can then read X-User-Id and X-User-Roles without repeating JWT parsing. Treat these headers as trusted only on requests that arrived via the gateway — deny direct calls to service ports that bypass the gateway at the network level.

Rate Limiting with Redis

Spring Cloud Gateway has built-in rate limiting via Redis and the token bucket algorithm. Configure it per route:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis-reactive</artifactId>
</dependency>
@Bean
public KeyResolver userKeyResolver() {
    // Rate limit per authenticated user ID
    return exchange -> Mono.justOrEmpty(
        exchange.getRequest().getHeaders().getFirst("X-User-Id")
    ).defaultIfEmpty("anonymous");
}

@Bean
public RouteLocator rateLimitedRoutes(RouteLocatorBuilder builder,
                                      RedisRateLimiter rateLimiter,
                                      KeyResolver userKeyResolver) {
    return builder.routes()
        .route("claims-service", r -> r
            .path("/api/claims/**")
            .filters(f -> f
                .stripPrefix(1)
                .requestRateLimiter(config -> config
                    .setRateLimiter(rateLimiter)
                    .setKeyResolver(userKeyResolver)
                    .setDenyEmptyKey(false)))
            .uri("lb://claims-service"))
        .build();
}

@Bean
public RedisRateLimiter redisRateLimiter() {
    // 20 requests per second, burst of 40
    return new RedisRateLimiter(20, 40, 1);
}

When a request exceeds the rate limit, Spring Cloud Gateway returns 429 Too Many Requests automatically. The Redis-backed implementation is distributed — it works correctly across multiple gateway instances, unlike an in-memory counter.

Circuit Breaking

Wrap downstream calls with a circuit breaker to prevent a failing service from cascading failures back to clients:

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-circuitbreaker-reactor-resilience4j</artifactId>
</dependency>
spring:
  cloud:
    gateway:
      routes:
        - id: payments-service
          uri: lb://payments-service
          predicates:
            - Path=/api/payments/**
          filters:
            - StripPrefix=1
            - name: CircuitBreaker
              args:
                name: payments-cb
                fallbackUri: forward:/fallback/payments

resilience4j:
  circuitbreaker:
    instances:
      payments-cb:
        slidingWindowSize: 20
        failureRateThreshold: 50
        waitDurationInOpenState: 30s
        permittedNumberOfCallsInHalfOpenState: 5
@RestController
public class FallbackController {

    @GetMapping("/fallback/payments")
    public Mono<ResponseEntity<Map<String, String>>> paymentsFallback() {
        return Mono.just(ResponseEntity
            .status(HttpStatus.SERVICE_UNAVAILABLE)
            .body(Map.of(
                "error", "payments_unavailable",
                "message", "Payment service is temporarily unavailable. Please try again shortly."
            )));
    }
}

The circuit breaker opens after 50% of 20 calls fail, waits 30 seconds, then allows a probe to check recovery. Clients get a structured fallback response rather than a connection timeout or 502.

Request and Response Logging

A global filter for structured request logging across all routes:

@Component
@Slf4j
public class RequestLoggingFilter implements GlobalFilter, Ordered {

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        String requestId   = UUID.randomUUID().toString().substring(0, 8);
        ServerHttpRequest  req  = exchange.getRequest();
        Instant            start = Instant.now();

        exchange = exchange.mutate()
            .request(req.mutate().header("X-Request-Id", requestId).build())
            .build();

        return chain.filter(exchange).doFinally(signal -> {
            long durationMs = Duration.between(start, Instant.now()).toMillis();
            log.info("requestId={} method={} path={} status={} durationMs={}",
                requestId,
                req.getMethod(),
                req.getPath().value(),
                exchange.getResponse().getStatusCode(),
                durationMs);
        });
    }

    @Override
    public int getOrder() {
        return Ordered.LOWEST_PRECEDENCE;
    }
}

ProTip: When a Gateway Adds Value vs Complexity

An API gateway is worth the operational overhead when you have multiple services, multiple clients, and cross-cutting concerns that would otherwise be duplicated across services. Authentication, rate limiting, TLS termination, and request correlation are all genuinely better handled in one place.

A gateway adds complexity without value when you have two or three services, all consumed by a single internal frontend, with no external clients and no authentication beyond a network boundary. In that scenario, you’ve added a moving part that can fail, a deployment you need to manage, and a reactive programming model that the team may not be familiar with — for concerns you could have handled with a Spring Security filter in each service.

The test I apply: if every service in your architecture would independently implement the same filter, it belongs in the gateway. If the gateway is just proxying requests with no modification, it’s adding latency and complexity for no benefit.

If you’re building a Spring Boot microservices platform and need an API gateway configured correctly from day one, get in touch.