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:
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: ResponseLoggingWhen 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:
@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:
@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:
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:
spring:
cloud:
gateway:
globalcors:
corsConfigurations:
"[/**]":
allowedOrigins:
- "https://app.company.local"
- "https://admin.company.local"
allowedMethods:
- GET
- POST
- PUT
- DELETE
allowedHeaders: "*"
allowCredentials: true
maxAge: 3600Every 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.
// 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-timeoutandresponse-timeouton 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.






Comments
Loading comments…