Clients should not fan out to every backend URL you invent this quarter. An API gateway is the single front door: one hostname, one TLS termination point, and a place to enforce cross-cutting rules before traffic hits order, catalog, or auth services.
Spring Boot does not ship “an API gateway” by itself. Spring Cloud does — in two Server flavors. This page is architecture-first: when a gateway earns its keep, what you give up, how the WebFlux and Web MVC gateways differ, then a working Spring Cloud Gateway Server Web MVC lab (the Servlet / MVC style).
What an API gateway is for
In a multi-service system the gateway sits between external clients and internal APIs:
Browser / mobile / partner
|
v
API Gateway <-- auth, routing, headers, rate limits, metrics
|
+----+----+----+
| | |
Order Catalog Auth
Its job is not “be another microservice with business rules.” Its job is edge concerns:
- Routing — map
/api/orders/**to the order service without teaching every client the internal hostname. - Policy — authenticate, authorize, strip or inject headers, CORS, request size limits.
- Shape — path rewrite, protocol adaptation, aggregate only when the edge truly owns the composition.
- Observability — one place to attach request IDs, latency histograms, and access logs for inbound traffic.
- Resilience at the edge — timeouts, retries (carefully), circuit breakers toward downstreams.
Note: A reverse proxy (nginx, Envoy, cloud load balancer) can cover TLS and crude routing. A Spring gateway earns its place when you want application-aware routes, Spring Security integration, and filters written in the same language as the rest of the platform.
When you should use one
Reach for a gateway when several of these are true:
- More than one backend is exposed to the same external audience.
- You want a stable public URL while services rename, split, or move.
- Authn/authz, API keys, or tenant checks should run once at the edge.
- You need consistent CORS, rate limiting, or audit headers across teams.
- Mobile or partner clients must not discover internal service DNS.
Skip a dedicated gateway when:
- You have one deployable API and a load balancer is enough.
- Every “route” is really business logic — that belongs in a BFF or the service itself.
- Your platform already fronts everything with a mature mesh/gateway (Istio, Kong, AWS API Gateway) and duplicating policy in Spring only adds a hop.
Note: A Backend-for-Frontend (BFF) is not the same animal. A BFF shapes responses for one client type (web vs mobile). A gateway is the shared edge for many clients. Teams often run both: gateway → BFFs → internals.
Trade-offs you actually feel
| You gain | You pay |
|---|---|
| One public contract | Extra network hop and failure domain |
| Central policy | Gateway becomes a release bottleneck if every team waits on it |
| Easier client config | Misconfigured routes become a production outage for all APIs |
| Shared metrics at ingress | Debugging needs correlation IDs across gateway + service |
| Path rewrite / facade | Over-aggregation turns the gateway into a god service |
Operational rules that keep the trade-off honest:
- Keep gateway filters thin — identity, routing, headers, limits. Not order totals.
- Prefer config-driven routes for stable paths; use code routes when filters need real logic.
- Treat the gateway like production data plane: health checks, autoscaling, and a rollback plan.
- Do not retry non-idempotent POSTs by default. Time out loudly; leave “clever” retries to the caller or a queue.
Two Spring Cloud Gateway Server flavors
Spring Cloud Gateway currently ships two full Server variants (plus lighter Proxy Exchange helpers for annotated controllers). You pick one stack for the gateway process:
| Server WebFlux | Server Web MVC | |
|---|---|---|
| Runtime | Reactive (Netty / WebFlux) | Servlet (Tomcat / Jetty) + WebMvc.fn |
| Starter (current) | spring-cloud-starter-gateway-server-webflux | spring-cloud-starter-gateway-server-webmvc |
| Best fit | Very high concurrency, non-blocking teams, reactive downstreams | Teams already on MVC/Servlet, Java 21 virtual threads, familiar filter/debug model |
| Mental model | RouteLocator, WebFlux filters | RouterFunctions, before/after HandlerFilterFunctions |
Older docs and starters say Spring Cloud Gateway for the reactive server and Gateway MVC for the Servlet one. Current docs say Server WebFlux and Server Web MVC. Same idea: reactive edge vs Servlet edge.
Choose WebFlux when the gateway must squeeze maximum concurrent connections on fewer threads and your org already operates reactive apps.
Choose Web MVC when you want the gateway to feel like every other Spring MVC service — blocking I/O is fine, especially with virtual threads on Java 21+, and you prefer Servlet filters, Actuator, and debugging habits you already have.
The rest of this post focuses on Server Web MVC.
Mental model: route → predicate → filter → proxy
On the MVC server, a route is a WebMvc.fn RouterFunction with a gateway HTTP handler. Rough pipeline:
- Predicate — does this request match? (path, method, header, host, …)
- Before filters — mutate the inbound request (URI, headers, path rewrite).
- Handler —
HandlerFunctions.http()proxies to the downstream. - After filters — mutate the response (headers, status rewriting).
YAML routes are convenient for ops. Java RouterFunction beans are better when a filter needs branching logic or typed config.
Note: Any path on the route uri itself is ignored — path handling belongs in predicates and rewrite filters, not in http://orders:8080/api style URIs.
Working example: edge in front of two services
Lab goal: run a gateway on :8080 that fronts a fake order API and a fake catalog API. You can point the URIs at real local services, or at httpbin.org to practice without writing backends.
Project skeleton
Use Spring Boot 3.5.x with Spring Cloud release train 2025.0.x (or Boot 3.4.x with 2024.0.x — keep the compatibility table honest).
pom.xml essentials:
<properties>
<java.version>21</java.version>
<spring-cloud.version>2025.0.0</spring-cloud.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring-cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway-server-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
</dependencies>
Optional but recommended on Java 21+ so Servlet blocking work rides virtual threads:
spring:
threads:
virtual:
enabled: true
Note: On older Cloud trains you may still see spring-cloud-starter-gateway-mvc and spring.cloud.gateway.mvc.routes. Prefer spring-cloud-starter-gateway-server-webmvc and spring.cloud.gateway.server.webmvc on current trains — same Servlet idea, current names.
YAML routes
application.yml:
server:
port: 8080
spring:
application:
name: edge-gateway
cloud:
gateway:
server:
webmvc:
routes:
- id: orders
uri: http://localhost:8081
predicates:
- Path=/api/orders/**
filters:
- StripPrefix=1
- AddRequestHeader=X-Edge, gateway-mvc
- id: catalog
uri: http://localhost:8082
predicates:
- Path=/api/catalog/**
filters:
- StripPrefix=1
- AddRequestHeader=X-Edge, gateway-mvc
- id: httpbin-get
uri: https://httpbin.org
predicates:
- Path=/lab/get
filters:
- SetPath=/get
management:
endpoints:
web:
exposure:
include: health,info,gateway
What this does:
/api/orders/1→http://localhost:8081/orders/1afterStripPrefix=1drops theapisegment./api/catalog/...likewise to port8082./lab/getproxies to httpbin’s/getso you can verify headers without local backends.
If you only want the httpbin lab, comment out the orders/catalog routes and start the app.
Same routes in Java
Prefer code when filters get interesting. Equivalent beans:
import static org.springframework.cloud.gateway.server.mvc.filter.BeforeFilterFunctions.addRequestHeader;
import static org.springframework.cloud.gateway.server.mvc.filter.BeforeFilterFunctions.setPath;
import static org.springframework.cloud.gateway.server.mvc.filter.BeforeFilterFunctions.uri;
import static org.springframework.cloud.gateway.server.mvc.filter.FilterFunctions.stripPrefix;
import static org.springframework.cloud.gateway.server.mvc.handler.GatewayRouterFunctions.route;
import static org.springframework.cloud.gateway.server.mvc.handler.HandlerFunctions.http;
import static org.springframework.web.servlet.function.RequestPredicates.path;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.function.RouterFunction;
import org.springframework.web.servlet.function.ServerResponse;
@Configuration
class GatewayRoutesConfig {
@Bean
RouterFunction<ServerResponse> ordersRoute() {
return route("orders")
.route(path("/api/orders/**"), http())
.before(uri("http://localhost:8081"))
.filter(stripPrefix(1))
.before(addRequestHeader("X-Edge", "gateway-mvc"))
.build();
}
@Bean
RouterFunction<ServerResponse> catalogRoute() {
return route("catalog")
.route(path("/api/catalog/**"), http())
.before(uri("http://localhost:8082"))
.filter(stripPrefix(1))
.before(addRequestHeader("X-Edge", "gateway-mvc"))
.build();
}
@Bean
RouterFunction<ServerResponse> httpbinRoute() {
return route("httpbin-get")
.GET("/lab/get", http())
.before(uri("https://httpbin.org"))
.before(setPath("/get"))
.build();
}
}
Use either YAML or Java for a given route id — do not maintain two sources of truth for the same path.
Smoke test
Start the gateway, then:
curl -sS http://localhost:8080/lab/get | jq .
You should see httpbin echo headers, including X-Edge: gateway-mvc when that filter is on the route. Point /api/orders/** at a real service (or a second Boot app on 8081) when you are ready for a full local mesh.
curl -sS http://localhost:8080/api/orders/42
curl -sS -X POST http://localhost:8080/api/orders -H 'Content-Type: application/json' -d '{"sku":"abc"}'
Architecture patterns that belong at this edge
Auth at the door
Validate JWT or opaque tokens at the gateway, then forward a trusted identity header (or leave the Authorization header) to internals. Internals still authorize business actions; the gateway proves who is calling.
Pair Spring Security with the MVC gateway the same way you would with any Servlet app — filter chain first, then route predicates.
Rate limiting and abuse controls
Global or route-scoped limits protect backends from thundering herds. Keep counters in Redis (or your platform’s limiter) so multiple gateway instances share budget. Return 429 with a clear body; do not silently queue at the edge.
CORS once
Browsers talk to the gateway origin. Configure CORS on the gateway so every microservice does not invent its own Access-Control-* story.
Observability
Propagate traceparent / X-Request-Id in a before filter if your platform does not already. Expose Actuator health for orchestrators. Metrics should include route id, downstream status, and latency — that is how you tell “edge slow” from “order service slow.”
What not to build here
- Cart checkout workflows
- Multi-service aggregation that needs transactions
- Per-tenant business pricing rules
Those rot into an untestable monolith in front of your monoliths.
Decision cheat sheet
One service, one client? -> Load balancer / ingress only
Many services, shared edge? -> API gateway
Need UI-specific shaping? -> BFF behind (or beside) the gateway
Team lives in Servlet/MVC? -> Gateway Server Web MVC
Team lives in reactive? -> Gateway Server WebFlux
Policy already in the mesh? -> Do not double-gateway without a reason
Wrap-up
An API gateway is a deliberate hop: you buy a stable front door and centralized edge policy, and you accept another component that must be as carefully operated as any customer-facing API. Spring offers that door on both reactive and Servlet stacks; Server Web MVC is the right default when your platform is already Tomcat/Jetty, Spring MVC, and (optionally) virtual threads.
From here, deepen the edge with Spring Security resource-server config on the gateway, Redis-backed request rate limiting per route, and shared tracing across gateway and services. Keep the filters boring — boring edge code is a feature.