API Gateway and Service Discovery in Spring Cloud
A staff-engineer guide to Spring Cloud Gateway routing, service discovery, and centralized configuration.
API Gateway and Service Discovery
As a system grows past a handful of services, two problems appear that individual services shouldn't have to solve on their own: how do external clients find the right service without knowing internal topology, and how do services find each other when instances scale up, scale down, and get rescheduled constantly? This guide covers the two building blocks that answer these questions in a Spring Cloud stack: the API Gateway for edge routing, and service discovery for internal service resolution — plus centralized configuration, which both depend on.
1. Why You Need an Edge Layer
Without a gateway, every client (web app, mobile app, third-party integrator) needs to know the address of every service, handle auth for each one individually, and adapt when service topology changes. An API Gateway centralizes this into a single, well-known entry point.
What a gateway centralizes
| Concern | Without a gateway | With a gateway |
|---|---|---|
| Routing | Clients hardcode service hostnames | Clients call one host; gateway routes by path |
| Auth | Every service implements token validation | Validated once at the edge (or delegated per-route) |
| Rate limiting | Each service reinvents throttling | Centralized, consistent policy |
| TLS termination | Certificates managed per service | Single termination point |
| Cross-cutting logging/tracing | Inconsistent per service | Uniform request logging, correlation IDs injected once |
| API versioning/aggregation | Clients handle multiple backend shapes | Gateway can adapt/aggregate responses |
A gateway is not a place to put business logic. It's tempting to add request transformation, orchestration, or domain validation into gateway filters because "it's centralized and easy." Resist this — it turns the gateway into an undocumented, hard-to-test extension of every service behind it, and couples deployment of the gateway to business logic changes that should live in the owning service.
2. Spring Cloud Gateway: Routes, Predicates, Filters
Spring Cloud Gateway (built on Project Reactor/Netty) is Spring's non-blocking API gateway. Routing is defined declaratively as a set of routes, each combining a predicate (when to match) and filters (what to do to the request/response).
# application.yml
spring:
cloud:
gateway:
routes:
- id: order-service-route
uri: lb://order-service # lb:// = resolve via service discovery
predicates:
- Path=/api/orders/**
filters:
- StripPrefix=1
- AddRequestHeader=X-Gateway-Source, edge
- name: CircuitBreaker
args:
name: orderServiceCB
fallbackUri: forward:/fallback/orders
- name: RequestRateLimiter
args:
redis-rate-limiter.replenishRate: 50
redis-rate-limiter.burstCapacity: 100
key-resolver: "#{@userKeyResolver}"
- id: payment-service-route
uri: lb://payment-service
predicates:
- Path=/api/payments/**
- Method=GET,POST
filters:
- StripPrefix=1
- name: Retry
args:
retries: 2
statuses: BAD_GATEWAY, GATEWAY_TIMEOUT
methods: GET
- id: legacy-user-service-route
uri: http://legacy-user-service.internal:8080
predicates:
- Path=/api/users/**
- Header=X-API-Version, v1
filters:
- StripPrefix=1
- RewritePath=/api/users/(?<segment>.*), /legacy/users/${segment}Programmatic route configuration (equivalent to YAML)
@Configuration
public class GatewayRoutesConfig {
@Bean
public RouteLocator customRoutes(RouteLocatorBuilder builder) {
return builder.routes()
.route("order-service-route", r -> r
.path("/api/orders/**")
.filters(f -> f
.stripPrefix(1)
.addRequestHeader("X-Gateway-Source", "edge")
.circuitBreaker(c -> c
.setName("orderServiceCB")
.setFallbackUri("forward:/fallback/orders")))
.uri("lb://order-service"))
.build();
}
@Bean
public KeyResolver userKeyResolver() {
// Rate-limit per authenticated user rather than globally
return exchange -> Mono.justOrEmpty(
exchange.getRequest().getHeaders().getFirst("X-User-Id")
).defaultIfEmpty("anonymous");
}
}lb://service-name tells the gateway to resolve the target host through the configured service discovery client (Eureka, Consul, or Kubernetes Service) rather than a static URI. This is what lets you deploy new instances of order-service without ever touching the gateway's routing configuration.
Common gateway filters
| Filter | Purpose |
|---|---|
StripPrefix | Removes leading path segments before forwarding (e.g., /api/orders/1 → /orders/1) |
RewritePath | Regex-based path rewriting for legacy or versioned backends |
AddRequestHeader / AddResponseHeader | Inject correlation IDs, source markers, security headers |
CircuitBreaker | Wraps the route in a Resilience4j circuit breaker with a fallback route |
RequestRateLimiter | Token-bucket rate limiting, typically backed by Redis |
Retry | Retries idempotent requests on specific status codes |
RemoveRequestHeader | Strips internal/sensitive headers before forwarding externally or scrubs client-set trust headers |
Strip client-supplied trust headers at the gateway. If your internal services trust a header like X-User-Roles or X-Internal-Auth set by the gateway after authentication, you must explicitly remove any client-supplied value for that same header at the edge before setting your own. Otherwise a client can simply set X-User-Roles: ADMIN themselves and bypass authorization entirely.
3. Service Discovery: Client-Side vs Server-Side
Once you have more than one instance of a service (which you always do in production), something needs to track which instances are alive and where they are. This is service discovery, and it comes in two architectural flavors.
| Aspect | Client-side discovery (Eureka + Ribbon/LoadBalancer) | Server-side discovery (mesh, k8s Service, ALB) |
|---|---|---|
| Who resolves the instance? | The calling service, via a local registry lookup | An intermediary (load balancer / mesh proxy) |
| Extra network hop? | No — call goes direct to the instance | Yes — call goes through the proxy/LB first |
| Client complexity | Higher — needs a discovery client library | Lower — client just calls a fixed address |
| Language/platform coupling | Requires a discovery-aware client in every language used | Language-agnostic — any HTTP client works |
| Failure isolation | Registry outage degrades to stale cache, calls still work | LB/mesh outage can block all traffic |
| Typical implementation | Netflix Eureka, Consul with client SDK | Kubernetes Service + kube-proxy, Istio/Linkerd, cloud ALB |
Kubernetes-native deployments increasingly favor server-side discovery (a Service object with kube-proxy or a service mesh sidecar) because it's language-agnostic and removes the need for a discovery client library in every service. Spring Cloud's Eureka-based client-side discovery remains common in non-Kubernetes deployments or where the extra hop of a mesh proxy isn't wanted.
4. Eureka: Client-Side Discovery in Spring Cloud
Eureka is Netflix's service registry, integrated into Spring Cloud as Spring Cloud Netflix Eureka. Services register themselves on startup and send periodic heartbeats; other services query the registry to resolve instances.
Setting up a Eureka server
@SpringBootApplication
@EnableEurekaServer
public class DiscoveryServerApplication {
public static void main(String[] args) {
SpringApplication.run(DiscoveryServerApplication.class, args);
}
}# discovery-server application.yml
server:
port: 8761
eureka:
client:
# A standalone registry doesn't need to register with itself
register-with-eureka: false
fetch-registry: false
server:
# In dev, disable self-preservation so dead instances are evicted quickly
enable-self-preservation: falseRegistering a service as a Eureka client
# inventory-service application.yml
spring:
application:
name: inventory-service # this is the logical name other services use to find it
eureka:
client:
service-url:
defaultZone: http://discovery-server:8761/eureka/
instance:
prefer-ip-address: true
lease-renewal-interval-in-seconds: 10
lease-expiration-duration-in-seconds: 30@SpringBootApplication
@EnableDiscoveryClient
public class InventoryServiceApplication {
public static void main(String[] args) {
SpringApplication.run(InventoryServiceApplication.class, args);
}
}Self-preservation mode is not a bug. Eureka's default behavior, if it stops receiving heartbeats from a large fraction of instances at once, is to assume it's the registry that has a network problem — not that every instance actually died — and stop evicting entries. This protects against a registry-side network partition wiping out your entire service map. It can be confusing in local development (stale instances linger), which is why disabling it for local/dev environments is common, but leave it enabled in production.
5. Centralized Configuration with Config Server
As service count grows, keeping each service's application.yml in sync (database URLs, feature flags, rate limits) across environments becomes its own coordination problem. Spring Cloud Config Server centralizes configuration in a single Git repository (or Vault, or a database), served over HTTP to every service at startup — and optionally refreshed at runtime.
@SpringBootApplication
@EnableConfigServer
public class ConfigServerApplication {
public static void main(String[] args) {
SpringApplication.run(ConfigServerApplication.class, args);
}
}# config-server application.yml
server:
port: 8888
spring:
cloud:
config:
server:
git:
uri: https://github.com/your-org/config-repo
default-label: main
search-paths: '{application}'
clone-on-start: true# inventory-service bootstrap.yml (or spring.config.import in Boot 3.x)
spring:
config:
import: "configserver:http://config-server:8888"
application:
name: inventory-service
profiles:
active: prod// Beans annotated @RefreshScope pick up new values without a restart
// when POST /actuator/refresh is called (requires spring-boot-starter-actuator)
@RefreshScope
@Component
public class RateLimitProperties {
@Value("${rate-limit.requests-per-second:100}")
private int requestsPerSecond;
public int getRequestsPerSecond() {
return requestsPerSecond;
}
}Version your config alongside your code, but deploy it separately. Storing configuration in Git gives you audit history, code review for config changes, and rollback via git revert — but a config change taking effect should not require a full service redeploy. Combine Config Server with @RefreshScope and a webhook-triggered /actuator/refresh (or Spring Cloud Bus for fleet-wide refresh) so config changes propagate independently of deployments.
Never store secrets (DB passwords, API keys) in plaintext in the config Git repo, even a private one. Use Spring Cloud Config's integration with HashiCorp Vault, or environment-injected secrets from your orchestrator (Kubernetes Secrets, AWS Secrets Manager) referenced by placeholder — not committed values. A Git history is forever; a leaked credential in an old commit is a leaked credential.
6. Putting It Together: Request Flow Through the Stack
Notice discovery happens twice here: once at the gateway (resolving which order-service instance handles the external request), and again inside Order Service (resolving which inventory-service instance to call internally). This is normal — the gateway only removes the need for clients to know internal topology; it doesn't replace service-to-service discovery for east-west traffic.
7. Choosing Your Discovery and Gateway Strategy
Don't run Eureka on top of Kubernetes "just because Spring Cloud supports it." Kubernetes already provides service discovery via Service objects and DNS. Layering Eureka on top is redundant infrastructure with its own failure modes (registry availability, heartbeat tuning) solving a problem the platform already solves. Reach for Eureka when you're not on Kubernetes, or when you need Spring-specific client-side load-balancing behavior that the platform doesn't provide.
Key takeaways
- An API Gateway centralizes routing, auth, rate limiting, and TLS termination at the edge — but it should never contain business logic; that belongs in the owning service.
- Spring Cloud Gateway routes are predicate + filter pairs;
lb://service-nameroutes through service discovery instead of a static address, decoupling the gateway from instance topology. - Always strip client-supplied trust headers (e.g.,
X-User-Roles) at the gateway before setting your own — otherwise clients can forge them and bypass authorization. - Client-side discovery (Eureka) resolves instances in the caller and calls directly, avoiding an extra hop; server-side discovery (k8s Service, service mesh) adds a hop but is language-agnostic and simpler for clients.
- Eureka's self-preservation mode is a deliberate safety feature against registry-side network partitions — don't "fix" it in production by disabling it, only in local/dev.
- On Kubernetes, prefer native Service discovery (and a mesh if you need mTLS/canary/traffic policy) over layering Eureka on top — it's redundant infrastructure solving an already-solved problem.
- Centralize configuration with Config Server backed by Git for audit history, but decouple config changes from deployments using
@RefreshScopeand/actuator/refresh. - Never commit secrets to a config repository, even a private one — Git history is permanent. Use Vault or your orchestrator's secret store.
Interview Questions
- What problems does an API Gateway solve that individual services shouldn't solve on their own?
- Why should business logic never live inside gateway filters?
- Explain the difference between client-side and server-side service discovery, with examples of each.
- How does Eureka's heartbeat and lease-expiration mechanism work? What happens when a service instance stops sending heartbeats?
- What is Eureka's self-preservation mode, and why does disabling it in production carry risk?
- Walk through what
lb://service-namedoes in a Spring Cloud Gateway route definition. - How would you prevent a client from forging an internal trust header like
X-User-Rolesthrough the gateway? - Why might you skip Eureka entirely on a Kubernetes deployment?
- What is the purpose of Spring Cloud Config Server, and how does it differ from just committing
application.ymlper service? - How do you propagate a configuration change to running services without a full redeploy?
- Why should secrets never be stored directly in a config Git repository?
- Describe the full request path from an external client to a downstream service through a gateway, discovery server, and two internal services.
- What's the trade-off of adding a service mesh proxy on every hop versus direct client-side discovery calls?
- How would you implement rate limiting per authenticated user (not globally) at the gateway layer?