Route Predicate Evaluation and Matching
Route Predicate Evaluation and Matching
Every request that enters the SaaS backend’s API gateway must be matched to exactly one route. The matching is done by predicates: composable boolean functions that evaluate against the incoming request. If you get predicates wrong, requests go to the wrong service or return 404. Understanding how predicates are created, combined, and ordered is the difference between a gateway that routes correctly and one that silently misroutes traffic.
RoutePredicateFactory
Predicates are not created directly. They are created by RoutePredicateFactory implementations. Each factory takes a configuration object and produces an AsyncPredicate<ServerWebExchange>:
public interface RoutePredicateFactory<C>
extends ShortcutConfigurable, Configurable<C> {
AsyncPredicate<ServerWebExchange> applyAsync(C config);
default Predicate<ServerWebExchange> apply(C config) {
// Default implementation wraps applyAsync
}
}
Spring Cloud Gateway ships with a dozen built-in factories. Each factory handles a specific aspect of request matching.
PathRoutePredicateFactory
The most common predicate. Matches against the request path using Ant-style patterns:
predicates:
- Path=/api/orders/**
The factory creates a predicate that uses Spring’s PathPatternParser (not AntPathMatcher) to match the request path. PathPatternParser is the faster, stricter parser introduced in Spring 5.
** matches any number of path segments. /api/orders/** matches /api/orders/123, /api/orders/123/items, and /api/orders. It does not match /api/order (no trailing s).
Multiple patterns can be specified:
predicates:
- Path=/api/orders/**,/api/legacy-orders/**
The predicate matches if any pattern matches. This is an OR within a single predicate.
When a path predicate matches, it extracts path variables and stores them in the exchange attributes for later use by filters (e.g., RewritePath).
HostRoutePredicateFactory
Matches against the Host header. For the SaaS backend’s multi-tenant routing:
routes:
- id: tenant-route
uri: lb://tenant-service
predicates:
- Host={tenant}.saas.example.com
The {tenant} segment is a URI template variable. When a request arrives for acme.saas.example.com, the predicate matches and extracts tenant=acme. The extracted value is stored in the exchange attributes as ServerWebExchangeUtils.URI_TEMPLATE_VARIABLES_ATTRIBUTE.
Filters can access it:
Map<String, String> uriVariables = ServerWebExchangeUtils
.getUriTemplateVariables(exchange);
String tenant = uriVariables.get("tenant");
This enables tenant routing without JWT parsing at the predicate level.
MethodRoutePredicateFactory
Matches against the HTTP method:
predicates:
- Method=GET,POST
Simple, but useful for separating read and write routes to different service instances.
HeaderRoutePredicateFactory
Matches against a specific header value using a regular expression:
predicates:
- Header=X-Api-Version, v2\.\d+
This routes requests with X-Api-Version: v2.0, X-Api-Version: v2.1, etc. to a specific backend. The SaaS backend uses this for API versioning:
routes:
- id: orders-v2
uri: lb://order-service-v2
predicates:
- Path=/api/orders/**
- Header=X-Api-Version, v2
order: 1
- id: orders-v1
uri: lb://order-service
predicates:
- Path=/api/orders/**
order: 2
Requests with X-Api-Version: v2 route to order-service-v2. All others fall through to order-service (v1). Order ensures v2 is evaluated first.
Other Built-in Factories
QueryRoutePredicateFactory: matches query parameters.Query=category, electronicsmatches?category=electronics.CookieRoutePredicateFactory: matches cookies.Cookie=session, .+matches any request with asessioncookie.BeforeRoutePredicateFactory,AfterRoutePredicateFactory,BetweenRoutePredicateFactory: time-based matching. Useful for canary deployments with time windows.WeightRoutePredicateFactory: assigns a weight to a route for weighted routing (canary releases).RemoteAddrRoutePredicateFactory: matches client IP.RemoteAddr=192.168.1.0/24routes internal traffic differently.
Combining Predicates
When a route has multiple predicates, they are combined with logical AND:
predicates:
- Path=/api/orders/**
- Method=POST
- Header=Content-Type, application/json
All three must match. The request must have the correct path AND be a POST AND have the correct Content-Type header.
In the Java DSL, you can combine predicates explicitly:
@Bean
public RouteLocator customRouteLocator(
RouteLocatorBuilder builder) {
return builder.routes()
.route("complex-route", r -> r
.path("/api/orders/**")
.and()
.method(HttpMethod.POST)
.and()
.header("X-Api-Version", "v2")
.uri("lb://order-service-v2"))
.route("negated-route", r -> r
.path("/api/internal/**")
.and()
.not(p -> p.remoteAddr("0.0.0.0/0"))
.uri("lb://internal-service"))
.build();
}
and(), or(), and negate() (via not()) let you build complex predicate trees. The YAML shorthand only supports AND (multiple predicates in the list). For OR or NOT, use the Java DSL.
Route Priority and Ordering
When multiple routes could match a request, order determines which one wins. The first matching route in iteration order is selected.
Routes are ordered by:
- The
orderproperty (if set). Lower values have higher priority. - Declaration order in YAML (top to bottom).
- For Java DSL routes, the order of
route()calls.
# BROKEN: Overlapping predicates with no explicit ordering
routes:
- id: catch-all
uri: lb://default-service
predicates:
- Path=/api/**
- id: orders
uri: lb://order-service
predicates:
- Path=/api/orders/**
The catch-all route matches /api/orders/123 because /api/** matches it. Since catch-all is declared first, it wins. order-service never receives order requests.
# CORRECT: Specific routes before catch-all, with explicit ordering
routes:
- id: orders
uri: lb://order-service
predicates:
- Path=/api/orders/**
order: 1
- id: notifications
uri: lb://notification-service
predicates:
- Path=/api/notifications/**
order: 2
- id: catch-all
uri: lb://default-service
predicates:
- Path=/api/**
order: 100
Explicit order values remove ambiguity. Specific routes have lower order values (higher priority). The catch-all has a high order value (low priority).
Dynamic Route Registration via DiscoveryClient
Spring Cloud Gateway can automatically create routes from the service registry:
spring:
cloud:
gateway:
discovery:
locator:
enabled: true
lower-case-service-id: true
With discovery locator enabled, the gateway creates a route for every service in the registry. A service named ORDER-SERVICE gets a route with predicate Path=/order-service/** (lowercased with lower-case-service-id: true).
DiscoveryClientRouteDefinitionLocator polls the DiscoveryClient periodically and creates RouteDefinition objects. These are merged with static route definitions.
The default predicate uses the path: /serviceId/**. The default filter rewrites the path to strip the service ID prefix: /order-service/api/orders becomes /api/orders.
For the SaaS backend, this is useful in development (no need to define routes manually) but dangerous in production. Every service in the registry gets a route, including internal services that should not be publicly accessible.
// BROKEN: Discovery locator enabled in production
// Internal services (config-server, eureka, admin) are exposed
// through the gateway with auto-generated routes.
// spring.cloud.gateway.discovery.locator.enabled: true
// CORRECT: Explicit route definitions in production.
// Only intended services are routed.
// Discovery locator disabled (default).
// spring.cloud.gateway.discovery.locator.enabled: false
In production, define every route explicitly. Use discovery locator only in development or testing environments where all services are safe to expose.
Custom Predicate Factory
For the SaaS backend, you need a predicate that matches based on tenant subscription tier:
@Component
public class TenantTierRoutePredicateFactory
extends AbstractRoutePredicateFactory<
TenantTierRoutePredicateFactory.Config> {
private final TenantRegistry tenantRegistry;
public TenantTierRoutePredicateFactory(
TenantRegistry tenantRegistry) {
super(Config.class);
this.tenantRegistry = tenantRegistry;
}
@Override
public Predicate<ServerWebExchange> apply(Config config) {
return exchange -> {
String tenantId = exchange.getRequest().getHeaders()
.getFirst("X-Tenant-Id");
if (tenantId == null) return false;
String tier = tenantRegistry.getTier(tenantId);
return config.getTiers().contains(tier);
};
}
@Validated
public static class Config {
@NotEmpty
private List<String> tiers;
public List<String> getTiers() { return tiers; }
public void setTiers(List<String> tiers) {
this.tiers = tiers;
}
}
}
Usage in YAML:
routes:
- id: premium-orders
uri: lb://order-service-premium
predicates:
- Path=/api/orders/**
- TenantTier=premium,enterprise
order: 1
- id: standard-orders
uri: lb://order-service
predicates:
- Path=/api/orders/**
order: 2
Premium and enterprise tenants route to dedicated instances. Standard tenants route to the shared pool. The predicate factory class name minus RoutePredicateFactory becomes the predicate name in YAML: TenantTierRoutePredicateFactory maps to TenantTier.
Note the naming convention: the factory must end with RoutePredicateFactory. Spring Cloud Gateway strips the suffix to derive the shortcut name. If you name it TenantTierPredicate, it will not be discoverable by the YAML parser.