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:

  1. Routing — map /api/orders/** to the order service without teaching every client the internal hostname.
  2. Policy — authenticate, authorize, strip or inject headers, CORS, request size limits.
  3. Shape — path rewrite, protocol adaptation, aggregate only when the edge truly owns the composition.
  4. Observability — one place to attach request IDs, latency histograms, and access logs for inbound traffic.
  5. 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 gainYou pay
One public contractExtra network hop and failure domain
Central policyGateway becomes a release bottleneck if every team waits on it
Easier client configMisconfigured routes become a production outage for all APIs
Shared metrics at ingressDebugging needs correlation IDs across gateway + service
Path rewrite / facadeOver-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 WebFluxServer Web MVC
RuntimeReactive (Netty / WebFlux)Servlet (Tomcat / Jetty) + WebMvc.fn
Starter (current)spring-cloud-starter-gateway-server-webfluxspring-cloud-starter-gateway-server-webmvc
Best fitVery high concurrency, non-blocking teams, reactive downstreamsTeams already on MVC/Servlet, Java 21 virtual threads, familiar filter/debug model
Mental modelRouteLocator, WebFlux filtersRouterFunctions, 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:

  1. Predicate — does this request match? (path, method, header, host, …)
  2. Before filters — mutate the inbound request (URI, headers, path rewrite).
  3. HandlerHandlerFunctions.http() proxies to the downstream.
  4. 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/1http://localhost:8081/orders/1 after StripPrefix=1 drops the api segment.
  • /api/catalog/... likewise to port 8082.
  • /lab/get proxies to httpbin’s /get so 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.