Skip to main content
spring internals

Route Predicate Evaluation and Matching

6 min read Chapter 65 of 78

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, electronics matches ?category=electronics.
  • CookieRoutePredicateFactory: matches cookies. Cookie=session, .+ matches any request with a session cookie.
  • 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/24 routes 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:

  1. The order property (if set). Lower values have higher priority.
  2. Declaration order in YAML (top to bottom).
  3. 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.