8 min read

Spring MVC Ambiguous Mappings Explained

Have you ever tried creating endpoints with the same paths but with different method parameters? If so, you are familiar with the problem this post is trying to explain.

  @RestController
  @RequestMapping("/user")
  public class UserSearchController {
    @GetMapping("/search")
    public List<User> searchByUsername(@RequestParam("username") String username) {...}
 
    @GetMapping("/search")
    public List<User> searchByEmail(@RequestParam("email") String email) {...}
  }

These two endpoints are meant to allow searching users by username or email. Although they have the same HTTP method (GET) and path (”/search"), the URLs required to send a request to these endpoints are not the same.

  • The url for searching with username:
    • [GET] http://domain:port/user/search?username=query
  • The URL for searching with email:
    • [GET] http://domain:port/user/search?email=query

So, would this work?

Absolutely not.

Enter IllegalStateException: Ambiguous Mapping.

What is This Error?

Running the above code will break the program and throw an exception called IllegalStateException.

Signals that a method has been invoked at an illegal or inappropriate time. In other words, the Java environment or Java application is not in an appropriate state for the requested operation.

The error logs provide a more clear explanation on why the exception was thrown, because the controller method could not be mapped.

  java.lang.IllegalStateException: Ambiguous mapping. Cannot map 'userSearchController' method
  com.confidential.controller.UserSearchController#searchByEmail(String)
  to {GET [/user/search]}: There is already 'userSearchController' bean method

Why Do We See This Error?

Here’s the short and simple answer:

During the application startup, Spring MVC scans your @RestController classes and registers each method with an endpoint, which is created by the mapping annotations like @GetMapping.

In this case, all Spring sees is one mapping, which is { GET [/user/search] }. But there are two methods pointing to that mapping, and Spring is confused about which method to call when a request is sent to this endpoint, thus throwing the exception.

The Long Answer

Let’s dive deeper into the error logs to see exactly where this exception is coming from.

  at org.springframework.web.servlet.handler.AbstractHandlerMethodMapping$MappingRegistry.validateMethodMapping(AbstractHandlerMethodMapping.java:676) ~[spring-webmvc-6.2.6.jar:6.2.6]
  at org.springframework.web.servlet.handler.AbstractHandlerMethodMapping$MappingRegistry.register(AbstractHandlerMethodMapping.java:637) ~[spring-webmvc-6.2.6.jar:6.2.6]
  at org.springframework.web.servlet.handler.AbstractHandlerMethodMapping.registerHandlerMethod(AbstractHandlerMethodMapping.java:331) ~[spring-webmvc-6.2.6.jar:6.2.6]
  at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping.registerHandlerMethod(RequestMappingHandlerMapping.java:507) ~[spring-webmvc-6.2.6.jar:6.2.6]
  at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping.registerHandlerMethod(RequestMappingHandlerMapping.java:84) ~[spring-webmvc-6.2.6.jar:6.2.6]

The logs draw the path for us to look for the root of the exception. But first let’s take a look at the RequestMappingHandlerMapping class.

RequestMappingHandlerMapping Class

This class is responsible for detecting all controller methods annotated with @RequestMapping, @GetMapping, @PostMapping etc. After detecting, the class registers a handler method for each mapping with the registerHandlerMethod(), which is inherited from the superclass AbstractHandlerMethodMapping.

  protected void registerHandlerMethod(Object handler, Method method, RequestMappingInfo mapping) {
    super.registerHandlerMethod(handler, method, mapping);
    this.updateConsumesCondition(mapping, method);
  }

AbstractHandlerMethodMapping Class

This class provides the main logic for maintaining the mapping of endpoints with handlers. The registerHandlerMethod() in this class prepares the HandlerMethod object with the mapping information and passes them to an inner class called MappingRegistry.

  protected void registerHandlerMethod(Object handler, Method method, T mapping) {
    this.mappingRegistry.register(mapping, handler, method);
  }

MappingRegistry Class

MappingRegistry class is crucially important in understanding the ambiguous mapping because it handles the logic of how Spring treats mappings and handlers, and when to throw an exception.

It is a private inner class of AbstractHandlerMethodMapping and it is responsible for storing all the matches between mappings and handler methods in a hashmap, creating new matches by registering mappings to handler methods.

Registry Map

  private final Map<T, MappingRegistration<T>> registry = new HashMap();

In MappingRegistry class, the information about mappings and handlers, and how they connect to each other is stored in a HashMap called registry. The type of this Map is deconstructed to be Map<RequestMappingInfo, MappingRegistration>.

  • RequestMappingInfo is a class that includes the details of a mapping, the path, the method etc.

  • MappingRegistration is a class that has some details along with a HandlerMethod, which is the controller method

Registry map allows us to get the handler method associated with a mapping like this: registry.get(mapping).getHandlerMethod().

To help you visualize, the registry entry for the search endpoint that we wrote at the beginning would look like this: { GET [/user/search] } : searchByUsername(String)

register()

Remember AbstractHandlerMethodMapping using the register() method to create a register of a mapping associated with a handler. This method is important for us because it calls the validateMethodMapping() which is responsible for throwing the ambiguous mapping exception.

  public void register(T mapping, Object handler, Method method) {
    //...
 
    try {
      HandlerMethod handlerMethod = AbstractHandlerMethodMapping.this.createHandlerMethod(handler, method);
      this.validateMethodMapping(handlerMethod, mapping);
      //...
    }
    //...
  }

Register method accepts a mapping, a handler, and a method and checks if the mapping is ambiguous or valid. If no conflict is found, the mapping is inserted into the registry map as the key, and the handler as the value. The validation of the mapping is the responsibility of validateMethodMapping() method.

validateMethodMapping()

  private void validateMethodMapping(HandlerMethod handlerMethod, T mapping) {
    MappingRegistration<T> registration = (MappingRegistration)this.registry.get(mapping);
    HandlerMethod existingHandlerMethod = registration != null ? registration.getHandlerMethod() : null;
    if (existingHandlerMethod != null && !existingHandlerMethod.equals(handlerMethod)) {
      String var10002 = String.valueOf(handlerMethod.getBean());
      throw new IllegalStateException("Ambiguous mapping. Cannot map ...");
    }
  }

We’ve reached the root of the exception. validateMethodMapping() takes in a handler method and a mapping as arguments to do the validation. Let’s assume that the mapping is { GET [/user/search] } and the handler method is searchByUsername(String).

  • The first line finds the entry from the registry map searching with the mapping argument.

  • If an entry is found, that means this mapping has already been created and is in the registry map. If that is the case, the method needs to check if a handler method is associated with the existing mapping.

  • If there is already a method related to the mapping, the method checks if the associated method is the handlerMethod argument.

  • If the methods are equal, there is no conflict. The handler method is already correctly mapped with the endpoint.

  • If the methods are different, that means the mapping is already associated with a method and we are creating a conflict trying to map it to different method. We have one mapping but multiple methods pointing to that.

    In our case, the one mapping is { GET [/user/search] } and the methods pointing to this mapping are:

    1. searchByUsername(String)
    2. searchByEmail(String)

    triggering the IllegalStateException.


It is now clear why the program breaks with such endpoints. But we also need to undersand the details of RequestMappingInfo to avoid ambiguous mapping exceptions. It is clear that the registry information is stored as key value pairings where the key is RequestMappingInfo and the value is a class containing the HandlerMethod.

But what does RequestMappingInfo actually involve, and why mappings only differ in some aspects like path and method, but not others like request parameters? Let’s take a look at this class to figure this out.

RequestMappingInfo Class

  private static final PathPatternsRequestCondition EMPTY_PATH_PATTERNS;
  private static final PatternsRequestCondition EMPTY_PATTERNS;
  private static final RequestMethodsRequestCondition EMPTY_REQUEST_METHODS;
  private static final ParamsRequestCondition EMPTY_PARAMS;
  private static final HeadersRequestCondition EMPTY_HEADERS;
  private static final ConsumesRequestCondition EMPTY_CONSUMES;
  private static final ProducesRequestCondition EMPTY_PRODUCES;
  private static final RequestConditionHolder EMPTY_CUSTOM;
  @Nullable
  private final String name;
  @Nullable
  private final PathPatternsRequestCondition pathPatternsCondition;
  @Nullable
  private final PatternsRequestCondition patternsCondition;
  private final RequestMethodsRequestCondition methodsCondition;
  private final ParamsRequestCondition paramsCondition;
  private final HeadersRequestCondition headersCondition;
  private final ConsumesRequestCondition consumesCondition;
  private final ProducesRequestCondition producesCondition;
  private final RequestConditionHolder customConditionHolder;
  private final int hashCode;
  private final BuilderConfiguration options;

Here are all the fields that RequestMappingInfo can have. These are the details making up our request mappings, but not all of them are taken into consideration to make a mapping unique. To figure out which of these fields differentiate a mapping, we can check the constructor:

  private RequestMappingInfo(...) {
      //...
      this.hashCode = calculateHashCode(
              this.pathPatternsCondition,
              this.patternsCondition,
              this.methodsCondition,
              this.paramsCondition,
              this.headersCondition,
              this.consumesCondition,
              this.producesCondition,
              this.customConditionHolder);
  }

When a mapping info object is created, the hashcode is automatically set with certain fields. This means that when at least one of these fields differ in two mappings, they are treated to be different mappings.

Notice the name field is not in the hashcode, this means when you define two endpoints like this:

  @GetMapping(value = "/test", name = "first endpoint")
  @GetMapping(value = "/test", name = "second endpoint")

Spring sees them as the same because name does not differentiate a mapping. The program would not run because ambiguous mapping would occur. Here is the mapping created for the first endpoint: {GET [/test]}

The second endpoint’s mapping is not created due to the exception in the creation process.

On the contrary, when you define 2 endpoints with the same name and path but with different methods, the mappings would be created like this:

EndpointMapping
@GetMapping(value =”/test”, name = “first”){GET [/test]}
@PostMapping(value = “/test”, name = “first”){POST [/test]}

They are treated as different mappings because their HTTP methods are not the same.

Why is @RequestParam is Not Enough?

In the constructor, we see that RequestMappingInfo’s hashcode also includes paramsCondition, which is the request params sent with the request. Our search endpoints had request params but the mappings did not show them.

  @GetMapping("/search")
  public List<User> searchByUsername(@RequestParam("username") String username) {}

Mapping for the above endpoint: { GET [/search] }

Why are the mappings not created with the request parameters?

Because the @RequestParam annotation applies to the handler method, not the mapping. It does not influence how Spring creates mappings.

During mapping creation, Spring uses only the mapping annotations to build a RequestMappingInfo. It doesn’t inspect handler method details like @RequestParam or @RequestBody. Thus, the difference in request params of two handler methods won’t affect the ambiguity unless params is defined on the mapping itself.

How to Define Params in Mapping Annotations

To differentiate two endpoints with request params, you can use the params attribute in mapping annotations.

  @GetMapping(value = "/search", params = "username")

Now, let’s see how using params attribute will prevent the ambiguous mapping.

  @GetMapping(value = "/search", params = "username")
  public List<User> searchByUsername(@RequestParam("username") String username) {...}
 
  @GetMapping(value "/search", params = "email")
  public List<User> searchByEmail(@RequestParam("email") String email) {...}

Now the program runs as expected, and no ambiguity exceptions are thrown. Let’s check the created mappings using the Actuator mapping endpoint.

template schema

We can see two mappings were created successfully and they contain the request's parameter information. Spring now knows that these are two different endpoints, and successfully connects them to their related handler methods.

Caution for Inconsistent Naming

If you choose different namings for the params and @RequestParam, no exception would be thrown in the mapping creation process but you’ll get an error when a request is sent to the endpoint.

Required request parameter 'username' for method parameter type String is not present

  @GetMapping(value = "/search", params = "name")
  public List<User> searchByUsername(@RequestParam("username") String username) {...}

Caution for Multiple Parameters

When you add multiple params to a mapping, the order of the params is not important for differentiating the mapping if the name of the params are the same.

These two endpoints would mean the same mapping: { GET [/user/search], params [name && email] }, creating the ambiguous mapping error.

  @GetMapping(value = "/search", params = {"username", "email"})
  @GetMapping(value = "/search", params = {"email", "username"})