Skip to content
ExponaBLOG
JAVA, SPRING FRAMEWORK & SPRING BOOT · DAY 072 / 100

API gateway with Spring Cloud Gateway: a practical guide

One entry point for routing, auth and rate limiting.

Expona Admin7 min read

When to reach for a gateway

Most teams building microservices eventually hit the same wall: multiple services, multiple ports, and a frontend that suddenly needs to talk to five different hosts. An API gateway with Spring Cloud Gateway: a practical guide becomes essential when you need one entry point that handles routing, authentication, and cross-cutting concerns without pushing that complexity into every single service.

Spring Cloud Gateway sits in front of your microservices, intercepting requests and deciding where they go. It's lightweight, non-blocking (built on Project Reactor), and plays well with Spring Boot. But like any gateway, it's only useful when you keep it focused. Let's walk through how to build one that actually stays maintainable.

Route by path — declarative routes in YAML

The simplest use case for any API gateway is request routing. Instead of your frontend juggling URLs like http://orders:8080, http://users:9090, and http://inventory:8081, everything comes through the gateway at http://api.company.local.

Spring Cloud Gateway routes are typically defined in application.yml. Each route has an id, a uri (where to send the request), and one or more predicates (conditions that must match). Here's a practical setup:

java
spring:
  cloud:
    gateway:
      routes:
        - id: orders
          uri: http://orders:8080
          predicates:
            - Path=/api/orders/**
          filters:
            - StripPrefix=2

        - id: users
          uri: http://users:9090
          predicates:
            - Path=/api/users/**
          filters:
            - StripPrefix=2

        - id: inventory
          uri: http://inventory:8081
          predicates:
            - Path=/api/inventory/**
            - Method=GET,POST
          filters:
            - StripPrefix=2

      default-filters:
        - name: RequestLogging
        - name: ResponseLogging

When a request comes in for GET /api/orders/123, the gateway matches it against the Path=/api/orders/** predicate, strips the first two path segments, and forwards GET /123 to http://orders:8080. The StripPrefix filter is crucial—without it, the orders service would receive /api/orders/123 and likely return 404.

You can also define routes programmatically if your routing logic is more complex. For example, if you need to route based on a custom header or hostname:

java
@Configuration
public class GatewayConfig {

  @Bean
  public RouteLocator customRoutes(RouteLocatorBuilder builder) {
    return builder.routes()
        .route("tenant-aware", r -> r
            .header("X-Tenant-Id", ".*")
            .uri("http://multi-tenant-service:8080"))
        .route("fallback", r -> r
            .path("/api/**")
            .uri("http://default-service:8080"))
        .build();
  }
}

Declarative routes in YAML are preferred for most teams because they're easy to visualize and change without redeploying the gateway. Reserve programmatic configuration for genuinely dynamic scenarios.

Centralize cross-cutting concerns

This is where the gateway earns its keep. Token validation, CORS headers, rate limiting, and request/response logging all happen in one place. Once you have that infrastructure, you can remove it from every downstream service.

Let's add JWT validation. Every request to protected routes must carry a valid token in the Authorization header. Instead of duplicating validation logic across five services, do it at the gateway:

java
@Component
public class JwtAuthenticationFilter implements GlobalFilter, Ordered {

  private final JwtTokenProvider tokenProvider;

  public JwtAuthenticationFilter(JwtTokenProvider tokenProvider) {
    this.tokenProvider = tokenProvider;
  }

  @Override
  public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
    String path = exchange.getRequest().getPath().value();

    // Skip auth for public endpoints
    if (path.startsWith("/api/auth/") || path.startsWith("/health")) {
      return chain.filter(exchange);
    }

    String token = extractToken(exchange.getRequest());
    if (token == null) {
      return onError(exchange, "Missing token", HttpStatus.UNAUTHORIZED);
    }

    try {
      String userId = tokenProvider.validateAndGetUserId(token);
      // Inject user ID into downstream headers
      ServerWebExchange mutated = exchange.mutate()
          .request(exchange.getRequest().mutate()
              .header("X-User-Id", userId)
              .build())
          .build();
      return chain.filter(mutated);
    } catch (InvalidTokenException e) {
      return onError(exchange, "Invalid token", HttpStatus.UNAUTHORIZED);
    }
  }

  private String extractToken(ServerHttpRequest request) {
    String auth = request.getHeaders().getFirst("Authorization");
    if (auth != null && auth.startsWith("Bearer ")) {
      return auth.substring(7);
    }
    return null;
  }

  private Mono<Void> onError(ServerWebExchange exchange, String message, HttpStatus status) {
    ServerHttpResponse response = exchange.getResponse();
    response.setStatusCode(status);
    response.getHeaders().setContentType(MediaType.APPLICATION_JSON);
    
    byte[] bytes = ("{\"error\":\"" + message + "\"}").getBytes();
    return response.writeWith(Mono.just(response.bufferFactory().wrap(bytes)));
  }

  @Override
  public int getOrder() {
    return -100; // Run early
  }
}

Now add rate limiting. Spring Cloud Gateway integrates with Redis for distributed rate limiting:

java
spring:
  cloud:
    gateway:
      routes:
        - id: orders
          uri: http://orders:8080
          predicates:
            - Path=/api/orders/**
          filters:
            - name: RequestRateLimiter
              args:
                redis-rate-limiter.replenishRate: 10
                redis-rate-limiter.burstCapacity: 20
                key-resolver: "#{@userKeyResolver}"
            - StripPrefix=2

@Configuration
public class RateLimitConfig {

  @Bean
  public KeyResolver userKeyResolver() {
    return exchange -> Mono.just(
        exchange.getRequest().getHeaders().getFirst("X-User-Id") != null
            ? exchange.getRequest().getHeaders().getFirst("X-User-Id")
            : exchange.getRequest().getRemoteAddress().getAddress().getHostAddress()
    );
  }
}

And CORS—handled once, globally:

java
spring:
  cloud:
    gateway:
      globalcors:
        corsConfigurations:
          "[/**]":
            allowedOrigins: 
              - "https://app.company.local"
              - "https://admin.company.local"
            allowedMethods:
              - GET
              - POST
              - PUT
              - DELETE
            allowedHeaders: "*"
            allowCredentials: true
            maxAge: 3600

Every microservice now inherits this protection without writing a single line of CORS or auth logic. That's the power of centralization.

Do not add business logic

This is the line we see teams cross most often. A gateway starts as a router and gradually accumulates request transformation, payload enrichment, and orchestration logic. Before long, it's a bottleneck that's hard to test and impossible to deploy independently.

Wrong approach: using the gateway to transform request payloads or aggregate responses from multiple services.

java
// DON'T DO THIS
@Component
public class OrderEnrichmentFilter implements GlobalFilter {

  @Override
  public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
    if (exchange.getRequest().getPath().value().startsWith("/api/orders")) {
      // Call user service to enrich the order
      // Call inventory service to add stock info
      // Transform both into a combined response
      // Now the gateway owns business logic
    }
    return chain.filter(exchange);
  }
}

Right approach: the gateway routes, authenticates, and rate-limits. Individual services own their data and business rules. If you need an enriched order response, that's the orders service's job. It can call the user and inventory services itself.

Keep the gateway focused on infrastructure concerns: routing, authentication, rate limiting, logging, and circuit breaker integration. Everything else belongs in a service.

Or use managed alternatives

Not every team needs to run their own gateway. If you're on AWS, API Gateway (or Application Load Balancer with path-based routing) can handle routing and basic rate limiting out of the box. If you're on Kubernetes, an Ingress controller (Nginx, Traefik) does the same thing and often includes middleware for auth and CORS.

Spring Cloud Gateway makes sense when:

  • You need tight integration with your Spring ecosystem (service discovery, circuit breakers, custom filters).
  • You're running on-premise or in a VPC where managed options are limited.
  • Your routing logic is complex enough to warrant a dedicated service.

It's overkill when:

  • A simple load balancer with path-based routing solves your problem.
  • You're already paying for API Gateway or similar managed services.
  • Your team doesn't have Spring expertise.

Evaluate the trade-off: operational simplicity of a managed service versus flexibility and control of a self-hosted gateway. Often, managed is the right call for small teams.

Common mistakes

  • Logging at the gateway and again in each service. You'll drown in duplicate logs. Decide: log at the gateway for external traffic, let services log internal activity. Use correlation IDs to trace requests end-to-end.
  • Missing timeout and circuit breaker configuration. A slow downstream service will pile up requests at the gateway and eventually bring it down. Always set connect-timeout and response-timeout on routes, and use Resilience4j for circuit breakers.
  • Treating the gateway as stateless when it isn't. If you run multiple gateway instances and one crashes, sticky sessions or in-memory state will be lost. Use external stores (Redis) for rate limiting and session data.
  • Not testing the gateway separately. Mock downstream services and test predicate matching, filter order, and error handling. A buggy gateway breaks everything downstream.
  • Over-engineering predicates. Keep route matching simple. Complex logic in predicates becomes hard to debug. Use dedicated filters for sophisticated behavior.

Key takeaways

  • Spring Cloud Gateway provides a lightweight, non-blocking entry point for microservices. Use YAML-based route configuration for simple path-based routing.
  • Centralize cross-cutting concerns like JWT validation, CORS, and rate limiting at the gateway to keep downstream services clean and reduce duplication.
  • Do not add business logic to the gateway. It becomes a bottleneck and couples infrastructure to domain concerns. Keep it focused on routing and security.
  • Evaluate whether a managed alternative (AWS API Gateway, Kubernetes Ingress) better fits your deployment environment and team expertise before building your own.
  • Always configure timeouts, circuit breakers, and use external stores for distributed state. A gateway is infrastructure and must be resilient.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles