-
Notifications
You must be signed in to change notification settings - Fork 94
Description
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:
AbstractGrailsControllerHelper
https://github.com/apache/grails-core/blob/2.5.x/grails-web-mvc/src/main/groovy/org/codehaus/groovy/grails/web/servlet/mvc/AbstractGrailsControllerHelper.java#L409-L410
This class uses a LinkedHashMap to build the Map inside a ModelAndView, which keeps the insertion order.
Grails 3+ (order lost)
Responsibility moved to:
UrlMappingsInfoHandlerAdapter
https://github.com/apache/grails-core/blob/4.1.x/grails-web-url-mappings/src/main/groovy/org/grails/web/mapping/mvc/UrlMappingsInfoHandlerAdapter.groovy#L104-L105
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)
}
}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.