Skip to content

Breaking of insertion order in Maps returned by Controllers (Grails 4 vs Grails 2) #166

@luizfiliperm

Description

@luizfiliperm

Context

We are currently migrating our application from Grails 2.5.6 to Grails 4.1.3. During this process, we noticed a behavioral change in how Maps returned by Controllers are handled before they reach our Filters/Interceptors.

Our application depends on returning ordered Maps in the API responses.

Example:

class SampleController {

    def example() {
        return [
            status : "OK",
            code   : 123,
            message: "Success"
        ]
    }
}

Using Grails 2, when this result reached a Filter, the insertion order was preserved, like exemplified below:

class ResponseFilters {

    def filters = {
        all(controller: '*', action: '*') {
            after = { model ->
                // Order preserved: status → code → message
                println "Model in filter: $model"
            }
        }
    }
}

However, using Grails 4.1.3, the same Map comes unordered, which is a problem for our context because our application is a public API.

Changing the order of the fields unexpectedly can cause considerable changes for clients, which can also cause non-predictable issues in the future. The customers expect a consistent and predictable structure, making this behavior risky for them.

Identified Behavior Change

Below are the code snippets of the framework that explain the change in behavior:

Grails 2 (order preserved)

Implementation in:

This class uses a LinkedHashMap to build the Map inside a ModelAndView, which keeps the insertion order.

Grails 3+ (order lost)

Responsibility moved to:

This version uses a HashMap, which does not keep the insertion order.

Root Cause

From Grails 3 onward, the internal Model passed between controllers and filters/interceptors is created using a HashMap, which causes the insertion order to be lost.

This is different from Grails 2, where a LinkedHashMap was used, and the order of the fields was always preserved.


Proposed Fix (Implemented and Tested)

We fixed the issue by rewriting the behavior of UrlMappingsInfoHandlerAdapter to instantiate a LinkedHashMap instead of a HashMap:

// Original behavior:
//...
else if(result instanceof Map) {
                    String viewName = controllerClass.actionUriToViewName(action)
                    def finalModel = new HashMap<String, Object>()
//...

// Updated behavior:
//...
else if(result instanceof Map) {
                    String viewName = controllerClass.actionUriToViewName(action)
                    def finalModel = new LinkedHashMap<String, Object>()
//...

This matches the behavior from Grails 2 and makes sure the insertion order is kept during the whole request lifecycle.

To apply this fix in our application (without changing the Grails source code), we recreated the UrlMappingsInfoHandlerAdapter class inside our project, using the same package and class name as the original one.
With this approach, our version is compiled and loaded before the version from the Grails web-URL-mappings plugin, which allows us to override the default behavior safely.

Compatibility Notes

We did not notice any behavior changes or regressions after applying this fix.

ModelAndView only requires the model to be a Map, and LinkedHashMap fully meets this requirement without affecting any expected behavior.

Alternative Solution Considered (Not Implemented)

Before overriding UrlMappingsInfoHandlerAdapter, we also looked at another possible solution:

Creating a custom Bean that implements org.grails.web.servlet.mvc.ActionResultTransformer.

This transformer is executed during the request handling flow, as shown in the framework code below:

if (actionResultTransformers) {
    for (transformer in actionResultTransformers) {
        result = transformer.transformActionResult(webRequest, action, result)
    }
}

Source:
https://github.com/apache/grails-core/blob/4.0.x/grails-web-url-mappings/src/main/groovy/org/grails/web/mapping/mvc/UrlMappingsInfoHandlerAdapter.groovy#L92-L95

Our idea was to use this hook to intercept the controller result and convert the returned Map into a LinkedHashMap before it reached the interceptors.

Even though this was technically possible, it had an important drawback:

The same interface (ActionResultTransformer) is also used by the ResponseRenderer, as shown here:
https://github.com/apache/grails-core/blob/4.0.x/grails-plugin-controllers/src/main/groovy/grails/artefact/controller/support/ResponseRenderer.groovy#L257-L260

Because of that, applying the transformer globally would also change the behavior of render inside controllers, potentially modifying responses and causing non-predictable issues in other parts of the application.

For this reason, we chose to proceed with the rewritten class approach instead.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions