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. A web app, a mobile app and a partner system call one API gateway on port 8080; the gateway handles routing, authentication, rate limiting, CORS and logging, and forwards requests to the catalog, order and review services behind it 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.
A request for GET /api/books/978-1 enters the gateway; the route predicates Path=/api/books/** match; the filters StripPrefix and AddRequestHeader change the request, which is forwarded to the catalog service as GET /books/978-1; on the way back AddResponseHeader adds a header to the response

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 catalog service runs on port 8081 and the order service on 8082. A third service, for reviews, is expected on port 8083 - and is deliberately not running.

The gateway: routes in YAML

The gateway is its own Spring Boot application. It needs the starter spring-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
Reading the three routes -
  • catalog - any path under /api/books/ goes to the catalog service. StripPrefix=1 removes the first path segment, so /api/books/978-1 arrives 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. RewritePath does 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 CircuitBreaker filter. 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 was spring.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 with RouteLocatorBuilder, 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);
  }
}
The filters in the YAML belong to one route each. A global filter applies to every route. This one logs each request with the route it matched, the status and the time taken -
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
  }
}
A gateway filter is a reactive function: it returns a 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"}
Request by request -
  • /api/books/978-1 - the catalog service received the path /books/978-1 and the header X-Request-Source: gateway; the client received the extra header X-Served-By. The client never saw port 8081.
  • /api/orders/42 - rewritten to /orders/42 and answered by the order service.
  • /api/authors/7 - matched the route written in Java, as the X-Route header 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=GET predicate 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.
The global filter logged these lines in the gateway's console -  OUTPUT in the gateway's log
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
There are four lines for six requests. Global filters run as part of a route, so the two requests that matched no route never reached the filter. The first call through each route took longer than the later ones because the gateway opens its connection to the service then; the call through the Java route reused the connection to port 8081.

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 TokenRelay filter 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 RequestRateLimiter filter 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-timeout bounds how long any route may wait, and the Retry filter repeats failed calls that are safe to repeat.
  • CORS - configured once at the gateway under globalcors, instead of in every service.
A gateway is also a single point of failure and a tempting place for business logic. Run at least two instances of it, and keep it to routing and cross-cutting work: a rule about orders belongs in the order 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.