namalzi

← All posts

Digging into Spring's Source 22: Handler Mappings and Adapters — How @RequestMapping Methods Are Found and Called

Spring · Published

When and where a method annotated with @GetMapping gets registered, which of several candidate methods wins for a request, who fills in the chosen method's arguments, and who turns its return value into a response — followed through the source of RequestMappingHandlerMapping and RequestMappingHandlerAdapter. Examples show the two different "ambiguous mapping" errors and exactly where a custom argument resolver slots in.

What this post covers

This is the twenty-second post in the "Digging into Spring's Source" series. In Part 21, DispatcherServlet did nothing but ask two questions per request: "who handles this request?" to a HandlerMapping, and "can you call this handler?" to a HandlerAdapter. This post reads the side that answers — specifically the two classes behind the @RequestMapping style we use every day. On the map in Part 1 this is unit U20 of Phase 5 (web).

There are four questions to answer.

Item Details
Source Spring Framework 7.1.0-SNAPSHOT
Commit f447f3c310 (2026-10-01, main branch)
Modules read most spring-webmvc (web.servlet.handler, web.servlet.mvc.method), spring-web (web.method)
Classes read most AbstractHandlerMethodMapping, RequestMappingHandlerMapping, RequestMappingInfo, RequestMappingHandlerAdapter, InvocableHandlerMethod, HandlerMethodArgumentResolverComposite

Every line number and behavioral detail in this post refers to that commit. Other versions will have different line numbers and may differ in detail. Part 1 explains how to check out the same commit.

This post keeps to the main thread, at a level someone new to Spring can follow. What was left out is listed by name in "If you want to go deeper" at the end.

Words to know first

A handler is the object that actually processes a request. DispatcherServlet treats a handler as a plain Object, which is why there is one part that finds it (HandlerMapping) and a separate part that knows how to call it (HandlerAdapter). That much was covered in Part 21.

A HandlerMethod bundles "this method of this bean" into one object (HandlerMethod line 71). In the @RequestMapping style the handler is not the controller class but each individual method, and this class is the container for it.

A mapping is a pairing that says "requests like this go to that handler". In the @RequestMapping style, the "requests like this" half is a RequestMappingInfo, which holds several conditions (RequestCondition): path, HTTP method, parameters, headers and so on.

An argument resolver (HandlerMethodArgumentResolver) produces, from the request, the value for one parameter of a controller method. There is one per kind — one for @RequestParam, one for @PathVariable, and so on.

A return value handler (HandlerMethodReturnValueHandler) is the other end. It takes what the controller method returned and either writes it to the response or records "render the view with this name". Despite the word "handler" in its name, it is not the same thing as the handler (the controller method) above.

An HttpMessageConverter turns a request body into an object and an object into a response body (JSON ↔ object, for example). It is the subject of Part 20; this post only goes as far as "who calls it".

1. The finding side and the calling side — two interfaces and their implementations

Both interfaces are tiny. HandlerMapping asks you to implement just getHandler(request) (line 187); HandlerAdapter has supports(handler) and handle(request, response, handler) (line 62, line 76). DispatcherServlet asks its mappings in turn and uses the first non-null answer (lines 1154–1164), then hands the handler to the first adapter that supports it (lines 1185–1195).

When you register no beans of your own, the default implementations come from DispatcherServlet.properties (lines 7–14), the file we met in Part 21.

Shape of the handler Finding side (HandlerMapping) Calling side (HandlerAdapter)
A method with @RequestMapping RequestMappingHandlerMapping RequestMappingHandlerAdapter
A bean whose name starts with / BeanNameUrlHandlerMapping one of the two below, depending on the bean's type
An object implementing HttpRequestHandler (URL-based mappings) HttpRequestHandlerAdapter (lines 42–44)
An object implementing the old Controller interface (URL-based mappings) SimpleControllerHandlerAdapter (lines 41–43)
A functional HandlerFunction RouterFunctionMapping HandlerFunctionAdapter (lines 86–88)

For the adapters in the bottom three rows, supports is a one-line instanceof. In HttpRequestHandlerAdapter and SimpleControllerHandlerAdapter, handle simply calls the interface's method; HandlerFunctionAdapter wraps the request in a ServerRequest, calls the function, and writes the ServerResponse it gets back. The first row is where the work is: every method has a different parameter list, so the arguments have to be prepared one by one before the call. The rest of this post is entirely about that first row.

RequestMappingHandlerMapping sits at the bottom of four layers of inheritance.

The inheritance structure of HandlerMapping implementations. AbstractHandlerMapping implements the HandlerMapping interface; below it AbstractHandlerMethodMapping, RequestMappingInfoHandlerMapping and RequestMappingHandlerMapping extend one another in turn. AbstractHandlerMethodMapping owns a MappingRegistry. AbstractUrlHandlerMapping and RouterFunctionMapping also sit below AbstractHandlerMapping. The left-hand branch is the four layers this post reads. The yellow box is the store where mappings pile up; the gray boxes are other implementations we only name in passing.

Layer Its job What it leaves to subclasses
AbstractHandlerMapping (line 82) The frame of getHandler(): takes a handler and wraps it in a chain with the interceptors (lines 542–591) getHandlerInternal() — actually finding the handler
AbstractHandlerMethodMapping<T> (line 68) Scans beans at start-up and registers methods; picks the best match when a request comes in. The mapping type is left open as T What is a handler (isHandler), what is a method's mapping (getMappingForMethod), does it match the request (getMatchingMapping), which matches better (getMappingComparator) (lines 499–534)
RequestMappingInfoHandlerMapping (line 68) Fixes T as RequestMappingInfo and passes "does it match / which is closer" straight to that object (lines 103–113) Reading annotations
RequestMappingHandlerMapping (line 77) Treats @Controller classes as handlers and reads @RequestMapping into a RequestMappingInfo —

The point is that "the algorithm for registering and choosing" and "reading annotations" live in different layers. The two middle layers know nothing about annotations at all.

2. At start-up: mappings pile up in afterPropertiesSet

Reading every controller's annotations on every request would be slow, so the mappings are built once, when the container starts. The place is afterPropertiesSet(). AbstractHandlerMethodMapping implements InitializingBean (line 68), and the container calls this method once the bean's dependencies have been injected. When exactly that callback runs was covered in Part 6.

	@Override
	public void afterPropertiesSet() {
		initHandlerMethods();
	}
	// ...
	protected void initHandlerMethods() {
		for (String beanName : getCandidateBeanNames()) {
			if (!beanName.startsWith(SCOPED_TARGET_NAME_PREFIX)) {
				processCandidateBean(beanName);
			}
		}
		handlerMethodsInitialized(getHandlerMethods());
	}

spring-webmvc/src/main/java/org/springframework/web/servlet/handler/AbstractHandlerMethodMapping.java, lines 206–224

getCandidateBeanNames() returns the names of every bean in the container (getBeanNamesForType(Object.class), lines 232–236). For each one, processCandidateBean() works out its type and, if that type is a handler, passes it on to detectHandlerMethods() (lines 249–263). No bean is created here; only the type is asked for, by name.

How mappings get registered. The container calls afterPropertiesSet, initHandlerMethods loops over every bean name, and isHandler checks for @Controller. Without it the bean is skipped; with it detectHandlerMethods walks the methods, getMappingForMethod builds a RequestMappingInfo, and MappingRegistry.register receives it. If the same mapping already exists an Ambiguous mapping exception is thrown; otherwise it goes into the two maps registry and pathLookup. Blue boxes are the starting point (the initialization callback) and the spot where the subclass reads annotations; yellow boxes are the store.

What counts as a handler

	@Override
	protected boolean isHandler(Class<?> beanType) {
		return AnnotatedElementUtils.hasAnnotation(beanType, Controller.class);
	}

spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/RequestMappingHandlerMapping.java, lines 176–179

A class is a handler if it carries @Controller. @RestController is itself annotated with @Controller (RestController lines 48–49), so it is caught too. AnnotatedElementUtils, which finds annotations on annotations, was covered in Part 13.

Building a RequestMappingInfo from a method

detectHandlerMethods() walks the methods of the class, calls getMappingForMethod() for each, and registers only those that return non-null (lines 270–297).

	@Override
	protected @Nullable RequestMappingInfo getMappingForMethod(Method method, Class<?> handlerType) {
		validateCglibProxyMethodVisibility(method, handlerType);

		RequestMappingInfo info = createRequestMappingInfo(method);
		if (info != null) {
			RequestMappingInfo typeInfo = createRequestMappingInfo(handlerType);
			if (typeInfo != null) {
				info = typeInfo.combine(info);
			}
			if (info.isEmptyMapping()) {
				info = info.mutate().paths("", "/").options(this.config).build();
			}
			String prefix = getPathPrefix(handlerType);
			if (prefix != null) {
				info = RequestMappingInfo.paths(prefix).options(this.config).build().combine(info);
			}
		}
		return info;
	}

spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/RequestMappingHandlerMapping.java, lines 199–218

The order is: build info from the method's annotation (no annotation means null, so nothing is registered), and if the class also has @RequestMapping, combine the two with typeInfo.combine(info). That combine is where the class's /users and the method's /{id} become /users/{id} (RequestMappingInfo lines 374–395).

The code that turns one annotation into a RequestMappingInfo copies each attribute into one condition.

	protected RequestMappingInfo createRequestMappingInfo(
			RequestMapping requestMapping, @Nullable RequestCondition<?> customCondition) {

		RequestMappingInfo.Builder builder = RequestMappingInfo
				.paths(resolveEmbeddedValuesInPatterns(requestMapping.path()))
				.methods(requestMapping.method())
				.params(requestMapping.params())
				.headers(requestMapping.headers())
				.consumes(requestMapping.consumes())
				.produces(requestMapping.produces())
				.version(requestMapping.version())
				.mappingName(requestMapping.name());

		if (customCondition != null) {
			builder.customCondition(customCondition);
		}

		return builder.options(this.config).build();
	}

spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/RequestMappingHandlerMapping.java, lines 364–382

But what we wrote was @GetMapping, and this method takes a RequestMapping. The bridge is MergedAnnotations from Part 13. @GetMapping is an annotation that is itself annotated with @RequestMapping(method = RequestMethod.GET) and wires its own attributes to those of @RequestMapping with @AliasFor (GetMapping lines 52–58). The caller uses MergedAnnotations.from(element, SearchStrategy.TYPE_HIERARCHY, …) to look for RequestMapping on the element and receives a RequestMapping object carrying the merged values (lines 265–274). So this code never needs to know about @GetMapping or @PostMapping individually.

At this commit a RequestMappingInfo holds the following conditions (lines 86–105).

Condition @RequestMapping attribute What it looks at in the request
Path (PathPatternsRequestCondition) path / value the URL path
Method (RequestMethodsRequestCondition) method GET, POST …
Parameters (ParamsRequestCondition) params presence and value of request parameters
Headers (HeadersRequestCondition) headers request headers
consumes (ConsumesRequestCondition) consumes Content-Type
produces (ProducesRequestCondition) produces Accept
Version (VersionRequestCondition) version the API version
Custom (RequestConditionHolder) — a condition you write yourself

Every condition does the same three things: combine with another condition (combine), check whether it matches this request (getMatchingCondition), and, when two both match, decide which matches better (compareTo) (RequestCondition line 45, line 59, line 67). Section 3 uses the last two. Among the fields there is one more path condition (line 91): the older, String-pattern-based PatternsRequestCondition, deprecated for removal since 7.0 (line 55). A mapping has either that or PathPatternsRequestCondition, never both; this post follows only PathPatternsRequestCondition.

Putting it into the MappingRegistry

Registration is done by MappingRegistry, an inner class of AbstractHandlerMethodMapping (line 543).

		public void register(T mapping, Object handler, Method method) {
			this.readWriteLock.writeLock().lock();
			try {
				HandlerMethod handlerMethod = createHandlerMethod(handler, method);
				validateMethodMapping(handlerMethod, mapping);

				Set<String> directPaths = getDirectPaths(mapping);
				for (String path : directPaths) {
					this.pathLookup.add(path, mapping);
				}
				// ...
				// Init validation flags
				// We do this strictly after using the original instance in the CORS lookups
				handlerMethod = handlerMethod.createWithValidateFlags();

				this.registry.put(mapping,
						new MappingRegistration<>(mapping, handlerMethod, directPaths, name, corsConfig != null));
			}
			finally {
				this.readWriteLock.writeLock().unlock();
			}
		}

spring-webmvc/src/main/java/org/springframework/web/servlet/handler/AbstractHandlerMethodMapping.java, lines 600–635

Notice that there are two maps.

pathLookup is the shortcut used in section 3. And before anything is stored, validateMethodMapping() checks whether the same mapping is already there (lines 637–646). If it is, an IllegalStateException is thrown — and since this happens inside afterPropertiesSet, the container fails to start.

The three mappings from the example below, as they sit in the two maps of MappingRegistry. registry is keyed by RequestMappingInfo and holds a MappingRegistration (including the HandlerMethod): {GET [/users/me]} → UserController#me, {GET [/users/{id}]} → UserController#one, {POST [/users], consumes [application/json]} → UserController#create. pathLookup is keyed by pattern-free paths and holds lists of mappings: /users/me points to {GET [/users/me]} and /users points to {POST [/users], …}. /users/{id} is a pattern, so it is not in pathLookup registry is the full list; pathLookup is a shortcut holding only pattern-free paths. The mappings and paths come from the real output of the example below.

Example: printing the registered mappings

This is ex/u20/MappingRegistrationExample.java. The example was run with the released Spring Framework 7.0.9 to confirm the output below (the 7.1.0-SNAPSHOT this series reads was not built and run directly). The code shown is trimmed to the essentials.

// An example written for this post (not Spring's code)
@RestController
@RequestMapping("/users")
static class UserController {
    @GetMapping("/{id}")
    String one(@PathVariable long id) { return "one"; }

    @GetMapping("/me")
    String me() { return "me"; }

    @PostMapping(consumes = "application/json")
    String create() { return "create"; }

    String notMapped() { return "no annotation, so it is not registered"; }
}

/** @Component, not @Controller — not a handler even though a method has @GetMapping. */
@Component
@RequestMapping("/plain")
static class PlainComponent {
    @GetMapping("/hello")
    String hello() { return "hello"; }
}

@Configuration
static class Config {
    @Bean UserController userController() { return new UserController(); }
    @Bean PlainComponent plainComponent() { return new PlainComponent(); }
    @Bean RequestMappingHandlerMapping handlerMapping() { return new RequestMappingHandlerMapping(); }
}

// ... in main
context.refresh();   // afterPropertiesSet() of the handlerMapping bean is called in here
RequestMappingHandlerMapping mapping = context.getBean(RequestMappingHandlerMapping.class);
// sort mapping.getHandlerMethods() and print one line each
// then start a second context whose controller has two @GetMapping("/same") methods

The examples in this post print their headings and labels in Korean. In the output blocks of this English version those headings and labels are translated; everything else (the values, the order, and the messages that come from Spring) is the actual output.

== 3 registered mappings
  {GET [/users/me]}  ->  UserController#me  (in the bean field: String "userController")
  {GET [/users/{id}]}  ->  UserController#one  (in the bean field: String "userController")
  {POST [/users], consumes [application/json]}  ->  UserController#create  (in the bean field: String "userController")
== Paths of the mappings without a pattern (getDirectPaths)
  [/users/me]
  [/users]
== Registering two mappings with identical conditions
  BeanCreationException <- IllegalStateException
  Ambiguous mapping. Cannot map 'duplicateController' method 
  ex.u20.MappingRegistrationExample$DuplicateController#a()
  to {GET [/same]}: There is already 'duplicateController' bean method
  ex.u20.MappingRegistrationExample$DuplicateController#b() mapped.

The class-level /users was put in front of each method's path, and @GetMapping was read as a GET condition. notMapped, which has no annotation, and the method of PlainComponent, which is not a @Controller, were not registered. Between the last heading and the exception Spring also logs one WARN line with the same content, which is left out here.

Easy to get wrong

3. When a request arrives: collect candidates, pick the best match

When DispatcherServlet calls getHandler(request), the frame in AbstractHandlerMapping calls getHandlerInternal(), and AbstractHandlerMethodMapping takes the read lock and hands over to lookupHandlerMethod() (lines 372–382).

	protected @Nullable HandlerMethod lookupHandlerMethod(String lookupPath, HttpServletRequest request) throws Exception {
		List<Match> matches = new ArrayList<>();
		List<T> directPathMatches = this.mappingRegistry.getMappingsByDirectPath(lookupPath);
		if (directPathMatches != null) {
			addMatchingMappings(directPathMatches, matches, request);
		}
		if (matches.isEmpty()) {
			addMatchingMappings(this.mappingRegistry.getRegistrations().keySet(), matches, request);
		}
		if (!matches.isEmpty()) {
			Match bestMatch = matches.get(0);
			if (matches.size() > 1) {
				Comparator<Match> comparator = new MatchComparator(getMappingComparator(request));
				// ...
				else {
					Match secondBestMatch = matches.get(1);
					if (comparator.compare(bestMatch, secondBestMatch) == 0) {
						Method m1 = bestMatch.getHandlerMethod().getMethod();
						Method m2 = secondBestMatch.getHandlerMethod().getMethod();
						String uri = request.getRequestURI();
						throw new IllegalStateException(
								"Ambiguous handler methods mapped for '" + uri + "': {" + m1 + ", " + m2 + "}");
					}
					// ...
			}
			request.setAttribute(BEST_MATCHING_HANDLER_ATTRIBUTE, bestMatch.getHandlerMethod());
			handleMatch(bestMatch.mapping, lookupPath, request);
			return bestMatch.getHandlerMethod();
		}
		else {
			return handleNoMatch(this.mappingRegistry.getRegistrations().keySet(), lookupPath, request);
		}
	}

spring-webmvc/src/main/java/org/springframework/web/servlet/handler/AbstractHandlerMethodMapping.java, lines 393–434

How the handler method for a request is found. First the path is looked up as-is in pathLookup and matching candidates are collected; if there are none, every mapping in registry is checked. Zero candidates leads to handleNoMatch, one is the answer, and two or more are sorted with compareTo: if the first and second compare equal an Ambiguous handler methods exception is thrown, otherwise the first wins. Then handleMatch stores the path variables as request attributes. The yellow boxes are the two maps filled in section 2.

Collecting candidates — the shortcut first

  1. Look the request path up as-is in pathLookup. One map lookup. Each mapping found is then checked against the remaining conditions (HTTP method and so on) with getMatchingMapping().
  2. Only if that produced nothing, loop over every mapping in registry and run the same check.

This is why, when both /users/me and /users/{id} exist, a request for /users/me goes to the former. Once step 1 yields a candidate, step 2 never runs, so /users/{id} does not even get to compete.

The matching itself is RequestMappingInfo.getMatchingCondition() (lines 421–466). It asks each condition in turn, and as soon as one returns null (no match) the whole thing is null. The order in the source is method, parameters, headers, consumes, produces, version, path, custom. If all match, it returns a new RequestMappingInfo narrowed to this request. A mapping with several path patterns, for instance, keeps only those that match this request (PathPatternsRequestCondition lines 190–197).

Picking the best match

With two or more candidates, they are sorted. The comparison is RequestMappingInfo.compareTo().

	public int compareTo(RequestMappingInfo other, HttpServletRequest request) {
		int result;
		// Automatic vs explicit HTTP HEAD mapping
		if (HttpMethod.HEAD.matches(request.getMethod())) {
			result = this.methodsCondition.compareTo(other.getMethodsCondition(), request);
			if (result != 0) {
				return result;
			}
		}
		result = getActivePatternsCondition().compareTo(other.getActivePatternsCondition(), request);
		if (result != 0) {
			return result;
		}
		result = this.paramsCondition.compareTo(other.getParamsCondition(), request);
		if (result != 0) {
			return result;
		}
		// ...
		// Implicit (no method) vs explicit HTTP method mappings
		result = this.methodsCondition.compareTo(other.getMethodsCondition(), request);
		if (result != 0) {
			return result;
		}
		result = this.customConditionHolder.compareTo(other.customConditionHolder, request);
		if (result != 0) {
			return result;
		}
		return 0;
	}

spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/RequestMappingInfo.java, lines 476–519

The elided middle has the same shape, so the order of comparison is path → parameters → headers → consumes → produces → version → HTTP method → custom. As soon as one step decides, the rest are not consulted. What "matches better" means for each condition:

Condition The better match Where
Path not a catch-all such as /** → fewer wildcards and variables (a variable scores 1, a wildcard 100, lower is more specific) → longer PathPattern lines 87–104, lines 138–146
Parameters more expressions ParamsRequestCondition lines 131–137
HTTP method declaring a method beats declaring none RequestMethodsRequestCondition lines 181–194

After sorting, the first and second are compared once more, and if the result is 0 there is no way to choose, so IllegalStateException("Ambiguous handler methods mapped for …") is thrown. Otherwise the first one wins. Then handleMatch() stores the variable values extracted from the path ({id} → 42) in the request attribute URI_TEMPLATE_VARIABLES_ATTRIBUTE (RequestMappingInfoHandlerMapping lines 134–154; the line that stores it is line 178). That attribute comes back in section 5, when @PathVariable is filled in.

Path matching is done by PathPattern, an object parsed once from the pattern string at start-up; matches() tells whether it matches and matchAndExtract() also pulls out the variable values (line 198, line 220). AbstractHandlerMapping holds a PathPatternParser by default (line 95). The pattern syntax and the parser's internals are outside this post.

When there is no candidate at all

Control goes to handleNoMatch(). RequestMappingInfoHandlerMapping does not simply return null here; it works out why nothing matched (lines 245–299). If some mapping matched the path but failed another condition, it throws the corresponding exception.

Path matched, but this did not Exception thrown Status code
HTTP method HttpRequestMethodNotSupportedException 405 (line 117)
Content-Type (consumes) HttpMediaTypeNotSupportedException 415
Accept (produces) HttpMediaTypeNotAcceptableException 406
Parameters (params) UnsatisfiedServletRequestParameterException 400

If not even the path matches anything, the result is null and DispatcherServlet asks the next HandlerMapping. What happens when nobody answers (noHandlerFound) is in Part 21.

Example: which one wins

This is ex/u20/BestMatchExample.java (run with Spring Framework 7.0.9). It calls RequestMappingHandlerMapping.getHandler() directly, without a DispatcherServlet, and prints what was chosen.

// An example written for this post (not Spring's code)
@RestController
static class ItemController {
    @GetMapping("/items/{id}")
    String byId(@PathVariable String id) { return "byId"; }

    @GetMapping("/items/new")
    String form() { return "form"; }

    @GetMapping(path = "/items/{id}", params = "view=full")
    String byIdFull(@PathVariable String id) { return "byIdFull"; }

    @RequestMapping("/items/{id}/any")
    String anyMethod(@PathVariable String id) { return "anyMethod"; }

    @GetMapping("/items/{id}/any")
    String getOnly(@PathVariable String id) { return "getOnly"; }

    @GetMapping("/files/**")
    String catchAll() { return "catchAll"; }

    @GetMapping("/files/*/meta")
    String wildcard() { return "wildcard"; }

    @GetMapping("/files/{name}/meta")
    String variable(@PathVariable String name) { return "variable"; }

    // only the variable name differs — different mappings at registration, equal matches for a request
    @GetMapping("/dup/{a}")
    String dupA(@PathVariable String a) { return "dupA"; }

    @GetMapping("/dup/{b}")
    String dupB(@PathVariable String b) { return "dupB"; }
}

// ... for each request
ServletRequestPathUtils.parseAndCache(request);   // in real life DispatcherServlet has already done this
HandlerExecutionChain chain = mapping.getHandler(request);
// ...
HandlerMethod handlerMethod = (HandlerMethod) chain.getHandler();

As before, the labels (pattern, variables, and the note on the /nowhere line) are translated from the Korean the example prints.

GET    /items/new            -> form  pattern=/items/new  variables={}
GET    /items/42             -> byId  pattern=/items/{id}  variables={id=42}
GET    /items/42?view=full   -> byIdFull  pattern=/items/{id}  variables={id=42}
GET    /items/42/any         -> getOnly  pattern=/items/{id}/any  variables={id=42}
DELETE /items/42/any         -> anyMethod  pattern=/items/{id}/any  variables={id=42}
GET    /files/a/meta         -> variable  pattern=/files/{name}/meta  variables={name=a}
GET    /files/a/b/c          -> catchAll  pattern=/files/**  variables={}
POST   /items/42             -> HttpRequestMethodNotSupportedException: Request method 'POST' is not supported
GET    /nowhere              -> null (a request this HandlerMapping does not know)
GET    /dup/1                -> IllegalStateException: Ambiguous handler methods mapped for '/dup/1': {java.lang.String ex.u20.BestMatchExample$ItemController.dupB(java.lang.String), java.lang.String ex.u20.BestMatchExample$ItemController.dupA(java.lang.String)}

Read from the top, the rules of this section appear one after another. /items/new is a pattern-free path and is settled by the shortcut. For /items/42?view=full, of two candidates with the same path the one with a parameter condition wins; for GET /items/42/any, the one that declares an HTTP method wins. All three patterns match /files/a/meta, but a variable (score 1) beats a wildcard (score 100), which beats /**. POST /items/42 matches by path only, so the exception that corresponds to 405 is thrown at the mapping stage; and for /dup/1 no condition separates the two candidates, so it is ambiguous.

Easy to get wrong: there are two kinds of "Ambiguous"

Ambiguous mapping Ambiguous handler methods mapped for …
When at start-up (register) when a request arrives (lookupHandlerMethod)
Condition the two methods' RequestMappingInfo are equal by equals different mappings match this request equally well
Result the container does not start only that request fails; others are fine
Example two @GetMapping("/same") /dup/{a} and /dup/{b}

The second kind is not caught at start-up. In the example above nothing went wrong until /dup/1 was requested. /dup/{a} and /dup/{b} have different pattern strings, so at registration they are different mappings.

4. The calling side: RequestMappingHandlerAdapter

With the handler in hand, DispatcherServlet picks an adapter. RequestMappingHandlerAdapter's supports lives in its parent class and is true whenever the handler is a HandlerMethod (AbstractHandlerMethodAdapter lines 68–70; supportsInternal is always true, RequestMappingHandlerAdapter lines 830–832). handle() goes through handleInternal() (lines 835–870) and reaches invokeHandlerMethod() (lines 889–944).

How a handler method is called. DispatcherServlet calls ha.handle, which passes through AbstractHandlerMethodAdapter.handle, RequestMappingHandlerAdapter.handleInternal and invokeHandlerMethod to ServletInvocableHandlerMethod.invokeAndHandle. Inside invokeForRequest, getMethodArgumentValues asks a resolver for each parameter's value and doInvoke calls the method by reflection. The return value is processed by returnValueHandlers.handleReturnValue, and getModelAndView hands the result back to DispatcherServlet. The two blue boxes are the subjects of sections 5 and 6. The green box is the line where the controller method we wrote is actually called.

Here is the part of invokeHandlerMethod() that belongs to the main thread.

		ServletInvocableHandlerMethod invocableMethod = createInvocableHandlerMethod(handlerMethod);
		if (this.argumentResolvers != null) {
			invocableMethod.setHandlerMethodArgumentResolvers(this.argumentResolvers);
		}
		if (this.returnValueHandlers != null) {
			invocableMethod.setHandlerMethodReturnValueHandlers(this.returnValueHandlers);
		}
		invocableMethod.setDataBinderFactory(binderFactory);
		invocableMethod.setParameterNameDiscoverer(this.parameterNameDiscoverer);
		invocableMethod.setMethodValidator(this.methodValidator);

		// ...
		invocableMethod.invokeAndHandle(webRequest, mavContainer);
		if (asyncManager.isConcurrentHandlingStarted()) {
			return null;
		}

		return getModelAndView(mavContainer, modelFactory, webRequest);

spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/RequestMappingHandlerAdapter.java, lines 910–943

It wraps the HandlerMethod (whose method is it) in a ServletInvocableHandlerMethod (an object that knows how to call it), plugs in the bundle of argument resolvers and the bundle of return value handlers, and calls invokeAndHandle(). The elided part creates a ModelAndViewContainer, prepares the model, and deals with async processing. A ModelAndViewContainer is a notepad carried through one invocation: it holds the model, the view name, and a flag saying "the response has already been written" (requestHandled) (ModelAndViewContainer line 51, line 69).

invokeAndHandle() does the two things its name says.

	public void invokeAndHandle(ServletWebRequest webRequest, ModelAndViewContainer mavContainer,
			@Nullable Object... providedArgs) throws Exception {

		Object returnValue = invokeForRequest(webRequest, mavContainer, providedArgs);
		setResponseStatus(webRequest);
		// ...
		mavContainer.setRequestHandled(false);
		Assert.state(this.returnValueHandlers != null, "No return value handlers");
		try {
			this.returnValueHandlers.handleReturnValue(
					returnValue, getReturnValueType(returnValue), mavContainer, webRequest);
		}
		// ...
	}

spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ServletInvocableHandlerMethod.java, lines 114–144

  1. invoke — invokeForRequest() prepares the arguments (getMethodArgumentValues) and calls the method (doInvoke). It lives in the parent, InvocableHandlerMethod (lines 171–192), and the actual call is one line of reflection, method.invoke(getBean(), args) (line 252).
  2. handle — the value that came back is passed to returnValueHandlers.handleReturnValue().

Afterwards getModelAndView() reads the notepad and decides what to give back to DispatcherServlet: null if requestHandled is true (there is no view to render), otherwise a ModelAndView with the view name and model (lines 1041–1061).

Easy to get wrong

5. Who fills in the arguments — HandlerMethodArgumentResolver

A controller method's parameters can be anything: @PathVariable long id, @RequestBody User user, HttpServletRequest request, Model model …. Rather than a long chain of ifs, this is solved with a list of objects that each answer "I'll take this parameter". The interface has two methods (line 43, line 60).

The loop over the parameters is InvocableHandlerMethod.getMethodArgumentValues().

	protected @Nullable Object[] getMethodArgumentValues(NativeWebRequest request, @Nullable ModelAndViewContainer mavContainer,
			@Nullable Object... providedArgs) throws Exception {

		MethodParameter[] parameters = getMethodParameters();
		if (ObjectUtils.isEmpty(parameters)) {
			return EMPTY_ARGS;
		}

		@Nullable Object[] args = new Object[parameters.length];
		for (int i = 0; i < parameters.length; i++) {
			MethodParameter parameter = parameters[i];
			parameter.initParameterNameDiscovery(this.parameterNameDiscoverer);
			args[i] = findProvidedArgument(parameter, providedArgs);
			if (args[i] != null) {
				continue;
			}
			if (parameter.getParameterType().equals(HandlerMethod.class) && parameter.isOptional()) {
				args[i] = null;
				continue;
			}
			if (!this.resolvers.supportsParameter(parameter)) {
				throw new IllegalStateException(formatArgumentError(parameter, "No suitable resolver"));
			}
			try {
				args[i] = this.resolvers.resolveArgument(parameter, mavContainer, request, this.dataBinderFactory);
			}
			// ...
		}
		return args;
	}

spring-web/src/main/java/org/springframework/web/method/support/InvocableHandlerMethod.java, lines 200–238

Here this.resolvers is not one resolver but a composite (HandlerMethodArgumentResolverComposite). The composite implements the same interface and, inside, walks its list for the first resolver whose supportsParameter is true.

	public @Nullable HandlerMethodArgumentResolver getArgumentResolver(MethodParameter parameter) {
		HandlerMethodArgumentResolver result = this.argumentResolverCache.get(parameter);
		if (result == null) {
			for (HandlerMethodArgumentResolver resolver : this.argumentResolvers) {
				if (resolver.supportsParameter(parameter)) {
					result = resolver;
					this.argumentResolverCache.put(parameter, result);
					break;
				}
			}
		}
		return result;
	}

spring-web/src/main/java/org/springframework/web/method/support/HandlerMethodArgumentResolverComposite.java, lines 129–141

The result is recorded in argumentResolverCache, keyed by the parameter (lines 44–45). A controller method's parameters do not change at runtime, so from the second request to the same method onward the list is not walked again.

The order in which a resolver is found for one parameter. First argumentResolverCache is checked; on a hit the remembered resolver is used. Otherwise the 15 resolvers chosen by annotation, the 10 chosen by type, the ones you registered, and the 3 catch-all resolvers are asked in that order; the first to answer true is cached and its resolveArgument is called. If nobody answers, a No suitable resolver exception is thrown. The list has four groups; they are asked from the front and the search stops at the first resolver that says yes.

The default list and its order

The list is built in the adapter's afterPropertiesSet(). If no list was supplied, the composite is created from the result of getDefaultArgumentResolvers() (lines 551–575). Like the mappings in section 2, it is prepared once in an initialization callback.

	private List<HandlerMethodArgumentResolver> getDefaultArgumentResolvers() {
		List<HandlerMethodArgumentResolver> resolvers = new ArrayList<>(30);

		// Annotation-based argument resolution
		resolvers.add(new RequestParamMethodArgumentResolver(getBeanFactory(), false));
		// ...
		// Type-based argument resolution
		resolvers.add(new ServletRequestMethodArgumentResolver());
		resolvers.add(new ServletResponseMethodArgumentResolver());
		// ...
		// Custom arguments
		if (getCustomArgumentResolvers() != null) {
			resolvers.addAll(getCustomArgumentResolvers());
		}

		// Catch-all
		resolvers.add(new PrincipalMethodArgumentResolver());
		resolvers.add(new RequestParamMethodArgumentResolver(getBeanFactory(), true));
		resolvers.add(new ServletModelAttributeMethodProcessor(true));

		return resolvers;
	}

spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/RequestMappingHandlerAdapter.java, lines 648–694

The comments in the source split the list into four groups. Counting at this commit: 15 annotation-based, 10 type-based (one more if Kotlin is present), whatever you registered, and the three at the very end.

Because the first to answer wins, order is priority. What the order means shows in those last three. RequestParamMethodArgumentResolver and ServletModelAttributeMethodProcessor are in the list twice each — near the front with the constructor argument false, and at the end with true.

It is thanks to these last two that writing just String name in a controller gets you the request parameter name. Why they come last is also clear: a resolver that says "anything without an annotation is mine" would, if placed earlier, also grab parameters such as HttpServletRequest or Model.

Resolvers you register go after the built-in ones and before those last three. So a type of your own (say CurrentUser) can be taken by your resolver before it falls through to model-attribute handling, while a parameter with @RequestParam or @PathVariable is never even offered to your resolver. Resolvers added through WebMvcConfigurer.addArgumentResolvers() end up in the same spot (WebMvcConfigurer line 162, WebMvcConfigurationSupport line 657).

Three representative implementations

@RequestParam and @PathVariable — fetch one value by name. Both extend the same parent, AbstractNamedValueMethodArgumentResolver (line 73). The parent's resolveArgument() is the frame (lines 103–150), and it goes like this.

  1. Read the name, required flag and default value from the annotation. If no name was given, the parameter's own name is used; if that is unavailable because the code was compiled without -parameters, an exception is thrown with a message telling you to add the flag (lines 176–189).
  2. Fetch the value with resolveName(). This is the part subclasses fill in. @RequestParam reads from request.getParameterValues(name) (RequestParamMethodArgumentResolver line 180); @PathVariable reads from the request attribute URI_TEMPLATE_VARIABLES_ATTRIBUTE that the mapping stored in section 3 (PathVariableMethodArgumentResolver lines 93–97). A resolver on the adapter side reads a value the mapping extracted, and all that connects the two is one request attribute.
  3. If there is no value, use the default, or throw if it is required.
  4. Convert the fetched value (a string) to the parameter's type. A WebDataBinder is created and its convertIfNecessary() is called (lines 274–298). This is where "42" becomes a long. The conversion machinery that turns a string into a target type was the subject of Part 14.

That you can write ${…} and #{…} in a default value or a name is also handled by this parent class (from line 195). #{…} is SpEL, covered in Part 15.

@RequestBody — build an object from the whole body. This is RequestResponseBodyMethodProcessor. supportsParameter only checks for @RequestBody (lines 134–136), and resolveArgument reads the body with readWithMessageConverters() and then validates (lines 152–174). The reading is delegated: the parent class walks its list of HttpMessageConverters and uses one for which canRead is true (from AbstractMessageConverterMethodArgumentResolver line 147). The inside of the converters is in Part 20.

Easy to get wrong

6. Who handles the return value — HandlerMethodReturnValueHandler

The other end has the same shape. Two methods (line 41, line 56), and a composite (HandlerMethodReturnValueHandlerComposite) that walks its list and hands the value to the first handler whose supportsReturnType is true (lines 70–91). The default list is built by getDefaultReturnValueHandlers() (lines 734–780).

A method that returns String shows what the order means. In the list, RequestResponseBodyMethodProcessor (line 759) comes before ViewNameMethodReturnValueHandler (line 763).

Two paths for a controller method that returned a String. With @ResponseBody on the method or class, RequestResponseBodyMethodProcessor sets requestHandled to true, writes the body with an HttpMessageConverter, and getModelAndView returns null. Without it, ViewNameMethodReturnValueHandler records the string as the view name, getModelAndView returns a ModelAndView, and DispatcherServlet renders the view. The same String becomes a body or a view name depending on one annotation.

	@Override
	public boolean supportsReturnType(MethodParameter returnType) {
		return (this.responseBodyControllerCache.computeIfAbsent(returnType.getContainingClass(),
				clazz -> AnnotatedElementUtils.hasAnnotation(clazz, ResponseBody.class)) ||
				returnType.hasMethodAnnotation(ResponseBody.class));
	}
	// ...
	@Override
	public void handleReturnValue(@Nullable Object returnValue, MethodParameter returnType,
			ModelAndViewContainer mavContainer, NativeWebRequest webRequest)
			throws IOException, HttpMediaTypeNotAcceptableException, HttpMessageNotWritableException {

		mavContainer.setRequestHandled(true);
		ServletServerHttpRequest inputMessage = createInputMessage(webRequest);
		ServletServerHttpResponse outputMessage = createOutputMessage(webRequest);
		// ...
		// Try even with null return value. ResponseBodyAdvice could get involved.
		writeWithMessageConverters(returnValue, returnType, inputMessage, outputMessage);
	}

spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/RequestResponseBodyMethodProcessor.java, lines 138–214

supportsReturnType is true if the method has @ResponseBody or the class does. @RestController is itself annotated with @ResponseBody (RestController line 49), so every method of a @RestController lands here. The first line of handleReturnValue is setRequestHandled(true) — writing "I have written the whole response" on the notepad from section 4 — after which writeWithMessageConverters() picks a converter that suits the Accept header and the return type and writes the body (from AbstractMessageConverterMethodProcessor line 205).

Without @ResponseBody this handler answers false, and the next in line, ViewNameMethodReturnValueHandler, takes over. It accepts a return type of void or a string (lines 71–74), and all it does is the single line mavContainer.setViewName(viewName) (lines 77–92). Nothing is written to the response. Finding a view by that name and rendering it happens after control returns to DispatcherServlet, and is the subject of Part 23.

Example: a custom resolver, and the two paths of a String

This is ex/u20/ArgumentResolverExample.java (run with Spring Framework 7.0.9). A resolver that builds a CurrentUser from the X-User header is added with setCustomArgumentResolvers, and mock requests are sent to a DispatcherServlet.

// An example written for this post (not Spring's code)
/** A value object built from the X-User request header. */
record CurrentUser(String name) {
}

static class CurrentUserArgumentResolver implements HandlerMethodArgumentResolver {
    int supportsCalls;

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        this.supportsCalls++;
        System.out.println("    [resolver] supportsParameter(" + parameter.getParameterType().getSimpleName()
                + " " + parameter.getParameterName() + ")");
        return parameter.getParameterType() == CurrentUser.class;
    }

    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
            NativeWebRequest webRequest, WebDataBinderFactory binderFactory) {
        System.out.println("    [resolver] resolveArgument(" + parameter.getParameterName() + ")");
        return new CurrentUser(webRequest.getHeader("X-User"));
    }
}

@Controller
static class GreetingController {
    @GetMapping("/greet/{times}")
    @ResponseBody
    String greet(CurrentUser user, @PathVariable int times, @RequestParam(defaultValue = "hi") String word,
            String plain) {
        return (word + " " + user.name() + " ").repeat(times).trim() + " / plain=" + plain;
    }

    @GetMapping("/page")
    String page() {
        return "greeting-page";      // no @ResponseBody, so this is a view name
    }
}

// ... in the configuration
RequestMappingHandlerAdapter adapter = new RequestMappingHandlerAdapter();
adapter.setCustomArgumentResolvers(List.of(resolver));

First, the adapter's two lists printed in order (headings translated, as above).

== 29 argument resolvers (in order)
   1 RequestParamMethodArgumentResolver
   2 RequestParamMapMethodArgumentResolver
   3 PathVariableMethodArgumentResolver
   4 PathVariableMapMethodArgumentResolver
   5 MatrixVariableMethodArgumentResolver
   6 MatrixVariableMapMethodArgumentResolver
   7 ServletModelAttributeMethodProcessor
   8 RequestResponseBodyMethodProcessor
   9 RequestPartMethodArgumentResolver
  10 RequestHeaderMethodArgumentResolver
  11 RequestHeaderMapMethodArgumentResolver
  12 ServletCookieValueMethodArgumentResolver
  13 ExpressionValueMethodArgumentResolver
  14 SessionAttributeMethodArgumentResolver
  15 RequestAttributeMethodArgumentResolver
  16 ServletRequestMethodArgumentResolver
  17 ServletResponseMethodArgumentResolver
  18 HttpEntityMethodProcessor
  19 RedirectAttributesMethodArgumentResolver
  20 ModelMethodProcessor
  21 MapMethodProcessor
  22 ErrorsMethodArgumentResolver
  23 SessionStatusMethodArgumentResolver
  24 UriComponentsBuilderMethodArgumentResolver
  25 ApiVersionMethodArgumentResolver
  26 CurrentUserArgumentResolver
  27 PrincipalMethodArgumentResolver
  28 RequestParamMethodArgumentResolver
  29 ServletModelAttributeMethodProcessor
== 15 return value handlers (in order)
   1 ModelAndViewMethodReturnValueHandler
   2 ModelMethodProcessor
   3 ViewMethodReturnValueHandler
   4 ResponseBodyEmitterReturnValueHandler
   5 StreamingResponseBodyReturnValueHandler
   6 ResponseEntityReturnValueHandler
   7 HttpHeadersReturnValueHandler
   8 CallableMethodReturnValueHandler
   9 DeferredResultMethodReturnValueHandler
  10 AsyncTaskMethodReturnValueHandler
  11 ServletModelAttributeMethodProcessor
  12 RequestResponseBodyMethodProcessor
  13 ViewNameMethodReturnValueHandler
  14 MapMethodProcessor
  15 ServletModelAttributeMethodProcessor

The list was printed on 7.0.9, but its order is the same as in the 7.1.0-SNAPSHOT source read above. My resolver is number 26 — after the 25 built-in ones and before the last three (the resolver that is added only when Kotlin is present is not in this run's list). Numbers 1 and 28 are the same class, and so are 7 and 29. Next, the same request sent twice and then /page once.

== GET /greet/2?plain=p  (1st request)
    [resolver] supportsParameter(CurrentUser user)
    [resolver] resolveArgument(user)
    [resolver] supportsParameter(String plain)
  status=200 body=[hi kim hi kim / plain=p] forwardedUrl=null
== GET /greet/2?plain=p  (2nd request)
    [resolver] resolveArgument(user)
  status=200 body=[hi kim hi kim / plain=p] forwardedUrl=null
== GET /page
  status=200 body=[] forwardedUrl=greeting-page
== Number of times the resolver's supportsParameter was called: 2

Three things show up.

Easy to get wrong

If you want to go deeper

Here are the names and locations of what this post left out. Data binding and validation for @ModelAttribute parameters (from ModelAttributeMethodProcessor line 106), and @InitBinder with getDataBinderFactory(), which builds the binder (from RequestMappingHandlerAdapter line 991). initControllerAdviceCache(), which collects @ControllerAdvice beans (from line 589), connects to the exception handling in Part 23. Async return values such as Callable and DeferredResult, and WebAsyncManager (lines 892–899). initCorsConfiguration(), which registers CORS configuration along with a mapping (from RequestMappingHandlerMapping line 474), and the preflight branch. The API version condition VersionRequestCondition, new in 7.0 (RequestMappingInfo line 103), and building mappings from @HttpExchange (RequestMappingHandlerMapping lines 391–406). HttpOptionsHandler, which answers OPTIONS requests automatically (RequestMappingInfoHandlerMapping line 525). The pattern syntax and parsing of PathPatternParser. The URL-based mappings AbstractUrlHandlerMapping (line 65) and BeanNameUrlHandlerMapping (line 48), and RouterFunctionMapping for functional endpoints (line 58). And WebMvcConfigurationSupport, where @EnableWebMvc registers the two beans of this post (line 269, line 648).

Recap

Concept One-line summary
HandlerMapping / HandlerAdapter The finding side and the calling side. Each shape of handler has its pair
HandlerMethod Bean (name or instance) + method. The handler in the @RequestMapping style
Four mapping layers Frame (AbstractHandlerMapping) → register-and-choose algorithm (AbstractHandlerMethodMapping) → ask the conditions (RequestMappingInfoHandlerMapping) → read annotations (RequestMappingHandlerMapping)
When registration happens afterPropertiesSet(). Loops over all beans and walks the methods of @Controller classes
RequestMappingInfo A bundle of path, method, parameter, header, consumes, produces, version and custom conditions. Class-level and method-level ones are merged with combine
MappingRegistry registry (everything) and pathLookup (shortcut for pattern-free paths)
lookupHandlerMethod Looks at everything only if the shortcut finds nothing. Sorts several candidates with compareTo and takes the first
Ambiguous mapping At start-up. Two methods have exactly the same mapping
Ambiguous handler methods At request time. Different mappings match equally well
handleNoMatch If only the path matched, the exception for 405, 415, 406 or 400; otherwise null
invokeHandlerMethod → invokeAndHandle Prepare arguments and call (invoke), then process the return value (handle)
HandlerMethodArgumentResolver Produces the value of one parameter. The first in the list whose supportsParameter is true takes it, and the choice is cached
Resolver order Annotation-based → type-based → the ones you registered → three catch-alls
HandlerMethodReturnValueHandler Turns a return value into a response or a view name. The @ResponseBody processor comes before the view name handler
ModelAndViewContainer The notepad of one invocation. If requestHandled is true, no view is rendered

What to look at next

In this post the controller method always returned a value without trouble. But an exception can be thrown while resolving arguments, inside the method, or — as in section 3 — at the mapping stage. The HandlerExceptionResolver that routes such an exception to an @ExceptionHandler method reuses the resolver and return value handler structure from this post as-is. The next post looks at that exception handling, at the interceptors that run before and after the handler, and at how the view whose name we merely wrote down in section 6 actually gets rendered.

More posts