Yesterday my two services learned to find each other by name through Eureka. But zoom out to the client's point of view and nothing had improved for it: the outside world still had to know each service's address. Orders on :8080, inventory on :8081, and tomorrow payments on :8083. Every client would need a map of the internal topology, a separate CORS relationship with each service, and its own copy of every cross-cutting concern — auth, rate limiting, logging. Today I put a single front door in front of everything: an API gateway.
The idea
A gateway is one public entry point. The client sends every request to one URL; the gateway matches the path against a route table, resolves the target service by name, load-balances to a live instance, and forwards. The services move, scale and multiply behind it, and the client never notices.
Spring Cloud Gateway is a Spring Boot app whose whole job is that. It runs on the reactive stack — WebFlux on Netty, not servlet Tomcat — which is the right call for something that spends nearly all its time waiting on the network: a non-blocking event loop soaks up thousands of concurrent in-flight requests without parking a thread per connection. Its behaviour is declarative — a route table of predicate → uri + filters — defined in YAML, not wired in Java.
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
spring:
cloud:
gateway:
routes:
- id: order-service
uri: lb://order-service
predicates:
- Path=/api/orders/**
filters:
- AddRequestHeader=X-Gateway, order-hub-edge
- id: inventory-service
uri: lb://inventory-service
predicates:
- Path=/api/inventory/**
server:
port: 8080
A predicate answers one question — does this request belong to this route? The gateway walks the table top-to-bottom and forwards to the first match; no match returns a 404 without touching any service. The magic is in the uri: not a host:port, but lb://order-service. That lb:// scheme tells the gateway to ask Spring Cloud LoadBalancer for a live instance of "order-service", which pulls the current list from Eureka and picks one. Routing by name is really "discover the instances, then balance across them" — the gateway holds zero hardcoded addresses. Scale a service to three replicas and it spreads traffic automatically; add a new service on a new route with no gateway edit.
Concerns at the edge, once
A route filter runs for one route; a global filter runs for all of them, which is exactly what cross-cutting concerns need. I added one that mints or reuses an X-Correlation-Id, stamps it on the forwarded request so every downstream service logs the same trace id, and echoes it back on the response.
@Component
public class CorrelationIdGlobalFilter implements GlobalFilter, Ordered {
public Mono<Void> filter(ServerWebExchange ex, GatewayFilterChain chain) {
String id = /* header or fresh UUID */;
var m = ex.mutate().request(r -> r.header("X-Correlation-Id", id)).build();
m.getResponse().getHeaders().set("X-Correlation-Id", id);
return chain.filter(m).then(Mono.fromRunnable(() -> log.info("[{}] done", id)));
}
public int getOrder(){ return Ordered.HIGHEST_PRECEDENCE; }
}
CORS is configured once here too, in globalcors, allowing the React UI's origin — so the services behind the gateway need no CORS config at all. And this is where auth belongs: verify the caller once at the door, reject anonymous requests with a 401 before they reach anything, and forward the trusted identity inward. One edge, one place for policy.
Two gotchas that shaped the config
First, StripPrefix. The common gateway tutorial strips the /api prefix before forwarding, because the service listens on /orders. Mine don't — order-service already serves /api/orders, inventory already serves /api/inventory. So adding StripPrefix=1 would turn /api/orders into /orders and 404 every request. The right answer was to add no strip filter at all and let the path pass through unchanged. "As needed" really does mean as needed.
Second, the route target has to be the real Eureka service id, not a name you wish it had. My order service had registered as order-hub since the monolith days, so lb://order-service resolved to zero instances. I renamed its spring.application.name to order-service so its registered name matches its module and the gateway can address it cleanly — and moved it off port 8080 to 8082, because 8080 is now the gateway's, the one port the outside world sees.
The tradeoff
A gateway centralises routing and policy, but it's now a single point of failure and one extra hop on every request — so in production it must be highly available (several replicas behind an ingress) and kept lean. And resist the "god gateway": keep business logic out; routing and truly cross-cutting concerns only. The reactor is four modules now, and mvn clean install stays green.
Full 3-tier walkthrough (with a live gateway pipeline sim): https://dev48v.infy.uk/orderhub.php
Code: https://github.com/dev48v/order-hub-from-zero
Top comments (0)