Skip to main content
spring internals

Request Mapping and Handler Resolution

7 min read Chapter 44 of 78

RequestMappingHandlerMapping is the component that turns your @GetMapping("/orders/{id}") annotations into a lookup table the framework queries on every request. Understanding how it builds and queries that table eliminates an entire category of routing bugs.

Registration at Startup

During ApplicationContext initialization, RequestMappingHandlerMapping implements InitializingBean. Its afterPropertiesSet() method triggers a scan of every bean in the context. For each bean whose type is annotated with @Controller or @RestController, it iterates every method looking for @RequestMapping or its composed variants (@GetMapping, @PostMapping, @PutMapping, @DeleteMapping, @PatchMapping).

For each annotated method, the framework creates a RequestMappingInfo object. This object is a composite of multiple RequestCondition instances:

  • PathPatternsRequestCondition: the URL patterns from value or path
  • RequestMethodsRequestCondition: the HTTP methods (GET, POST, etc.)
  • ConsumesRequestCondition: the consumes media types (what the endpoint accepts)
  • ProducesRequestCondition: the produces media types (what the endpoint returns)
  • ParamsRequestCondition: required request parameters
  • HeadersRequestCondition: required request headers

These conditions are composed from both the class-level and method-level annotations. If @RequestMapping("/api/v1/tenants/{tenantId}") is on the class and @GetMapping("/orders") is on the method, the resulting path pattern is /api/v1/tenants/{tenantId}/orders.

The RequestMappingInfo is registered in an internal MappingRegistry, keyed by path pattern. You can see the entire registry through /actuator/mappings.

@RestController
@RequestMapping("/api/v1/tenants/{tenantId}")
public class OrderController {

    // Registered pattern: GET /api/v1/tenants/{tenantId}/orders
    @GetMapping("/orders")
    public List<OrderDto> listOrders(@PathVariable String tenantId) {
        return orderService.findByTenant(tenantId);
    }

    // Registered pattern: GET /api/v1/tenants/{tenantId}/orders/{orderId}
    @GetMapping("/orders/{orderId}")
    public OrderDto getOrder(@PathVariable String tenantId,
                              @PathVariable Long orderId) {
        return orderService.findByTenantAndId(tenantId, orderId);
    }

    // Registered pattern: POST /api/v1/tenants/{tenantId}/orders
    // consumes: application/json, produces: application/json
    @PostMapping(value = "/orders",
                 consumes = MediaType.APPLICATION_JSON_VALUE,
                 produces = MediaType.APPLICATION_JSON_VALUE)
    public ResponseEntity<OrderDto> createOrder(
            @PathVariable String tenantId,
            @RequestBody CreateOrderRequest request) {
        OrderDto created = orderService.create(tenantId, request);
        return ResponseEntity.status(HttpStatus.CREATED).body(created);
    }
}

PathPatternParser vs AntPathMatcher

Spring 6 and Spring Boot 3 default to PathPatternParser. Earlier versions used AntPathMatcher. The difference matters for performance and behavior.

AntPathMatcher operates on raw String paths. It evaluates patterns like /orders/** and /orders/{id} by splitting on / and comparing segments. It supports ? (single character), * (single path segment), and ** (multiple path segments). Pattern matching happens on every request against every registered pattern. For applications with hundreds of mappings, this becomes measurable.

PathPatternParser pre-parses patterns into PathPattern objects at registration time. At request time, it operates on a pre-parsed PathContainer representation of the request URI. This avoids repeated string splitting and allocation. Benchmarks show 6-8x faster matching for complex patterns.

PathPatternParser also introduces a constraint: ** is only allowed at the end of a pattern. You cannot write /api/**/orders. This forces cleaner URL design.

To check which parser your application uses:

@Bean
public CommandLineRunner inspectPathMatching(RequestMappingHandlerMapping mapping) {
    return args -> {
        // In Spring 6+, patternParser is non-null (PathPatternParser is the default)
        // In Spring 5 or if configured otherwise, pathMatcher is used (AntPathMatcher)
        log.info("Using PathPatternParser: {}",
                mapping.getPatternParser() != null);
    };
}

To revert to AntPathMatcher (not recommended, but sometimes necessary for legacy pattern compatibility):

spring.mvc.pathmatch.matching-strategy=ant-path-matcher

Content Negotiation Matching

consumes and produces participate in request matching, not just response formatting. A mapping with consumes = "application/json" will not match a request with Content-Type: application/xml. A mapping with produces = "application/json" will not match a request with Accept: application/xml (unless no better match exists and the mapping has no produces constraint).

This allows multiple methods on the same URL pattern to coexist:

// Matches POST with Content-Type: application/json
@PostMapping(value = "/orders", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<OrderDto> createOrderFromJson(
        @PathVariable String tenantId,
        @RequestBody CreateOrderRequest request) {
    return ResponseEntity.status(HttpStatus.CREATED)
            .body(orderService.create(tenantId, request));
}

// Matches POST with Content-Type: text/csv
@PostMapping(value = "/orders", consumes = "text/csv")
public ResponseEntity<BatchImportResult> importOrdersFromCsv(
        @PathVariable String tenantId,
        @RequestBody String csvContent) {
    return ResponseEntity.ok(orderService.importCsv(tenantId, csvContent));
}

Both methods map to POST /api/v1/tenants/{tenantId}/orders. The consumes condition differentiates them. The framework selects the correct handler based on the Content-Type header of the incoming request.

RequestCondition Composition

Each condition type implements RequestCondition<T>, which defines combine(), getMatchingCondition(), and compareTo().

  • combine() merges class-level and method-level conditions. For paths, it concatenates. For HTTP methods, it unions (method-level overrides if both are specified).
  • getMatchingCondition() checks if the current request satisfies the condition. Returns null if it does not match, or a narrowed condition if it does.
  • compareTo() determines specificity when multiple mappings match the same request. More specific patterns rank higher.

The specificity ranking for paths (most specific first):

  1. Exact match: /orders/export
  2. Path variable: /orders/{id}
  3. Wildcard: /orders/*
  4. Catch-all: /orders/**

When multiple mappings match, the framework sorts by specificity and selects the best match. If two mappings have equal specificity, the framework throws IllegalStateException at request time: “Ambiguous handler methods mapped.”

Ambiguous Mapping Detection

The framework catches some ambiguous mappings at startup. If two methods map to the exact same RequestMappingInfo (same path, same HTTP method, same conditions), the application fails to start with IllegalStateException: Ambiguous mapping.

But not all ambiguities are caught at startup. Some only manifest at request time, when the path matching algorithm cannot determine a winner.

// BROKEN: ambiguous path resolution
@RestController
@RequestMapping("/api/v1/tenants/{tenantId}")
public class OrderController {

    // Pattern: GET /api/v1/tenants/{tenantId}/orders/{orderId}
    @GetMapping("/orders/{orderId}")
    public OrderDto getOrder(@PathVariable String tenantId,
                              @PathVariable String orderId) {
        return orderService.findByTenantAndId(tenantId, orderId);
    }

    // Pattern: GET /api/v1/tenants/{tenantId}/orders/export
    @GetMapping("/orders/export")
    public ResponseEntity<byte[]> exportOrders(@PathVariable String tenantId) {
        byte[] csv = orderService.exportCsv(tenantId);
        return ResponseEntity.ok()
                .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=orders.csv")
                .body(csv);
    }
}

A request to GET /api/v1/tenants/acme/orders/export matches both patterns. The {orderId} variable happily consumes “export” as its value. With PathPatternParser, the framework resolves this correctly: the literal segment /export is more specific than the variable {orderId}. The export endpoint wins.

But with AntPathMatcher, the behavior depends on registration order. If getOrder is registered first, it can intercept the request with orderId = "export". Your export endpoint never fires. The developer sees a 500 error because orderService.findByTenantAndId("acme", "export") fails trying to parse “export” as an order identifier.

// CORRECT: eliminate ambiguity with explicit constraints
@RestController
@RequestMapping("/api/v1/tenants/{tenantId}")
public class OrderController {

    // Option 1: Constrain the path variable to numeric values
    @GetMapping("/orders/{orderId:\\d+}")
    public OrderDto getOrder(@PathVariable String tenantId,
                              @PathVariable Long orderId) {
        return orderService.findByTenantAndId(tenantId, orderId);
    }

    // /orders/export can never match {orderId:\\d+}, so no ambiguity
    @GetMapping("/orders/export")
    public ResponseEntity<byte[]> exportOrders(@PathVariable String tenantId) {
        byte[] csv = orderService.exportCsv(tenantId);
        return ResponseEntity.ok()
                .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=orders.csv")
                .body(csv);
    }
}

The regex constraint {orderId:\\d+} restricts the path variable to digit-only values. “export” does not match \\d+, so the framework skips getOrder and falls through to exportOrders. No ambiguity.

Alternative approaches:

  • Use different HTTP methods if semantics allow it
  • Use a different URL structure: /orders/actions/export separates actions from resource identifiers
  • Use PathPatternParser (the Spring 6 default) which correctly ranks literal segments above variable segments

Debugging Handler Resolution

When a request returns 404 and you are certain the controller exists, trace the resolution:

  1. Enable DEBUG logging: logging.level.org.springframework.web.servlet.mvc.method.annotation=DEBUG. The framework logs which patterns were checked and why they did not match.

  2. Check /actuator/mappings. Confirm your pattern appears. If it does not, your controller bean was not scanned. Verify component scanning base packages.

  3. Set a breakpoint in RequestMappingHandlerMapping.getHandlerInternal(). Inspect the lookupPath and walk through the matching logic. You will see exactly which conditions fail.

  4. For content negotiation issues, check the Accept and Content-Type headers. A mismatch between the client’s Content-Type and your consumes constraint is a silent 415 (Unsupported Media Type), not a 404.

The mapping registry is deterministic. Registration order follows bean discovery order, and the matching algorithm is well-defined. When the result surprises you, the problem is always in the conditions you specified, not in the framework’s resolution logic.