API Gateway with Spring Cloud Gateway
Introduction
A system of microservices has many addresses - one per service, and they change. Browsers, mobile apps and partner systems should not have to know them. An API gateway is a single entry point that stands in front of the services: clients call the gateway, and the gateway forwards each request to the service that owns it. Because every request passes through it, the gateway is also the place for work that all services share - authentication, rate limits, CORS, logging. Spring Cloud Gateway is the gateway of the Spring ecosystem. This tutorial builds one in front of two services: routes in YAML and in Java, predicates that select a route, filters that change requests and responses, a global filter that logs every call, and a circuit breaker with a fallback for a service that is down. The code runs on Spring Boot 4.1.2 with Spring Cloud 2025.1.4 (Spring Cloud Gateway 5.0).What a gateway does
Without a gateway every client holds the address of every service, and every service must itself check tokens, answer CORS preflight requests and limit abusive callers. With a gateway the outside world sees one host, and the services behind it can be moved, split and renamed without any client noticing.
Spring Cloud Gateway comes in two flavours with the same concepts: Server WebFlux, built on Spring WebFluxClick to read tutorial: Building REST APIs with Spring WebFlux and Netty, and Server WebMVC, built on Spring MVC. This tutorial uses the WebFlux one, the original and more complete of the two. A gateway mostly waits for other servers, which is the kind of work a non-blocking server handles with few threads.
Routes, predicates and filters
Three terms describe everything a gateway is configured with -- A route is one forwarding rule: an id, a destination URI, a list of predicates and a list of filters.
- A predicate is a condition on the incoming request - its path, method, a header, a query parameter, the host, the time. A route matches when all of its predicates are true. The first route that matches wins.
- A filter changes the request before it is forwarded, or the response on its way back: it adds or removes headers, rewrites the path, applies a circuit breaker, limits the rate.
Two services behind the gateway
The two backend services are ordinary Spring MVC applications. The catalog service reports the path and one header as it received them, so that the gateway's work is visible in the response -catalog-service: src/main/java/.../catalog/CatalogApp.java
//CatalogApp.java
package com.javabrahman.cloud.gateway.catalog;
import jakarta.servlet.http.HttpServletRequest;
import java.util.LinkedHashMap;
import java.util.Map;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
@SpringBootApplication
@RestController
public class CatalogApp {
@GetMapping("/books/{isbn}")
Map<String, Object> book(@PathVariable String isbn, HttpServletRequest request) {
Map<String, Object> book = new LinkedHashMap<>();
book.put("isbn", isbn);
book.put("title", "Effective Java");
book.put("receivedPath", request.getRequestURI());
book.put("receivedSourceHeader", request.getHeader("X-Request-Source"));
return book;
}
@GetMapping("/authors/{id}")
Map<String, Object> author(@PathVariable int id) {
return Map.of("id", id, "name", "Joshua Bloch");
}
public static void main(String[] args) {
SpringApplication.run(CatalogApp.class, args);
}
}
order-service: src/main/java/.../order/OrderApp.java
//OrderApp.java
package com.javabrahman.cloud.gateway.order;
import java.util.Map;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
@SpringBootApplication
@RestController
public class OrderApp {
@GetMapping("/orders/{id}")
Map<String, Object> order(@PathVariable int id) {
return Map.of("id", id, "status", "SHIPPED");
}
public static void main(String[] args) {
SpringApplication.run(OrderApp.class, args);
}
}
The gateway: routes in YAML
The gateway is its own Spring Boot application. It needs the starterspring-cloud-starter-gateway-server-webflux, and for the circuit breaker filter spring-cloud-starter-circuitbreaker-reactor-resilience4j. Most gateways are configuration, and YAML suits the nested lists -
gateway: src/main/resources/application.yml
server:
port: 8080
spring:
application:
name: gateway
cloud:
gateway:
server:
webflux:
routes:
- id: catalog
uri: http://localhost:8081
predicates:
- Path=/api/books/**
filters:
- StripPrefix=1
- AddRequestHeader=X-Request-Source, gateway
- AddResponseHeader=X-Served-By, catalog-service
- id: orders
uri: http://localhost:8082
predicates:
- Path=/api/orders/**
- Method=GET
filters:
- RewritePath=/api/orders/(?<id>.*), /orders/$\{id}
- id: reviews
uri: http://localhost:8083
predicates:
- Path=/api/reviews/**
filters:
- StripPrefix=1
- name: CircuitBreaker
args:
name: reviews
fallbackUri: forward:/fallback/reviews
logging:
level:
com.javabrahman: INFO
- catalog - any path under
/api/books/goes to the catalog service.StripPrefix=1removes the first path segment, so/api/books/978-1arrives as/books/978-1. One header is added to the request and one to the response. - orders - two predicates: the path, and the method GET.
RewritePathdoes with a regular expression what StripPrefix did by counting;$\{id}is written with a backslash so that Spring does not treat it as a property placeholder. - reviews - wrapped in a
CircuitBreakerfilter. If the call to the review service fails, the request is forwarded inside the gateway to/fallback/reviews. - The property prefix is
spring.cloud.gateway.server.webflux. Before Spring Cloud 2025.0 it wasspring.cloud.gateway, which is what older articles show.
The gateway: a route in Java, a fallback and a global filter
Routes can also be written in Java withRouteLocatorBuilder, which helps when a route needs logic. The same class holds the fallback endpoint -
gateway: src/main/java/.../edge/GatewayApp.java
//GatewayApp.java
package com.javabrahman.cloud.gateway.edge;
import java.util.List;
import java.util.Map;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.gateway.route.RouteLocator;
import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@SpringBootApplication
@RestController
public class GatewayApp {
@Bean
RouteLocator javaRoutes(RouteLocatorBuilder builder) {
return builder.routes()
.route("authors", route -> route
.path("/api/authors/**")
.filters(filters -> filters
.stripPrefix(1)
.addResponseHeader("X-Route", "defined-in-java"))
.uri("http://localhost:8081"))
.build();
}
// where the reviews route is forwarded to when the review service fails
@GetMapping("/fallback/reviews")
Map<String, Object> reviewsFallback() {
return Map.of("reviews", List.of(), "note", "Reviews are not available right now");
}
public static void main(String[] args) {
SpringApplication.run(GatewayApp.class, args);
}
}
gateway: src/main/java/.../edge/RequestLogFilter.java
//RequestLogFilter.java
package com.javabrahman.cloud.gateway.edge;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.cloud.gateway.route.Route;
import org.springframework.cloud.gateway.support.ServerWebExchangeUtils;
import org.springframework.core.Ordered;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
@Component
public class RequestLogFilter implements GlobalFilter, Ordered {
private static final Logger log = LoggerFactory.getLogger(RequestLogFilter.class);
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
long start = System.nanoTime();
String request = exchange.getRequest().getMethod() + " " + exchange.getRequest().getPath();
// everything before chain.filter() runs before the request is forwarded ("pre"),
// everything in then() runs after the response has come back ("post")
return chain.filter(exchange).then(Mono.fromRunnable(() -> {
Route route = exchange.getAttribute(ServerWebExchangeUtils.GATEWAY_ROUTE_ATTR);
long millis = (System.nanoTime() - start) / 1_000_000;
log.info("{} -> route {} -> status {} in {} ms", request,
route != null ? route.getId() : "none", exchange.getResponse().getStatusCode().value(), millis);
}));
}
@Override
public int getOrder() {
return Ordered.HIGHEST_PRECEDENCE; // outermost: sees the request first and the response last
}
}
Mono and must not block. Code before chain.filter(exchange) acts on the request; code chained after it acts on the response.
Running the gateway
The catalog service, the order service and the gateway are started, and all requests go to the gateway on port 8080 - OUTPUT of calling the services through the gateway> curl -i http://localhost:8080/api/books/978-1
HTTP/1.1 200
X-Served-By: catalog-service
Content-Length: 104
Content-Type: application/json
{"isbn":"978-1","title":"Effective Java","receivedPath":"/books/978-1","receivedSourceHeader":"gateway"}
> curl -i http://localhost:8080/api/orders/42
HTTP/1.1 200
Content-Length: 28
Content-Type: application/json
{"id":42,"status":"SHIPPED"}
> curl -i http://localhost:8080/api/authors/7
HTTP/1.1 200
X-Route: defined-in-java
Content-Length: 30
Content-Type: application/json
{"name":"Joshua Bloch","id":7}
> curl -i http://localhost:8080/api/reviews/978-1
HTTP/1.1 200
Content-Length: 59
Content-Type: application/json
{"reviews":[],"note":"Reviews are not available right now"}
> curl -i -X POST http://localhost:8080/api/orders/42
HTTP/1.1 404
Content-Length: 122
Content-Type: application/json
{"timestamp":"2026-11-10T05:22:53.071Z","path":"/api/orders/42","status":404,"error":"Not Found","requestId":"9fada4b0-5"}
> curl -i http://localhost:8080/api/payments/1
HTTP/1.1 404
Content-Length: 123
Content-Type: application/json
{"timestamp":"2026-11-10T05:22:53.134Z","path":"/api/payments/1","status":404,"error":"Not Found","requestId":"9fada4b0-6"}
- /api/books/978-1 - the catalog service received the path
/books/978-1and the headerX-Request-Source: gateway; the client received the extra headerX-Served-By. The client never saw port 8081. - /api/orders/42 - rewritten to
/orders/42and answered by the order service. - /api/authors/7 - matched the route written in Java, as the
X-Routeheader shows. Routes from YAML and from Java work side by side. - /api/reviews/978-1 - nothing listens on port 8083. The circuit breaker filter caught the failed connection and forwarded to the fallback, so the client got status 200 with an empty list and a note, not an error page. A product page can then show the book without its reviews.
- POST /api/orders/42 - the path matched the orders route but the
Method=GETpredicate did not, and no other route matched: 404, from the gateway itself. The order service was never called. - /api/payments/1 - no route at all: 404 again. A service without a route is not reachable from outside, which makes the route list the public surface of the whole system.
2026-11-10T10:52:51.750+05:30 INFO 47048 --- [gateway] [ctor-http-nio-3] c.j.cloud.gateway.edge.RequestLogFilter : GET /api/books/978-1 -> route catalog -> status 200 in 1110 ms 2026-11-10T10:52:52.523+05:30 INFO 47048 --- [gateway] [ctor-http-nio-3] c.j.cloud.gateway.edge.RequestLogFilter : GET /api/orders/42 -> route orders -> status 200 in 638 ms 2026-11-10T10:52:52.632+05:30 INFO 47048 --- [gateway] [ctor-http-nio-3] c.j.cloud.gateway.edge.RequestLogFilter : GET /api/authors/7 -> route authors -> status 200 in 92 ms 2026-11-10T10:52:52.999+05:30 INFO 47048 --- [gateway] [ctor-http-nio-3] c.j.cloud.gateway.edge.RequestLogFilter : GET /api/reviews/978-1 -> route reviews -> status 200 in 354 ms
More that a gateway usually does
The routes above use fixed addresses. A production gateway adds a few more pieces -- Discovery - with a registry on the classpath, a route's URI can be
lb://catalog-service. The gateway then looks the name up and balances over the instances, as in the service discovery tutorialClick to read tutorial: Service Discovery: Eureka vs Kubernetes. On Kubernetes the URI is simply the Service name. - Security - the gateway is a natural OAuth2 resource server or login client: it validates the token once and the
TokenRelayfilter passes it on. Services behind it should still check what they are given. See OAuth2 resource server with JWTClick to read tutorial: Spring Security OAuth2 Resource Server with JWT. - Rate limiting - the
RequestRateLimiterfilter limits requests per user or per key with a token bucket. Its counters must be shared by all gateway instances, so it keeps them in Redis. It is not run in this tutorial. - Timeouts and retries -
spring.cloud.gateway.server.webflux.httpclient.response-timeoutbounds how long any route may wait, and theRetryfilter repeats failed calls that are safe to repeat. - CORS - configured once at the gateway under
globalcors, instead of in every service.
Summary
In this tutorial we put Spring Cloud Gateway in front of two services. Routes, in YAML and in Java, joined predicates - a path, a method - with filters that stripped a prefix, rewrote a path and added headers to requests and responses. A global filter logged every request with its route, status and time. A circuit breaker filter answered from a fallback endpoint when the review service could not be reached, and requests that matched no route were answered with 404 by the gateway itself. The circuit breaker deserves a closer look than one filter line; that is the next tutorial: Circuit Breakers and Retries with Resilience4jClick to read tutorial: Circuit Breakers and Retries with Resilience4j.Spring Cloud and Microservices Tutorials
Service Discovery: Eureka vs KubernetesClick to read tutorial: Service Discovery: Eureka vs Kubernetes Circuit Breakers and Retries with Resilience4jClick to read tutorial: Circuit Breakers and Retries with Resilience4j Microservices with Spring: When and WhyClick to read tutorial: Microservices with Spring: When and Why Centralized Configuration with Spring Cloud ConfigClick to read tutorial: Centralized Configuration with Spring Cloud Config Distributed Tracing with Micrometer and OpenTelemetryClick to read tutorial: Distributed Tracing with Micrometer and OpenTelemetry Inter-Service Communication: REST vs Messaging vs gRPCClick to read tutorial: Inter-Service Communication: REST vs Messaging vs gRPC Saga Pattern for Distributed TransactionsClick to read tutorial: Saga Pattern for Distributed Transactions
Service Discovery: Eureka vs KubernetesClick to read tutorial: Service Discovery: Eureka vs Kubernetes Circuit Breakers and Retries with Resilience4jClick to read tutorial: Circuit Breakers and Retries with Resilience4j Microservices with Spring: When and WhyClick to read tutorial: Microservices with Spring: When and Why Centralized Configuration with Spring Cloud ConfigClick to read tutorial: Centralized Configuration with Spring Cloud Config Distributed Tracing with Micrometer and OpenTelemetryClick to read tutorial: Distributed Tracing with Micrometer and OpenTelemetry Inter-Service Communication: REST vs Messaging vs gRPCClick to read tutorial: Inter-Service Communication: REST vs Messaging vs gRPC Saga Pattern for Distributed TransactionsClick to read tutorial: Saga Pattern for Distributed Transactions