You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@grails.apache.org by ja...@apache.org on 2026/06/29 22:33:04 UTC

(grails-core) 01/01: Infer controller namespace for link generation when unambiguous

This is an automated email from the ASF dual-hosted git repository.

jamesfredley pushed a commit to branch fix/namespace-aware-link-generation
in repository https://gitbox.apache.org/repos/asf/grails-core.git

commit ed99402b205f019b0145b26da20229575976c98c
Author: James Fredley <ja...@users.noreply.github.com>
AuthorDate: Mon Jun 29 18:32:42 2026 -0400

    Infer controller namespace for link generation when unambiguous
    
    Grails controllers can declare a namespace, but URL-generating tags and
    methods only applied the request namespace when a link targeted the current
    controller. Links to any other controller dropped the namespace unless it was
    passed explicitly, so the same controller/action attributes produced
    namespaced URLs in some places and non-namespaced URLs in others.
    
    Link generation now infers the namespace from the target controller so links
    work from controller and action alone:
    
    - a self-link uses the current request namespace
    - when exactly one controller has the name, that controller's namespace is used
    - when the same name is defined in multiple namespaces (a discouraged design),
      the non-namespaced controller is preferred, then the current namespace; any
      remaining ambiguity must be resolved with an explicit namespace
    
    The shared LinkGenerator.getDefaultNamespace resolver is applied consistently to
    g:link, g:createLink, createLink(), g:form, g:formActionSubmit, g:paginate,
    g:sortableColumn, g:include, redirect() and chain(), and is folded into the
    CachingLinkGenerator cache key (including nested url maps and resource links) so
    links generated from different request namespaces never collide. An explicit
    namespace always wins; use namespace="" in GSP markup or namespace: null in a
    method call to target a non-namespaced controller.
    
    Adds unit, taglib, controller and functional (Geb) tests covering every surface
    plus a custom application tag library, and documents the behaviour in grails-doc.
    
    Assisted-by: claude-code:claude-4.8-opus
---
 .../controller/support/ResponseRedirector.groovy   |  14 +-
 grails-doc/src/en/guide/introduction/whatsNew.adoc |   7 +
 .../urlmappings/namespacedControllers.adoc         |  15 +-
 .../src/en/guide/upgrading/upgrading80x.adoc       |   3 +
 grails-doc/src/en/ref/Controllers/chain.adoc       |   4 +-
 grails-doc/src/en/ref/Controllers/namespace.adoc   |   2 +
 grails-doc/src/en/ref/Controllers/redirect.adoc    |   6 +-
 grails-doc/src/en/ref/Tags - GSP/createLink.adoc   |   4 +-
 grails-doc/src/en/ref/Tags - GSP/form.adoc         |   1 +
 grails-doc/src/en/ref/Tags - GSP/include.adoc      |   1 +
 grails-doc/src/en/ref/Tags - GSP/link.adoc         |   2 +-
 grails-doc/src/en/ref/Tags - GSP/paginate.adoc     |   1 +
 .../src/en/ref/Tags - GSP/sortableColumn.adoc      |   1 +
 .../plugins/web/taglib/UrlMappingTagLib.groovy     |  23 +-
 .../web/taglib/NamespaceInferenceTagLibSpec.groovy | 123 ++++++++++
 .../admin/AdminNamespaceControllers.groovy         |  35 ++-
 .../root/RootNamespaceControllers.groovy           |  18 +-
 .../sales/SalesNamespaceControllers.groovy         |  11 +-
 .../grails-app/controllers/UrlMappings.groovy      |   4 +
 ...PageController.groovy => BookController.groovy} |  20 +-
 .../namespaces/admin/PageController.groovy         |  20 ++
 .../namespaces/AppLinkTagLib.groovy}               |  12 +-
 .../namespaces/grails-app/views/book/index.gsp     |  30 +++
 .../grails-app/views/page/namespaceLinks.gsp       |  58 +++++
 .../namespaces/NamespaceViewRenderingSpec.groovy   |  51 +++-
 .../web/servlet/mvc/RedirectMethodTests.groovy     |  56 +++++
 .../servlet/mvc/beta/NamespacedController.groovy   |  16 ++
 .../groovy/grails/web/mapping/LinkGenerator.java   |  35 +++
 .../grails/web/mapping/CachingLinkGenerator.java   |  44 +++-
 .../grails/web/mapping/DefaultLinkGenerator.groovy | 136 ++++++++++-
 .../LinkGeneratorNamespaceInferenceSpec.groovy     | 259 +++++++++++++++++++++
 .../admin/AdminNamespaceControllers.groovy         |  53 +++++
 .../root/RootNamespaceControllers.groovy           |  25 +-
 .../sales/SalesNamespaceControllers.groovy         |  25 +-
 34 files changed, 1048 insertions(+), 67 deletions(-)

diff --git a/grails-controllers/src/main/groovy/grails/artefact/controller/support/ResponseRedirector.groovy b/grails-controllers/src/main/groovy/grails/artefact/controller/support/ResponseRedirector.groovy
index a55bf90341..63e018eb65 100644
--- a/grails-controllers/src/main/groovy/grails/artefact/controller/support/ResponseRedirector.groovy
+++ b/grails-controllers/src/main/groovy/grails/artefact/controller/support/ResponseRedirector.groovy
@@ -151,8 +151,20 @@ trait ResponseRedirector implements WebAttributes {
     void chain(Map args) {
         String controller = (args.controller ?: GrailsNameUtils.getLogicalPropertyName(getClass().name, ControllerArtefactHandler.TYPE)).toString()
         String action = args.action?.toString()
-        String namespace = args.remove('namespace')
         String plugin = args.remove('plugin')?.toString()
+        // Honor an explicit namespace so a blank one (namespace="" or namespace: null) opts out to the
+        // non-namespaced controller; otherwise infer the namespace for the target controller so chain()
+        // stays consistent with redirect() and link generation.
+        String namespace
+        if (args.containsKey('namespace')) {
+            namespace = args.remove('namespace')?.toString()
+            if (namespace != null && namespace.trim().isEmpty()) {
+                namespace = null
+            }
+        }
+        else {
+            namespace = getGrailsLinkGenerator().getDefaultNamespace(controller, plugin)
+        }
         def id = args.id
         def params = CollectionUtils.getOrCreateChildMap(args, 'params')
         def model = CollectionUtils.getOrCreateChildMap(args, 'model')
diff --git a/grails-doc/src/en/guide/introduction/whatsNew.adoc b/grails-doc/src/en/guide/introduction/whatsNew.adoc
index 9c494096ab..127fda71b4 100644
--- a/grails-doc/src/en/guide/introduction/whatsNew.adoc
+++ b/grails-doc/src/en/guide/introduction/whatsNew.adoc
@@ -49,6 +49,13 @@ The Grails Gradle extension now defaults `preserveParameterNames` to `true`, so
 Tag library unit tests also clean up and rebuild TagLib metadata automatically between features.
 Tests that use `TagLibUnitTest` no longer need to manage `purgeTagLibMetaClass`, and specs that mock additional tag libraries continue to work across feature methods.
 
+==== Namespace-Aware Link Generation
+
+Links, form actions, pagination links, sortable column links, redirects, chains, and includes now resolve the target controller namespace automatically when `namespace` is omitted.
+In the normal case, where only one controller has the target name, `controller` and `action` are enough to generate the correct namespaced or non-namespaced URL.
+If multiple controllers share the same name, specify `namespace` to choose one explicitly.
+Use `namespace=""` in GSP markup, or `namespace: null` in Groovy code, to target a non-namespaced controller.
+
 ==== @GrailsCompileStatic on Controllers That Use Tag Libraries
 
 Controllers annotated with `@GrailsCompileStatic` can now invoke tag library methods without compile-time errors.
diff --git a/grails-doc/src/en/guide/theWebLayer/urlmappings/namespacedControllers.adoc b/grails-doc/src/en/guide/theWebLayer/urlmappings/namespacedControllers.adoc
index 20e7d9c5a6..daaba54e46 100644
--- a/grails-doc/src/en/guide/theWebLayer/urlmappings/namespacedControllers.adoc
+++ b/grails-doc/src/en/guide/theWebLayer/urlmappings/namespacedControllers.adoc
@@ -73,7 +73,7 @@ class UrlMappings {
 }
 ----
 
-Reverse URL mappings also require that the `namespace` be specified.
+Reverse URL mappings may specify the `namespace` explicitly.
 
 [source,groovy]
 ----
@@ -81,9 +81,16 @@ Reverse URL mappings also require that the `namespace` be specified.
 <g:link controller="admin" namespace="users">Click For User Admin</g:link>
 ----
 
-When resolving a URL mapping (forward or reverse) to a namespaced controller,
-a mapping will only match if the `namespace` has been provided.  If
-the application provides several controllers with the same name in different
+Most links do not need a `namespace` attribute. When a link, redirect, chain, form action, pagination link, sortable column link, or include targets a controller and omits `namespace`, Grails resolves the namespace for the target controller automatically.
+
+If the target is the controller currently handling the request, Grails uses the current request namespace. If exactly one controller has the target name, Grails uses that controller's namespace, whether the controller is namespaced or not. In the normal case, `controller` and `action` are enough to generate the correct URL.
+
+Ambiguity only occurs when the same controller name is defined by multiple controllers in different namespaces. That architecture is discouraged and should be avoided. When it does happen, Grails prefers a non-namespaced controller if one exists, otherwise a controller in the current request namespace. Any remaining ambiguity must be resolved by specifying `namespace` explicitly.
+
+An explicit `namespace` always wins. Use `namespace="reports"` in GSP markup, or `namespace: 'reports'` in Groovy code, to target a specific namespace. Use `namespace=""` in GSP markup, or `namespace: null` in Groovy code, to target a non-namespaced controller.
+
+When resolving a URL mapping to a namespaced controller, the selected namespace
+must identify one target controller. If the application provides several controllers with the same name in different
 packages, at most 1 of them may be defined without a `namespace` property.  If
 there are multiple controllers with the same name that do not define a
 `namespace` property, the framework will not know how to distinguish between
diff --git a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc
index 5eae1ae3fb..e1c90ae1a6 100644
--- a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc
+++ b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc
@@ -812,6 +812,9 @@ If your application contributed `HttpMessageConverter` beans through this class,
 Spring Framework 7 deprecated `org.springframework.lang.Nullable` and `org.springframework.lang.NonNull` in favor of the JSpecify annotations (`org.jspecify.annotations.Nullable`, `org.jspecify.annotations.NonNull`).
 The Spring annotations still work, so this is non-blocking, but new code should prefer the JSpecify equivalents.
 
+* **Namespaced link generation is namespace-aware.**
+When a link, form action, pagination link, sortable column link, redirect, chain, or include targets a controller without an explicit `namespace`, Grails now resolves the namespace automatically. In the normal case, where only one controller has the target name, `controller` and `action` generate the correct namespaced or non-namespaced URL. Ambiguity only occurs when multiple controllers share the same name. In that case, specify `namespace` to choose the target explicitly. Pass `namesp [...]
+
 ==== 21. Tag Library Test Cleanup Changes
 
 Grails 8 removes the `purgeTagLibMetaClass` test hook used by some web and TagLib unit tests.
diff --git a/grails-doc/src/en/ref/Controllers/chain.adoc b/grails-doc/src/en/ref/Controllers/chain.adoc
index d0e5896dde..1a1f254017 100644
--- a/grails-doc/src/en/ref/Controllers/chain.adoc
+++ b/grails-doc/src/en/ref/Controllers/chain.adoc
@@ -52,7 +52,7 @@ Arguments:
 
 * `uri` - The full uri to redirect to (example /book/list, book/show/2)
 * `controller` (optional) - The controller to redirect to; defaults to the current controller if not specified
-* `namespace` (optional) - the namespace of the controller to chain to
+* `namespace` (optional) - the namespace of the controller to chain to. If omitted, the namespace is inferred from the target controller; you only need to set it when more than one controller shares the name. Use `namespace: null` to target a non-namespaced controller.
 * `action` - The action to redirect to, either a string name or a reference to an action within the current controller
 * `id` (optional) - The id to use in redirection
 * `model` - The model to chain to the next action
@@ -60,6 +60,8 @@ Arguments:
 
 The chain method stores the passed model in flash scope and then performs an HTTP redirect. The model is then restored for the next request. The model that will eventually be passed to the view will be created by starting with the model that was passed to the chain method and then adding to that all of the data that is in the model returned by the next action, which may replace some of the data that was in the model originally passed to the chain method. For example:
 
+When `namespace` is omitted, `chain` uses the same namespace inference rules as `redirect`.
+
 [source,groovy]
 ----
 def one() {
diff --git a/grails-doc/src/en/ref/Controllers/namespace.adoc b/grails-doc/src/en/ref/Controllers/namespace.adoc
index f343b15d3d..4c436d73f7 100644
--- a/grails-doc/src/en/ref/Controllers/namespace.adoc
+++ b/grails-doc/src/en/ref/Controllers/namespace.adoc
@@ -30,6 +30,8 @@ Multiple controllers may be defined in the same namespace.  Multiple
 controllers may be defined with the same name as long as they are defined in
 separate packages and are not defined in the same namespace.
 
+When URL generation targets a controller and no `namespace` is specified, Grails resolves the namespace from the target controller. If exactly one controller has that name, `controller` and `action` are enough to generate the correct namespaced or non-namespaced URL. Specify `namespace` only when more than one controller shares the same name, or when you need to choose a namespace explicitly.
+
 
 === Examples
 
diff --git a/grails-doc/src/en/ref/Controllers/redirect.adoc b/grails-doc/src/en/ref/Controllers/redirect.adoc
index 1a28eae47c..c598d215c5 100644
--- a/grails-doc/src/en/ref/Controllers/redirect.adoc
+++ b/grails-doc/src/en/ref/Controllers/redirect.adoc
@@ -61,7 +61,7 @@ redirect(action: "foo", permanent: true, moved: false)
 
 === Description
 
-Redirects the current action to another action, optionally passing parameters and/or errors. When issuing a redirect from a namespaced controller, the namespace for the target controller is implied to be that of the controller initiating the redirect. To issue a redirect from a namespaced controller to a controller that is not in a namespace, the namespace must be explicitly specified with a value of `null` as shown below.  By default, the redirect code will be MOVED_TEMPORARILY (302).
+Redirects the current action to another action, optionally passing parameters and/or errors. When `namespace` is omitted, Grails resolves the target controller namespace automatically. If the target is the current controller, Grails uses the current request namespace. If exactly one controller has the target name, Grails uses that controller's namespace, whether the controller is namespaced or not. You only need to set `namespace` when more than one controller shares the same name. To is [...]
 
 [source,groovy]
 ----
@@ -81,7 +81,7 @@ class SomeController {
 
 * `action` (optional) - the name of the action to use in the link, if not specified the default action will be linked
 * `controller` (optional) - the name of the controller to use in the link, if not specified the current controller will be linked
-* `namespace` (optional) - the namespace of the controller to redirect to
+* `namespace` (optional) - the namespace of the controller to redirect to. If omitted, the namespace is inferred from the target controller; you only need to set it when more than one controller shares the name. Use `namespace: null` to target a non-namespaced controller.
 * `plugin` (optional) - the name of the plugin which provides the controller
 * `id` (optional) - the id to use in the link
 * `fragment` (optional) - The link fragment (often called anchor tag) to use
@@ -97,4 +97,4 @@ class SomeController {
 
 `redirect(Object domainClass)`
 
-A special case is if a domain class is passed into `redirect`, it will use the `LinkGenerator` to create the URL. For example, `redirect(Book.get(1))` will generate a redirect to `/book/show/1`.
\ No newline at end of file
+A special case is if a domain class is passed into `redirect`, it will use the `LinkGenerator` to create the URL. For example, `redirect(Book.get(1))` will generate a redirect to `/book/show/1`.
diff --git a/grails-doc/src/en/ref/Tags - GSP/createLink.adoc b/grails-doc/src/en/ref/Tags - GSP/createLink.adoc
index ff048b0e75..69a3dd09e1 100644
--- a/grails-doc/src/en/ref/Tags - GSP/createLink.adoc	
+++ b/grails-doc/src/en/ref/Tags - GSP/createLink.adoc	
@@ -68,7 +68,7 @@ Example usages for above controller:
 <!-- generates "/shop/book/list" -->
 <g:createLink url="[action:'list',controller:'book']" />
 
-<!-- generates a link tot he book controller in the publishing namespace -->
+<!-- generates a link to the book controller in the publishing namespace -->
 <g:createLink controller="book" namespace="publishing"/>
 
 <!-- generates "https://portal.mygreatsite.com/book" -->
@@ -104,7 +104,7 @@ Attributes
 
 * `action` (optional) - The name of the action to use in the link; if not specified the default action will be linked
 * `controller` (optional) - The name of the controller to use in the link; if not specified the current controller will be linked
-* `namespace` (optional) - The namespace of the controller to use in the link
+* `namespace` (optional) - The namespace of the controller to use in the link. If omitted, the namespace is inferred from the target controller; you only need to set it when more than one controller shares the name. Use `namespace=""` to target a non-namespaced controller.
 * `plugin` (optional) - The name of the plugin which provides the controller
 * `id` (optional) - The id to use in the link
 * `fragment` (optional) - The link fragment (often called anchor tag) to use
diff --git a/grails-doc/src/en/ref/Tags - GSP/form.adoc b/grails-doc/src/en/ref/Tags - GSP/form.adoc
index 4e4dfdbfc2..6e7929648c 100644
--- a/grails-doc/src/en/ref/Tags - GSP/form.adoc	
+++ b/grails-doc/src/en/ref/Tags - GSP/form.adoc	
@@ -76,6 +76,7 @@ Attributes
 
 * `action` (optional) - The name of the action to use in the link; if not specified the default action will be linked
 * `controller` (optional) - The name of the controller to use in the link; if not specified the current controller will be linked
+* `namespace` (optional) - The namespace of the controller to use for the form action. If omitted, the namespace is inferred from the target controller; you only need to set it when more than one controller shares the name. Use `namespace=""` to target a non-namespaced controller.
 * `id` (optional) - The id to use in the link
 * `fragment` (optional) - The link fragment (often called anchor tag) to use
 * `mapping` (optional) - The link:{guidePath}/theWebLayer.html#namedMappings[named URL mapping] to use to rewrite the link
diff --git a/grails-doc/src/en/ref/Tags - GSP/include.adoc b/grails-doc/src/en/ref/Tags - GSP/include.adoc
index 42816a0a96..4fdd5c932b 100644
--- a/grails-doc/src/en/ref/Tags - GSP/include.adoc	
+++ b/grails-doc/src/en/ref/Tags - GSP/include.adoc	
@@ -92,6 +92,7 @@ Attributes
 
 * `action` (optional) - the name of the action to use in the include
 * `controller` (optional) - the name of the controller to use in the include
+* `namespace` (optional) - the namespace of the controller to use in the include. If omitted, the namespace is inferred from the target controller; you only need to set it when more than one controller shares the name. Use `namespace=""` to target a non-namespaced controller.
 * `id` (optional) - the id to use in the include
 * `view` (optional) - The name of the view to use in the include
 * `params` (optional) - a Map of request parameters
diff --git a/grails-doc/src/en/ref/Tags - GSP/link.adoc b/grails-doc/src/en/ref/Tags - GSP/link.adoc
index 2e6e585ecd..9fa86195d1 100644
--- a/grails-doc/src/en/ref/Tags - GSP/link.adoc	
+++ b/grails-doc/src/en/ref/Tags - GSP/link.adoc	
@@ -113,7 +113,7 @@ Attributes
 * `action` (optional) - the name of the action to use in the link; if not specified the default action will be linked
 * `controller` (optional) - the name of the controller to use in the link; if not specified the current controller will be linked
 * `resource` (optional)   - the name of the resource url mapping to use in the link; if not specified controller should be used
-* `namespace` (optional) - the namespace of the controller to use in the link
+* `namespace` (optional) - the namespace of the controller to use in the link. If omitted, the namespace is inferred from the target controller; you only need to set it when more than one controller shares the name. Use `namespace=""` to target a non-namespaced controller.
 * `plugin` (optional) - the name of the plugin which provides the controller
 * `elementId` (optional) - this value will be used to populate the `id` attribute of the generated href
 * `id` (optional) - the id to use in the link
diff --git a/grails-doc/src/en/ref/Tags - GSP/paginate.adoc b/grails-doc/src/en/ref/Tags - GSP/paginate.adoc
index 067bdab04c..233163d353 100644
--- a/grails-doc/src/en/ref/Tags - GSP/paginate.adoc	
+++ b/grails-doc/src/en/ref/Tags - GSP/paginate.adoc	
@@ -77,6 +77,7 @@ Attributes
 * `total` (required) - The total number of results to paginate
 * `action` (optional) - the name of the action to use in the link; if not specified the default action will be linked
 * `controller` (optional) - the name of the controller to use in the link; if not specified the current controller will be linked
+* `namespace` (optional) - the namespace of the controller to use in generated links. If omitted, the namespace is inferred from the target controller; you only need to set it when more than one controller shares the name. Use `namespace=""` to target a non-namespaced controller.
 * `id` (optional) - The id to use in the link
 * `params` (optional) - A Map of request parameters
 * `prev` (optional) - The text to display for the previous link (defaults to "Previous" as defined by `default.paginate.prev` property in the i18n `messages.properties` file)
diff --git a/grails-doc/src/en/ref/Tags - GSP/sortableColumn.adoc b/grails-doc/src/en/ref/Tags - GSP/sortableColumn.adoc
index edce74074f..dc5584f76f 100644
--- a/grails-doc/src/en/ref/Tags - GSP/sortableColumn.adoc	
+++ b/grails-doc/src/en/ref/Tags - GSP/sortableColumn.adoc	
@@ -61,5 +61,6 @@ Attributes
 * `titleKey` (optional) - title key to use for the column, resolved against the message source
 * `params` (optional) - a Map containing request parameters
 * `action` (optional) - the name of the action to use in the link; if not specified the `list` action will be used
+* `namespace` (optional) - the namespace of the controller to use in the link. If omitted, the namespace is inferred from the target controller; you only need to set it when more than one controller shares the name. Use `namespace=""` to target a non-namespaced controller.
 * `mapping` (optional) - The link:{guidePath}/theWebLayer.html#namedMappings[named URL mapping] to use to rewrite the link
 
diff --git a/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/UrlMappingTagLib.groovy b/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/UrlMappingTagLib.groovy
index f683e4719a..5e24adaab8 100644
--- a/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/UrlMappingTagLib.groovy
+++ b/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/UrlMappingTagLib.groovy
@@ -80,8 +80,20 @@ class UrlMappingTagLib implements TagLibrary {
                 id: attrs.id as String,
                 params: attrs.params as Map)
 
-            if (attrs.namespace != null) {
-                mapping.namespace = attrs.namespace as String
+            // Honor an explicit namespace so a blank one (namespace="" or namespace: null) opts out to
+            // the non-namespaced controller; otherwise infer the namespace for the target controller so
+            // g:include stays consistent with link generation.
+            if (attrs.containsKey('namespace')) {
+                String explicitNamespace = attrs.namespace as String
+                if (explicitNamespace?.trim()) {
+                    mapping.namespace = explicitNamespace
+                }
+            }
+            else if (attrs.controller) {
+                String inferredNamespace = linkGenerator.getDefaultNamespace(attrs.controller as String, attrs.plugin as String)
+                if (inferredNamespace != null) {
+                    mapping.namespace = inferredNamespace
+                }
             }
             if (attrs.plugin != null) {
                 mapping.pluginName = attrs.plugin as String
@@ -289,6 +301,9 @@ class UrlMappingTagLib implements TagLibrary {
 
         def property = attrs.remove('property')
         def action = attrs.action ? attrs.remove('action') : (actionName ?: 'list')
+        // Only forward namespace when the tag actually supplied one, so that an omitted namespace
+        // lets the link generator infer the current controller's namespace.
+        boolean hasNamespace = attrs.containsKey('namespace')
         def namespace = attrs.remove('namespace')
 
         def defaultOrder = attrs.remove('defaultOrder')
@@ -352,7 +367,9 @@ class UrlMappingTagLib implements TagLibrary {
         }
 
         linkAttrs.action = action
-        linkAttrs.namespace = namespace
+        if (hasNamespace) {
+            linkAttrs.namespace = namespace
+        }
 
         writer << callLink((Map) linkAttrs) {
             title
diff --git a/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/NamespaceInferenceTagLibSpec.groovy b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/NamespaceInferenceTagLibSpec.groovy
new file mode 100644
index 0000000000..fcbd421542
--- /dev/null
+++ b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/NamespaceInferenceTagLibSpec.groovy
@@ -0,0 +1,123 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.grails.web.taglib
+
+import grails.artefact.Artefact
+import grails.testing.web.UrlMappingsUnitTest
+import spock.lang.Specification
+import spock.lang.Unroll
+
+class NamespaceInferenceTagLibSpec extends Specification implements UrlMappingsUnitTest<NamespaceInferenceTagLibUrlMappings> {
+
+    @Override
+    Class[] getControllersToMock() {
+        [
+                org.grails.web.taglib.nsinference.root.AuthorController,
+                org.grails.web.taglib.nsinference.root.HomeController,
+                org.grails.web.taglib.nsinference.admin.AuthorController,
+                org.grails.web.taglib.nsinference.admin.BookController,
+                org.grails.web.taglib.nsinference.admin.ReportController,
+                org.grails.web.taglib.nsinference.sales.ReportController
+        ] as Class[]
+    }
+
+    def setup() {
+        def linkGenerator = applicationContext.getBean('grailsLinkGenerator')
+        linkGenerator.grailsApplication = grailsApplication
+        linkGenerator.resetControllerNamespaceCache()
+        bindRequest('page', 'admin')
+    }
+
+    @Unroll
+    def "#tagName respects namespace inference for #description"() {
+        given:
+        bindRequest(requestController, requestNamespace)
+
+        expect:
+        applyTemplate(template) == expectedOutput
+
+        where:
+        tagName        | description                    | requestController | requestNamespace | template                                                                 | expectedOutput
+        'g:createLink' | 'same namespace controller'    | 'page'            | 'admin'          | '<g:createLink controller="book" action="list" />'                    | '/admin/book/list'
+        'g:link'       | 'same namespace controller'    | 'page'            | 'admin'          | '<g:link controller="book" action="list">Book</g:link>'               | '<a href="/admin/book/list">Book</a>'
+        'g:createLink' | 'unique namespaced controller' | 'page'            | 'sales'          | '<g:createLink controller="book" action="index" />'                   | '/admin/book/index'
+        'g:createLink' | 'root and namespace ambiguity' | 'page'            | 'sales'          | '<g:createLink controller="author" action="index" />'                 | '/author/index'
+        'g:createLink' | 'multiple namespace ambiguity' | 'page'            | 'editor'         | '<g:createLink controller="report" action="index" />'                 | '/report/index'
+        'g:link'       | 'explicit namespace'           | 'page'            | 'admin'          | '<g:link controller="report" action="index" namespace="sales">Report</g:link>' | '<a href="/sales/report/index">Report</a>'
+        'g:createLink' | 'empty namespace opt-out'      | 'page'            | 'admin'          | '<g:createLink controller="book" action="index" namespace="" />'    | '/book/index'
+        'g:createLink' | 'null namespace opt-out'       | 'page'            | 'admin'          | '<g:createLink controller="book" action="index" namespace="${null}" />' | '/book/index'
+        'g:createLink' | 'same controller request'      | 'book'            | 'admin'          | '<g:createLink controller="book" action="index" />'                   | '/admin/book/index'
+    }
+
+    def "form tags infer namespace for their action URLs"() {
+        given:
+        bindRequest('page', 'admin')
+
+        expect:
+        applyTemplate('<g:form controller="book" action="list">Body</g:form>') ==
+                '<form action="/admin/book/list" method="post" >Body</form>'
+        applyTemplate('<g:formActionSubmit controller="book" action="list" value="Save" />') ==
+                '<input type="submit" formaction="/admin/book/list" value="Save" />'
+    }
+
+    def "paginate links infer namespace for a controller in the current namespace"() {
+        given:
+        bindRequest('page', 'admin')
+
+        when:
+        String output = applyTemplate('<g:paginate next="Next" prev="Prev" max="10" total="20" controller="book" action="list" />')
+
+        then:
+        output.contains('href="/admin/book/list?offset=10&amp;max=10"')
+        !output.contains('href="/book/list?offset=10&amp;max=10"')
+    }
+
+    def "sortableColumn links infer the current controller namespace"() {
+        given:
+        bindRequest('book', 'admin')
+        webRequest.actionName = 'list'
+
+        expect:
+        applyTemplate('<g:sortableColumn property="title" title="Title" />') ==
+                '<th class="sortable" ><a href="/admin/book/list?sort=title&amp;order=asc">Title</a></th>'
+    }
+
+    // g:include performs a real servlet include/forward to the target controller, which the taglib
+    // unit harness cannot dispatch; its namespace inference is covered by the functional Geb specs in
+    // grails-test-examples/namespaces (see the #bookInclude assertion).
+
+    private void bindRequest(String controllerName, String namespace) {
+        webRequest.controllerName = controllerName
+        webRequest.controllerNamespace = namespace
+        webRequest.actionName = 'index'
+    }
+}
+
+@Artefact('UrlMappings')
+class NamespaceInferenceTagLibUrlMappings {
+    static mappings = {
+        "/admin/$controller/$action?/$id?" {
+            namespace = 'admin'
+        }
+        "/sales/$controller/$action?/$id?" {
+            namespace = 'sales'
+        }
+        "/$controller/$action?/$id?"()
+    }
+}
diff --git a/grails-test-examples/namespaces/grails-app/controllers/namespaces/admin/PageController.groovy b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/nsinference/admin/AdminNamespaceControllers.groovy
similarity index 58%
copy from grails-test-examples/namespaces/grails-app/controllers/namespaces/admin/PageController.groovy
copy to grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/nsinference/admin/AdminNamespaceControllers.groovy
index 1d13c87ac4..ab3997d07d 100644
--- a/grails-test-examples/namespaces/grails-app/controllers/namespaces/admin/PageController.groovy
+++ b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/nsinference/admin/AdminNamespaceControllers.groovy
@@ -16,14 +16,41 @@
  *  specific language governing permissions and limitations
  *  under the License.
  */
+package org.grails.web.taglib.nsinference.admin
 
-package namespaces.admin
+import grails.artefact.Artefact
 
-class PageController {
+@Artefact('Controller')
+class AuthorController {
+    static namespace = 'admin'
 
-    static namespace = "admin"
+    def index() {
+        render 'admin author index'
+    }
+
+    def list() {
+        render 'admin author list'
+    }
+}
+
+@Artefact('Controller')
+class BookController {
+    static namespace = 'admin'
+
+    def index() {
+        render 'admin book index'
+    }
+
+    def list() {
+        render 'admin book list'
+    }
+}
+
+@Artefact('Controller')
+class ReportController {
+    static namespace = 'admin'
 
     def index() {
-        render view: "/page/index", model: [pageTitle: "Admin Page"]
+        render 'admin report index'
     }
 }
diff --git a/grails-test-examples/namespaces/grails-app/controllers/namespaces/admin/PageController.groovy b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/nsinference/root/RootNamespaceControllers.groovy
similarity index 72%
copy from grails-test-examples/namespaces/grails-app/controllers/namespaces/admin/PageController.groovy
copy to grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/nsinference/root/RootNamespaceControllers.groovy
index 1d13c87ac4..3bad4d09a8 100644
--- a/grails-test-examples/namespaces/grails-app/controllers/namespaces/admin/PageController.groovy
+++ b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/nsinference/root/RootNamespaceControllers.groovy
@@ -16,14 +16,24 @@
  *  specific language governing permissions and limitations
  *  under the License.
  */
+package org.grails.web.taglib.nsinference.root
 
-package namespaces.admin
+import grails.artefact.Artefact
 
-class PageController {
+@Artefact('Controller')
+class AuthorController {
+    def index() {
+        render 'root author index'
+    }
 
-    static namespace = "admin"
+    def list() {
+        render 'root author list'
+    }
+}
 
+@Artefact('Controller')
+class HomeController {
     def index() {
-        render view: "/page/index", model: [pageTitle: "Admin Page"]
+        render 'root home index'
     }
 }
diff --git a/grails-test-examples/namespaces/grails-app/controllers/namespaces/admin/PageController.groovy b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/nsinference/sales/SalesNamespaceControllers.groovy
similarity index 81%
copy from grails-test-examples/namespaces/grails-app/controllers/namespaces/admin/PageController.groovy
copy to grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/nsinference/sales/SalesNamespaceControllers.groovy
index 1d13c87ac4..bb6a52128b 100644
--- a/grails-test-examples/namespaces/grails-app/controllers/namespaces/admin/PageController.groovy
+++ b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/nsinference/sales/SalesNamespaceControllers.groovy
@@ -16,14 +16,15 @@
  *  specific language governing permissions and limitations
  *  under the License.
  */
+package org.grails.web.taglib.nsinference.sales
 
-package namespaces.admin
+import grails.artefact.Artefact
 
-class PageController {
-
-    static namespace = "admin"
+@Artefact('Controller')
+class ReportController {
+    static namespace = 'sales'
 
     def index() {
-        render view: "/page/index", model: [pageTitle: "Admin Page"]
+        render 'sales report index'
     }
 }
diff --git a/grails-test-examples/namespaces/grails-app/controllers/UrlMappings.groovy b/grails-test-examples/namespaces/grails-app/controllers/UrlMappings.groovy
index 15da89a7fb..fa1ec4de73 100644
--- a/grails-test-examples/namespaces/grails-app/controllers/UrlMappings.groovy
+++ b/grails-test-examples/namespaces/grails-app/controllers/UrlMappings.groovy
@@ -30,6 +30,10 @@ class UrlMappings {
             namespace = "admin"
         }
 
+        "/frontend/$controller/$action?/$id?(.$format)?"{
+            namespace = "frontend"
+        }
+
         "/"(view:"/index")
         "500"(view:'/error')
     }
diff --git a/grails-test-examples/namespaces/grails-app/controllers/namespaces/admin/PageController.groovy b/grails-test-examples/namespaces/grails-app/controllers/namespaces/admin/BookController.groovy
similarity index 70%
copy from grails-test-examples/namespaces/grails-app/controllers/namespaces/admin/PageController.groovy
copy to grails-test-examples/namespaces/grails-app/controllers/namespaces/admin/BookController.groovy
index 1d13c87ac4..a5768aeeda 100644
--- a/grails-test-examples/namespaces/grails-app/controllers/namespaces/admin/PageController.groovy
+++ b/grails-test-examples/namespaces/grails-app/controllers/namespaces/admin/BookController.groovy
@@ -19,11 +19,27 @@
 
 package namespaces.admin
 
-class PageController {
+class BookController {
 
     static namespace = "admin"
 
     def index() {
-        render view: "/page/index", model: [pageTitle: "Admin Page"]
+        render view: "/book/index", model: [pageTitle: "Admin Book"]
+    }
+
+    def list() {
+        render view: "/book/index", model: [pageTitle: "Admin Book List"]
+    }
+
+    def save() {
+        render "Admin Book Save"
+    }
+
+    def alternateSave() {
+        render "Admin Book Alternate Save"
+    }
+
+    def included() {
+        render "Admin Book Include"
     }
 }
diff --git a/grails-test-examples/namespaces/grails-app/controllers/namespaces/admin/PageController.groovy b/grails-test-examples/namespaces/grails-app/controllers/namespaces/admin/PageController.groovy
index 1d13c87ac4..c455fbccdd 100644
--- a/grails-test-examples/namespaces/grails-app/controllers/namespaces/admin/PageController.groovy
+++ b/grails-test-examples/namespaces/grails-app/controllers/namespaces/admin/PageController.groovy
@@ -26,4 +26,24 @@ class PageController {
     def index() {
         render view: "/page/index", model: [pageTitle: "Admin Page"]
     }
+
+    def links() {
+        render view: "/page/namespaceLinks", model: [pageTitle: "Admin Namespace Links"]
+    }
+
+    def list() {
+        render view: "/page/namespaceLinks", model: [pageTitle: "Admin Namespace Links"]
+    }
+
+    def redirectToBook() {
+        redirect controller: "book", action: "index"
+    }
+
+    def chainToBook() {
+        chain controller: "book", action: "index"
+    }
+
+    def redirectToRootReport() {
+        redirect controller: "report", action: "index", namespace: null
+    }
 }
diff --git a/grails-test-examples/namespaces/grails-app/controllers/namespaces/admin/PageController.groovy b/grails-test-examples/namespaces/grails-app/taglib/namespaces/AppLinkTagLib.groovy
similarity index 72%
copy from grails-test-examples/namespaces/grails-app/controllers/namespaces/admin/PageController.groovy
copy to grails-test-examples/namespaces/grails-app/taglib/namespaces/AppLinkTagLib.groovy
index 1d13c87ac4..ef53f0f648 100644
--- a/grails-test-examples/namespaces/grails-app/controllers/namespaces/admin/PageController.groovy
+++ b/grails-test-examples/namespaces/grails-app/taglib/namespaces/AppLinkTagLib.groovy
@@ -17,13 +17,15 @@
  *  under the License.
  */
 
-package namespaces.admin
+package namespaces
 
-class PageController {
+class AppLinkTagLib {
 
-    static namespace = "admin"
+    static namespace = "app"
+    static defaultEncodeAs = [taglib: "none"]
 
-    def index() {
-        render view: "/page/index", model: [pageTitle: "Admin Page"]
+    def bookLink = { attrs ->
+        String elementId = attrs.id ?: "appBookLink"
+        out << "<a id=\"${elementId}\" href=\"${g.createLink(controller: 'book', action: 'index')}\">Custom Book Link</a>"
     }
 }
diff --git a/grails-test-examples/namespaces/grails-app/views/book/index.gsp b/grails-test-examples/namespaces/grails-app/views/book/index.gsp
new file mode 100644
index 0000000000..55fc41992c
--- /dev/null
+++ b/grails-test-examples/namespaces/grails-app/views/book/index.gsp
@@ -0,0 +1,30 @@
+<%--
+  ~  Licensed to the Apache Software Foundation (ASF) under one
+  ~  or more contributor license agreements.  See the NOTICE file
+  ~  distributed with this work for additional information
+  ~  regarding copyright ownership.  The ASF licenses this file
+  ~  to you under the Apache License, Version 2.0 (the
+  ~  "License"); you may not use this file except in compliance
+  ~  with the License.  You may obtain a copy of the License at
+  ~
+  ~    https://www.apache.org/licenses/LICENSE-2.0
+  ~
+  ~  Unless required by applicable law or agreed to in writing,
+  ~  software distributed under the License is distributed on an
+  ~  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+  ~  KIND, either express or implied.  See the License for the
+  ~  specific language governing permissions and limitations
+  ~  under the License.
+  --%>
+<!doctype html>
+<html>
+<head>
+    <meta name="layout" content="main"/>
+    <title>${pageTitle}</title>
+</head>
+<body>
+    <div id="content" role="main">
+        <h1 id="bookTitle">${pageTitle}</h1>
+    </div>
+</body>
+</html>
diff --git a/grails-test-examples/namespaces/grails-app/views/page/namespaceLinks.gsp b/grails-test-examples/namespaces/grails-app/views/page/namespaceLinks.gsp
new file mode 100644
index 0000000000..2d2b0102c4
--- /dev/null
+++ b/grails-test-examples/namespaces/grails-app/views/page/namespaceLinks.gsp
@@ -0,0 +1,58 @@
+<%--
+  ~  Licensed to the Apache Software Foundation (ASF) under one
+  ~  or more contributor license agreements.  See the NOTICE file
+  ~  distributed with this work for additional information
+  ~  regarding copyright ownership.  The ASF licenses this file
+  ~  to you under the Apache License, Version 2.0 (the
+  ~  "License"); you may not use this file except in compliance
+  ~  with the License.  You may obtain a copy of the License at
+  ~
+  ~    https://www.apache.org/licenses/LICENSE-2.0
+  ~
+  ~  Unless required by applicable law or agreed to in writing,
+  ~  software distributed under the License is distributed on an
+  ~  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+  ~  KIND, either express or implied.  See the License for the
+  ~  specific language governing permissions and limitations
+  ~  under the License.
+  --%>
+<!doctype html>
+<html>
+<head>
+    <meta name="layout" content="main"/>
+    <title>${pageTitle}</title>
+</head>
+<body>
+    <div id="content" role="main">
+        <h1>${pageTitle}</h1>
+
+        <g:link elementId="bookLink" controller="book" action="index">Book Link</g:link>
+        <a id="bookCreateLink" href="${createLink(controller: 'book', action: 'list')}">Book Create Link</a>
+        <g:link elementId="reportLink" controller="report" action="index">Report Link</g:link>
+        <g:link elementId="currentPageLink" controller="page" action="index">Current Page Link</g:link>
+        <g:link elementId="frontendPageLink" controller="page" action="index" namespace="frontend">Frontend Page Link</g:link>
+        <g:link elementId="rootReportLink" controller="report" action="index" namespace="${null}">Root Report Link</g:link>
+        <app:bookLink id="customBookLink"/>
+
+        <g:form name="bookForm" controller="book" action="save">
+            <g:formActionSubmit id="bookFormActionSubmit" controller="book" action="alternateSave" value="Submit To Book"/>
+        </g:form>
+
+        <div id="bookPagination">
+            <g:paginate controller="book" action="list" total="30" max="10"/>
+        </div>
+
+        <table>
+            <thead>
+                <tr>
+                    <g:sortableColumn id="pageSortable" property="title" action="list" title="Title"/>
+                </tr>
+            </thead>
+        </table>
+
+        <div id="bookInclude">
+            <g:include controller="book" action="included"/>
+        </div>
+    </div>
+</body>
+</html>
diff --git a/grails-test-examples/namespaces/src/integration-test/groovy/namespaces/NamespaceViewRenderingSpec.groovy b/grails-test-examples/namespaces/src/integration-test/groovy/namespaces/NamespaceViewRenderingSpec.groovy
index f3f2e77b35..231ec4f27e 100644
--- a/grails-test-examples/namespaces/src/integration-test/groovy/namespaces/NamespaceViewRenderingSpec.groovy
+++ b/grails-test-examples/namespaces/src/integration-test/groovy/namespaces/NamespaceViewRenderingSpec.groovy
@@ -34,12 +34,59 @@ class NamespaceViewRenderingSpec extends ContainerGebSpec {
         go('/myAppTest/test/implicitView')
 
         then:"The view is rendered"
-        	$('body').text() == "Implicit View Rendered!"
+        $('body').text() == "Implicit View Rendered!"
 
         when:"When an explicit view with a namespace is used"
         go('/myAppTest/test/explicitView')
 
         then:"The view is rendered"
-        	$('body').text() == "Foo View Rendered"
+        $('body').text() == "Foo View Rendered"
+    }
+
+    void "Test namespace is inferred for links rendered from an admin namespaced page"() {
+        when:"An admin namespaced page renders URL-generating tags"
+        go('/myAppTest/admin/page/links')
+
+        then:"Links without an explicit namespace target admin controllers"
+        $('#bookLink').@href.contains('/myAppTest/admin/book/index')
+        $('#bookCreateLink').@href.contains('/myAppTest/admin/book/list')
+        $('#reportLink').@href.contains('/myAppTest/admin/report/index')
+        $('#currentPageLink').@href.contains('/myAppTest/admin/page/index')
+        $('#customBookLink').@href.contains('/myAppTest/admin/book/index')
+
+        and:"Form-related tags infer the admin namespace"
+        $('#bookForm').@action.contains('/myAppTest/admin/book/save')
+        $('#bookFormActionSubmit').@formaction.contains('/myAppTest/admin/book/alternateSave')
+
+        and:"Pagination, sortable column, and include infer the admin namespace"
+        $('#bookPagination a.step').first().@href.contains('/myAppTest/admin/book/list')
+        $('#pageSortable a').@href.contains('/myAppTest/admin/page/list')
+        $('#bookInclude').text().contains('Admin Book Include')
+
+        and:"Explicit namespace and explicit null namespace override inference"
+        $('#frontendPageLink').@href.contains('/myAppTest/frontend/page/index')
+        $('#rootReportLink').@href.contains('/myAppTest/report/index')
+        !$('#rootReportLink').@href.contains('/myAppTest/admin/report')
+    }
+
+    void "Test namespace is inferred for redirects and chains from an admin namespaced controller"() {
+        when:"An admin action redirects to book without an explicit namespace"
+        go('/myAppTest/admin/page/redirectToBook')
+
+        then:"The redirect lands on the admin book controller"
+        waitFor { currentUrl.contains('/myAppTest/admin/book/index') }
+
+        when:"An admin action chains to book without an explicit namespace"
+        go('/myAppTest/admin/page/chainToBook')
+
+        then:"The chain lands on the admin book controller"
+        waitFor { currentUrl.contains('/myAppTest/admin/book/index') }
+
+        when:"An admin action opts out with an explicit null namespace"
+        go('/myAppTest/admin/page/redirectToRootReport')
+
+        then:"The redirect lands on the root report controller"
+        waitFor { currentUrl.contains('/myAppTest/report/index') }
+        !currentUrl.contains('/myAppTest/admin/report')
     }
 }
diff --git a/grails-test-suite-uber/src/test/groovy/org/grails/web/servlet/mvc/RedirectMethodTests.groovy b/grails-test-suite-uber/src/test/groovy/org/grails/web/servlet/mvc/RedirectMethodTests.groovy
index 26a71ef0ea..a3a8b9dc0c 100644
--- a/grails-test-suite-uber/src/test/groovy/org/grails/web/servlet/mvc/RedirectMethodTests.groovy
+++ b/grails-test-suite-uber/src/test/groovy/org/grails/web/servlet/mvc/RedirectMethodTests.groovy
@@ -19,6 +19,7 @@
 package org.grails.web.servlet.mvc
 
 import grails.testing.web.UrlMappingsUnitTest
+import org.grails.core.artefact.ControllerArtefactHandler
 import org.grails.web.servlet.mvc.alpha.NamespacedController
 import grails.web.mapping.mvc.exceptions.CannotRedirectException
 import org.grails.web.util.GrailsApplicationAttributes
@@ -95,6 +96,61 @@ class RedirectMethodTests extends Specification implements UrlMappingsUnitTest<U
         '/anotherSecondaryNamespace/demo' == response.redirectedUrl
     }
 
+    // chain() issues a real HTTP redirect via response.sendRedirect(), which commits the mock
+    // response; a committed MockHttpServletResponse cannot be reused, so each chain case lives in its
+    // own feature method to get a fresh response (unlike redirect(), which only sets the Location
+    // header and can be exercised repeatedly within a single method).
+
+    void "test chain to the same namespaced controller infers the current namespace"() {
+        when:
+        def secondary = new org.grails.web.servlet.mvc.beta.NamespacedController()
+        webRequest.controllerName = 'namespaced'
+        webRequest.controllerNamespace = 'secondary'
+        secondary.chainToSelfWithImplicitNamespace()
+
+        then:
+        '/secondaryNamespace/demo' == response.redirectedUrl
+    }
+
+    void "test chain to a different controller in the same namespace infers the namespace"() {
+        given: 'the target namespaced controller is registered so its namespace can be inferred'
+        grailsApplication.addArtefact(ControllerArtefactHandler.TYPE, org.grails.web.servlet.mvc.beta.AnotherNamespacedController)
+        def linkGenerator = applicationContext.getBean('grailsLinkGenerator')
+        linkGenerator.grailsApplication = grailsApplication
+        linkGenerator.resetControllerNamespaceCache()
+
+        when:
+        def secondary = new org.grails.web.servlet.mvc.beta.NamespacedController()
+        webRequest.controllerName = 'namespaced'
+        webRequest.controllerNamespace = 'secondary'
+        secondary.chainToAnotherSecondaryImplicitNamespace()
+
+        then:
+        '/anotherSecondaryNamespace/demo' == response.redirectedUrl
+    }
+
+    void "test chain with an explicit namespace is honored"() {
+        when:
+        def secondary = new org.grails.web.servlet.mvc.beta.NamespacedController()
+        webRequest.controllerName = 'namespaced'
+        webRequest.controllerNamespace = 'secondary'
+        secondary.chainToAnotherSecondaryExplicitNamespace()
+
+        then:
+        '/anotherSecondaryNamespace/demo' == response.redirectedUrl
+    }
+
+    void "test chain with an explicit null namespace opts out to the root controller"() {
+        when:
+        def secondary = new org.grails.web.servlet.mvc.beta.NamespacedController()
+        webRequest.controllerName = 'namespaced'
+        webRequest.controllerNamespace = 'secondary'
+        secondary.chainToPrimaryExplicitNullNamespace()
+
+        then:
+        '/anotherNoNamespace/demo' == response.redirectedUrl
+    }
+
     void testRedirectToDefaultActionOfAnotherController() {
 
         when:
diff --git a/grails-test-suite-uber/src/test/groovy/org/grails/web/servlet/mvc/beta/NamespacedController.groovy b/grails-test-suite-uber/src/test/groovy/org/grails/web/servlet/mvc/beta/NamespacedController.groovy
index a78a96501c..32a111287e 100644
--- a/grails-test-suite-uber/src/test/groovy/org/grails/web/servlet/mvc/beta/NamespacedController.groovy
+++ b/grails-test-suite-uber/src/test/groovy/org/grails/web/servlet/mvc/beta/NamespacedController.groovy
@@ -54,6 +54,22 @@ class NamespacedController {
         redirect controller: 'anotherNamespaced', namespace: 'secondary', action: 'demo'
     }
 
+    def chainToSelfWithImplicitNamespace() {
+        chain action: 'demo'
+    }
+
+    def chainToAnotherSecondaryImplicitNamespace() {
+        chain controller: 'anotherNamespaced', action: 'demo'
+    }
+
+    def chainToAnotherSecondaryExplicitNamespace() {
+        chain controller: 'anotherNamespaced', action: 'demo', namespace: 'secondary'
+    }
+
+    def chainToPrimaryExplicitNullNamespace() {
+        chain controller: 'anotherNamespaced', action: 'demo', namespace: null
+    }
+
     def demo() {
         render 'Rendered by the secondary Namespaced Controller'
     }
diff --git a/grails-web-url-mappings/src/main/groovy/grails/web/mapping/LinkGenerator.java b/grails-web-url-mappings/src/main/groovy/grails/web/mapping/LinkGenerator.java
index 607f69f134..39d340b2d7 100644
--- a/grails-web-url-mappings/src/main/groovy/grails/web/mapping/LinkGenerator.java
+++ b/grails-web-url-mappings/src/main/groovy/grails/web/mapping/LinkGenerator.java
@@ -162,6 +162,41 @@ public interface LinkGenerator {
      */
     String link(@SuppressWarnings("rawtypes") Map params, String encoding);
 
+    /**
+     * Resolves the effective namespace to use for a link/redirect that targets the given controller
+     * when the caller did not supply an explicit {@code namespace} attribute.
+     *
+     * <p>Implementations infer the namespace from the registered controllers so that links generated
+     * from {@code controller} and {@code action} alone "just work" without a namespace attribute. The
+     * contract is:</p>
+     *
+     * <ul>
+     *    <li>If the target controller is the controller currently handling the request, the current
+     *        request namespace is returned.</li>
+     *    <li>Otherwise, if exactly one controller has the given name, that controller's namespace is
+     *        returned (which may be {@code null} when that single controller is non-namespaced).</li>
+     *    <li>Otherwise the name is defined by more than one controller in different namespaces - a
+     *        discouraged design - so a sensible default is chosen: the non-namespaced controller if one
+     *        exists, then a controller in the current request namespace; any remaining ambiguity yields
+     *        {@code null} and must be disambiguated by specifying the namespace explicitly.</li>
+     * </ul>
+     *
+     * <p>Callers must only use this when no explicit {@code namespace} attribute was supplied; an
+     * explicit {@code namespace} (including an explicit blank one) must always take precedence so that
+     * {@code namespace=""} (or {@code namespace: null}) can be used to target a non-namespaced
+     * controller.</p>
+     *
+     * <p>When a {@code pluginName} is supplied the target lives in a plugin, so cross-controller
+     * inference from the application's own controllers is not performed.</p>
+     *
+     * @param controller The logical name of the target controller
+     * @param pluginName The name of the plugin providing the target controller, or {@code null}
+     * @return The resolved namespace, or {@code null} for the default namespace
+     */
+    default String getDefaultNamespace(String controller, String pluginName) {
+        return null;
+    }
+
     /**
      * Obtains the context path from which this link generator is operating.
      *
diff --git a/grails-web-url-mappings/src/main/groovy/org/grails/web/mapping/CachingLinkGenerator.java b/grails-web-url-mappings/src/main/groovy/org/grails/web/mapping/CachingLinkGenerator.java
index 5f842a1fc0..129ef3db2e 100644
--- a/grails-web-url-mappings/src/main/groovy/org/grails/web/mapping/CachingLinkGenerator.java
+++ b/grails-web-url-mappings/src/main/groovy/org/grails/web/mapping/CachingLinkGenerator.java
@@ -52,6 +52,10 @@ public class CachingLinkGenerator extends DefaultLinkGenerator {
     private static final String COMMA_SEPARATOR = ", ";
     private static final String KEY_VALUE_SEPARATOR = ":";
     private static final String THIS_MAP = "(this Map)";
+    // Synthetic cache-key entries that capture the request context namespace inference depends on for
+    // resource links, whose target controller this class does not resolve.
+    private static final String REQUEST_CONTROLLER_KEY = "__grailsRequestController";
+    private static final String REQUEST_NAMESPACE_KEY = "__grailsRequestNamespace";
 
     private Cache<String, Object> linkCache;
 
@@ -109,10 +113,41 @@ public class CachingLinkGenerator extends DefaultLinkGenerator {
                 map.put(UrlMapping.CONTROLLER, requestControllerName);
                 map.put(UrlMapping.ACTION, action);
             }
-            if (map.get(UrlMapping.NAMESPACE) == null && map.get(UrlMapping.CONTROLLER) == requestControllerName) {
-                String namespace = getRequestStateLookupStrategy().getControllerNamespace();
-                if (GrailsStringUtils.isNotEmpty(namespace)) {
-                    map.put(UrlMapping.NAMESPACE, namespace);
+            // Fold the effective namespace into the cache key whenever none was supplied explicitly,
+            // so an inferred namespace (not just the current-controller case) is part of the key and
+            // links generated from different request namespaces never collide. The target controller
+            // may be supplied at the top level or nested in a url attribute map (for example
+            // <g:link url="[controller:'book']"/>); the plugin, like link(), is read from the top
+            // level. For these shapes we fold the precisely resolved namespace, which keeps the key
+            // tight and the cache hit rate high.
+            if (!map.containsKey(UrlMapping.NAMESPACE)) {
+                Object controllerValue = map.get(UrlMapping.CONTROLLER);
+                Object pluginValue = map.get(UrlMapping.PLUGIN);
+                Object urlValue = map.get(ATTRIBUTE_URL);
+                if (controllerValue == null && urlValue instanceof Map) {
+                    controllerValue = ((Map) urlValue).get(UrlMapping.CONTROLLER);
+                }
+                if (controllerValue != null) {
+                    String namespace = getDefaultNamespace(controllerValue.toString(),
+                            pluginValue == null ? null : pluginValue.toString());
+                    if (GrailsStringUtils.isNotEmpty(namespace)) {
+                        map.put(UrlMapping.NAMESPACE, namespace);
+                    }
+                }
+                else if (map.get(RESOURCE_PREFIX) != null && hasNamespacedControllers()) {
+                    // A resource link derives its controller through more involved resolution that we
+                    // do not duplicate here. When namespaced controllers exist a namespace could be
+                    // inferred, so fold the request context the inference depends on into the key so
+                    // resource links from different request namespaces never collide on a cached URL.
+                    // (A non-null request namespace always implies a namespaced controller is
+                    // registered, so this condition also covers the same-controller resource case.)
+                    String requestNamespace = getRequestStateLookupStrategy().getControllerNamespace();
+                    if (GrailsStringUtils.isNotEmpty(requestControllerName)) {
+                        map.put(REQUEST_CONTROLLER_KEY, requestControllerName);
+                    }
+                    if (GrailsStringUtils.isNotEmpty(requestNamespace)) {
+                        map.put(REQUEST_NAMESPACE_KEY, requestNamespace);
+                    }
                 }
             }
             boolean first = true;
@@ -198,5 +233,6 @@ public class CachingLinkGenerator extends DefaultLinkGenerator {
 
     public void clearCache() {
         linkCache.invalidateAll();
+        resetControllerNamespaceCache();
     }
 }
diff --git a/grails-web-url-mappings/src/main/groovy/org/grails/web/mapping/DefaultLinkGenerator.groovy b/grails-web-url-mappings/src/main/groovy/org/grails/web/mapping/DefaultLinkGenerator.groovy
index fd2a3fc665..7ed93b11b5 100644
--- a/grails-web-url-mappings/src/main/groovy/org/grails/web/mapping/DefaultLinkGenerator.groovy
+++ b/grails-web-url-mappings/src/main/groovy/org/grails/web/mapping/DefaultLinkGenerator.groovy
@@ -44,6 +44,10 @@ import grails.web.mapping.LinkGenerator
 import grails.web.mapping.UrlCreator
 import grails.web.mapping.UrlMapping
 import grails.web.mapping.UrlMappingsHolder
+import grails.core.GrailsApplication
+import grails.core.GrailsClass
+import grails.core.GrailsControllerClass
+import org.grails.core.artefact.ControllerArtefactHandler
 import org.grails.core.artefact.DomainClassArtefactHandler
 import org.grails.datastore.mapping.model.MappingContext
 import org.grails.datastore.mapping.model.PersistentEntity
@@ -83,6 +87,12 @@ class DefaultLinkGenerator implements LinkGenerator, PluginManagerAware {
     @Autowired(required = false)
     UrlConverter grailsUrlConverter = new CamelCaseUrlConverter()
 
+    @Autowired(required = false)
+    GrailsApplication grailsApplication
+
+    private volatile Map<String, Set<String>> controllerNamespacesByName
+    private volatile GrailsClass[] cachedControllers
+
     @Value('${grails.resources.pattern:/static/**}')
     String resourcePattern = Settings.DEFAULT_RESOURCE_PATTERN
 
@@ -247,12 +257,20 @@ class DefaultLinkGenerator implements LinkGenerator, PluginManagerAware {
                     params.put(ATTRIBUTE_ID, id)
                 }
                 def pluginName = attrs.get(UrlMapping.PLUGIN)?.toString()
-                def namespace = attrs.get(UrlMapping.NAMESPACE)?.toString()
-                if (namespace == null) {
-                    if (controller == requestStateLookupStrategy.controllerName) {
-                        namespace = requestStateLookupStrategy.controllerNamespace
+                // An explicit namespace attribute always wins so that a blank one (namespace="" or
+                // namespace: null) can opt out and target a non-namespaced controller. A blank value is
+                // normalised to null because reverse mappings key non-namespaced controllers on null.
+                // Only when no namespace was supplied do we infer one for the target controller.
+                String namespace
+                if (attrs.containsKey(UrlMapping.NAMESPACE)) {
+                    namespace = attrs.get(UrlMapping.NAMESPACE)?.toString()
+                    if (namespace != null && namespace.trim().isEmpty()) {
+                        namespace = null
                     }
                 }
+                else {
+                    namespace = getDefaultNamespace(controller, pluginName)
+                }
                 UrlCreator mapping = urlMappingsHolder.getReverseMappingNoDefault(controller, action, namespace, pluginName, httpMethod, params)
                 if (mapping == null && isDefaultAction) {
                     mapping = urlMappingsHolder.getReverseMappingNoDefault(controller, null, namespace, pluginName, httpMethod, params)
@@ -288,6 +306,116 @@ class DefaultLinkGenerator implements LinkGenerator, PluginManagerAware {
         return writer.toString()
     }
 
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    String getDefaultNamespace(String controller, String pluginName) {
+        if (controller == null) {
+            return null
+        }
+        String currentControllerName = requestStateLookupStrategy.controllerName
+        // Preserve the historical behaviour of reusing the current request namespace when the link
+        // targets the controller currently handling the request.
+        if (controller == currentControllerName) {
+            return requestStateLookupStrategy.controllerNamespace
+        }
+
+        // A plugin-provided target is resolved within that plugin, so do not infer a namespace from
+        // the application's own controllers, which could pick an unrelated same-named controller.
+        if (pluginName != null) {
+            return null
+        }
+
+        Set<String> namespaces = getControllerNamespacesByName().get(controller)
+        if (namespaces == null || namespaces.isEmpty()) {
+            return null
+        }
+
+        // The normal case: exactly one controller has this name, so use its namespace (which may be
+        // the non-namespaced/default one). Links therefore "just work" from controller and action
+        // alone, with no namespace attribute required.
+        if (namespaces.size() == 1) {
+            return namespaces.iterator().next()
+        }
+
+        // Otherwise the same controller name is defined in more than one namespace - a discouraged
+        // design that the caller is expected to disambiguate with an explicit namespace. Fall back to
+        // a sensible default rather than guessing: prefer the non-namespaced controller when one
+        // exists, then a controller in the current request namespace; leave anything still ambiguous
+        // to the existing reverse-mapping default.
+        if (namespaces.contains(null)) {
+            return null
+        }
+        String currentNamespace = requestStateLookupStrategy.controllerNamespace
+        if (currentNamespace != null && namespaces.contains(currentNamespace)) {
+            return currentNamespace
+        }
+        return null
+    }
+
+    private Map<String, Set<String>> getControllerNamespacesByName() {
+        GrailsApplication application = grailsApplication
+        if (application == null) {
+            return Collections.emptyMap()
+        }
+        // getArtefacts returns a cached array that is replaced with a new instance whenever the set of
+        // controllers changes (late registration in tests, a development-mode reload, or a namespace
+        // edit). Comparing the array identity rebuilds the index on any such change while staying O(1)
+        // on the common path where nothing changed.
+        GrailsClass[] controllers = application.getArtefacts(ControllerArtefactHandler.TYPE)
+        Map<String, Set<String>> index = controllerNamespacesByName
+        if (index == null || !controllers.is(cachedControllers)) {
+            index = buildControllerNamespaceIndex(controllers)
+            controllerNamespacesByName = index
+            cachedControllers = controllers
+        }
+        return index
+    }
+
+    /**
+     * @return {@code true} if at least one registered controller declares a namespace. Used by the
+     * caching link generator to decide whether a request's namespace context must be folded into the
+     * cache key for link shapes whose target controller it cannot cheaply resolve (resource links).
+     */
+    protected boolean hasNamespacedControllers() {
+        for (Set<String> namespaces in getControllerNamespacesByName().values()) {
+            for (String namespace in namespaces) {
+                if (namespace != null) {
+                    return true
+                }
+            }
+        }
+        return false
+    }
+
+    private Map<String, Set<String>> buildControllerNamespaceIndex(GrailsClass[] controllers) {
+        Map<String, Set<String>> index = new HashMap<>()
+        for (GrailsClass gc in controllers) {
+            GrailsControllerClass controllerClass = (GrailsControllerClass) gc
+            String name = controllerClass.logicalPropertyName
+            if (name == null) {
+                continue
+            }
+            Set<String> namespaces = index.get(name)
+            if (namespaces == null) {
+                namespaces = new HashSet<>()
+                index.put(name, namespaces)
+            }
+            namespaces.add(controllerClass.namespace)
+        }
+        return index
+    }
+
+    /**
+     * Clears the cached controller-to-namespace index so it is rebuilt on next use. Invoked when the
+     * set of controllers may have changed (for example during development-mode reloads).
+     */
+    void resetControllerNamespaceCache() {
+        controllerNamespacesByName = null
+        cachedControllers = null
+    }
+
     @CompileStatic(TypeCheckingMode.SKIP)
     protected String getResourceId(resourceAttribute) {
         try {
diff --git a/grails-web-url-mappings/src/test/groovy/org/grails/web/mapping/LinkGeneratorNamespaceInferenceSpec.groovy b/grails-web-url-mappings/src/test/groovy/org/grails/web/mapping/LinkGeneratorNamespaceInferenceSpec.groovy
new file mode 100644
index 0000000000..58a00cc872
--- /dev/null
+++ b/grails-web-url-mappings/src/test/groovy/org/grails/web/mapping/LinkGeneratorNamespaceInferenceSpec.groovy
@@ -0,0 +1,259 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.grails.web.mapping
+
+import grails.core.DefaultGrailsApplication
+import grails.util.GrailsWebMockUtil
+import grails.web.CamelCaseUrlConverter
+import grails.web.mapping.UrlCreator
+import grails.web.mapping.UrlMappingsHolder
+import org.grails.web.util.WebUtils
+import org.springframework.web.context.request.RequestContextHolder
+
+import spock.lang.Specification
+import spock.lang.Unroll
+
+/**
+ * Exhaustive tests for namespace inference performed by
+ * {@link org.grails.web.mapping.DefaultLinkGenerator#getDefaultNamespace(String)} and the way it
+ * feeds into {@link org.grails.web.mapping.DefaultLinkGenerator#link(java.util.Map, String)}.
+ *
+ * <p>The registered controllers establish these logical-name to namespace mappings:</p>
+ * <ul>
+ *   <li>{@code home}   -> root only</li>
+ *   <li>{@code book}   -> {@code admin} only (unambiguous single namespace)</li>
+ *   <li>{@code author} -> root and {@code admin} (ambiguous: root sibling)</li>
+ *   <li>{@code report} -> {@code admin} and {@code sales} (ambiguous: multiple namespaces)</li>
+ * </ul>
+ */
+class LinkGeneratorNamespaceInferenceSpec extends Specification {
+
+    static final String BASE_URL = 'https://myserver.com/foo'
+    static final String CONTEXT = '/bar'
+
+    DefaultGrailsApplication grailsApplication
+
+    def setup() {
+        WebUtils.clearGrailsWebRequest()
+        grailsApplication = new DefaultGrailsApplication(
+                org.grails.web.mapping.nsinference.root.AuthorController,
+                org.grails.web.mapping.nsinference.root.HomeController,
+                org.grails.web.mapping.nsinference.admin.AuthorController,
+                org.grails.web.mapping.nsinference.admin.BookController,
+                org.grails.web.mapping.nsinference.admin.ReportController,
+                org.grails.web.mapping.nsinference.sales.ReportController
+        ).tap {
+            initialise()
+        }
+    }
+
+    void cleanup() {
+        RequestContextHolder.resetRequestAttributes()
+        WebUtils.clearGrailsWebRequest()
+    }
+
+    @Unroll
+    def "getDefaultNamespace(#controller) with request controller=#reqController namespace=#reqNamespace resolves to #expected"() {
+        given:
+        bindRequest(reqController, reqNamespace)
+        def generator = createGenerator()
+
+        expect:
+        generator.getDefaultNamespace(controller, null) == expected
+
+        where:
+        reqController | reqNamespace || controller || expected
+        // Same controller as the current request: reuse the current namespace (historical behaviour)
+        'book'        | 'admin'      || 'book'      || 'admin'
+        'home'        | null         || 'home'      || null
+        // No current namespace: only infer for an unambiguous single-namespaced target
+        null          | null         || 'book'      || 'admin'   // unique namespaced
+        null          | null         || 'home'      || null      // root only
+        null          | null         || 'author'    || null      // ambiguous: root + admin
+        null          | null         || 'report'    || null      // ambiguous: admin + sales
+        null          | null         || 'unknown'   || null      // not registered
+        // Ambiguous root+namespaced: the non-namespaced controller wins even from a matching namespace
+        'page'        | 'admin'      || 'author'    || null
+        // Ambiguous multiple-namespaced (no root): fall back to the current request namespace
+        'page'        | 'admin'      || 'report'    || 'admin'
+        'page'        | 'sales'      || 'report'    || 'sales'
+        // Single controller with the name -> just works regardless of the current namespace
+        'page'        | 'admin'      || 'book'      || 'admin'
+        'page'        | 'sales'      || 'book'      || 'admin'
+        'page'        | 'sales'      || 'author'    || null      // ambiguous root+admin -> root
+        'page'        | 'admin'      || 'home'      || null      // single root controller -> root
+    }
+
+    @Unroll
+    def "link infers namespace for #attrs -> #expectedUrl"() {
+        given:
+        bindRequest(reqController, reqNamespace)
+        def generator = createGenerator()
+
+        expect:
+        generator.link(attrs) == expectedUrl
+
+        where:
+        reqController | reqNamespace || attrs                                          || expectedUrl
+        null          | null         || [controller: 'book', action: 'index']          || '/bar/admin/book/index'
+        null          | null         || [controller: 'home', action: 'index']          || '/bar/home/index'
+        null          | null         || [controller: 'author', action: 'index']        || '/bar/author/index'
+        'page'        | 'admin'      || [controller: 'author', action: 'list']         || '/bar/author/list'
+        'page'        | 'admin'      || [controller: 'report', action: 'index']        || '/bar/admin/report/index'
+        'page'        | 'sales'      || [controller: 'report', action: 'index']        || '/bar/sales/report/index'
+    }
+
+    def "an explicit namespace attribute always wins over inference"() {
+        given:
+        bindRequest('page', 'admin')
+        def generator = createGenerator()
+
+        expect: 'explicit namespace beats the inferred admin namespace'
+        generator.link(controller: 'book', action: 'index', namespace: 'frontend') == '/bar/frontend/book/index'
+    }
+
+    def "an explicit null namespace opts out of inference and targets the root controller"() {
+        given:
+        bindRequest('page', 'admin')
+        def generator = createGenerator()
+
+        expect: 'namespace: null suppresses the inferred admin namespace'
+        generator.link(controller: 'book', action: 'index', namespace: null) == '/bar/book/index'
+    }
+
+    @Unroll
+    def "a blank explicit namespace (#explicitNamespace) reaches reverse mapping as null like namespace: null"() {
+        given: 'a generator that records the namespace passed to the reverse mapping'
+        bindRequest('page', 'admin')
+        String captured = 'UNSET'
+        def generator = new DefaultLinkGenerator(BASE_URL, CONTEXT)
+        generator.grailsUrlConverter = new CamelCaseUrlConverter()
+        generator.grailsApplication = grailsApplication
+        def callable = { String controller, String action, String namespace, String pluginName, String httpMethod, Map params ->
+            captured = namespace
+            [createRelativeURL: { String c, String a, String n, String p, Map pv, String enc, String frag ->
+                "/$controller/$action".toString()
+            }] as UrlCreator
+        }
+        generator.urlMappingsHolder = [getReverseMapping: callable, getReverseMappingNoDefault: callable] as UrlMappingsHolder
+
+        when: 'a blank namespace is supplied explicitly (book exists only in admin, so inference would yield admin)'
+        generator.link(controller: 'book', action: 'index', namespace: explicitNamespace)
+
+        then: 'the blank namespace is normalised to null so non-namespaced reverse mappings match'
+        captured == null
+
+        where:
+        explicitNamespace << ['', '   ', null]
+    }
+
+    def "inference is skipped gracefully when no GrailsApplication is available"() {
+        given:
+        bindRequest(null, null)
+        def generator = createGenerator(false)
+
+        expect: 'with no application wired, links are never namespaced by inference'
+        generator.getDefaultNamespace('book', null) == null
+        generator.link(controller: 'book', action: 'index') == '/bar/book/index'
+    }
+
+    def "a plugin-targeted link does not infer a namespace from the application controllers"() {
+        given:
+        bindRequest('page', 'admin')
+        def generator = createGenerator()
+
+        expect: 'a plugin target is resolved within the plugin, so no app namespace is inferred'
+        generator.getDefaultNamespace('book', 'someplugin') == null
+        generator.link(controller: 'book', action: 'index', plugin: 'someplugin') == '/bar/book/index'
+    }
+
+    def "caching link generator does not collide across request namespaces for the same attrs"() {
+        given: 'report exists in both admin and sales namespaces'
+        def generator = createCachingGenerator()
+
+        when: 'the same url-map link is generated from an admin request'
+        bindRequest('page', 'admin')
+        generator.resetControllerNamespaceCache()
+        def adminUrl = generator.link(url: [controller: 'report', action: 'index'])
+
+        and: 'and then from a sales request'
+        bindRequest('page', 'sales')
+        def salesUrl = generator.link(url: [controller: 'report', action: 'index'])
+
+        then: 'each request namespace gets its own correctly namespaced URL (no cache collision)'
+        adminUrl == '/bar/admin/report/index'
+        salesUrl == '/bar/sales/report/index'
+    }
+
+    def "caching link generator does not collide across request namespaces for resource links"() {
+        given: 'report exists in both admin and sales namespaces'
+        def generator = createCachingGenerator()
+
+        when: 'the same resource link is generated from an admin request'
+        bindRequest('page', 'admin')
+        generator.resetControllerNamespaceCache()
+        def adminUrl = generator.link(resource: 'report', action: 'index')
+
+        and: 'and then from a sales request'
+        bindRequest('page', 'sales')
+        def salesUrl = generator.link(resource: 'report', action: 'index')
+
+        then: 'the resource link is namespaced per request (no cache collision)'
+        adminUrl == '/bar/admin/report/index'
+        salesUrl == '/bar/sales/report/index'
+    }
+
+    private void bindRequest(String controllerName, String namespace) {
+        def webRequest = GrailsWebMockUtil.bindMockWebRequest()
+        if (controllerName != null) {
+            webRequest.setControllerName(controllerName)
+        }
+        if (namespace != null) {
+            webRequest.setControllerNamespace(namespace)
+        }
+    }
+
+    private DefaultLinkGenerator createGenerator(boolean withApplication = true) {
+        def generator = new DefaultLinkGenerator(BASE_URL, CONTEXT)
+        generator.grailsUrlConverter = new CamelCaseUrlConverter()
+        if (withApplication) {
+            generator.grailsApplication = grailsApplication
+        }
+        final callable = { String controller, String action, String namespace, String pluginName, String httpMethod, Map params ->
+            [createRelativeURL: { String c, String a, String n, String p, Map parameterValues, String encoding, String fragment ->
+                "${namespace ? '/' + namespace : ''}/$controller/$action${parameterValues.id ? '/' + parameterValues.id : ''}".toString()
+            }] as UrlCreator
+        }
+        generator.urlMappingsHolder = [getReverseMapping: callable, getReverseMappingNoDefault: callable] as UrlMappingsHolder
+        generator
+    }
+
+    private CachingLinkGenerator createCachingGenerator() {
+        def generator = new CachingLinkGenerator(BASE_URL, CONTEXT)
+        generator.grailsUrlConverter = new CamelCaseUrlConverter()
+        generator.grailsApplication = grailsApplication
+        final callable = { String controller, String action, String namespace, String pluginName, String httpMethod, Map params ->
+            [createRelativeURL: { String c, String a, String n, String p, Map parameterValues, String encoding, String fragment ->
+                "${namespace ? '/' + namespace : ''}/$controller/$action${parameterValues.id ? '/' + parameterValues.id : ''}".toString()
+            }] as UrlCreator
+        }
+        generator.urlMappingsHolder = [getReverseMapping: callable, getReverseMappingNoDefault: callable] as UrlMappingsHolder
+        generator
+    }
+}
diff --git a/grails-web-url-mappings/src/test/groovy/org/grails/web/mapping/nsinference/admin/AdminNamespaceControllers.groovy b/grails-web-url-mappings/src/test/groovy/org/grails/web/mapping/nsinference/admin/AdminNamespaceControllers.groovy
new file mode 100644
index 0000000000..609324c28c
--- /dev/null
+++ b/grails-web-url-mappings/src/test/groovy/org/grails/web/mapping/nsinference/admin/AdminNamespaceControllers.groovy
@@ -0,0 +1,53 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.grails.web.mapping.nsinference.admin
+
+import grails.artefact.Artefact
+
+/**
+ * An {@code admin}-namespaced controller whose logical name {@code author} also exists at the root,
+ * used to exercise the ambiguous root-plus-namespaced resolution case.
+ */
+@Artefact('Controller')
+class AuthorController {
+    static namespace = 'admin'
+    def index() {}
+    def list() {}
+}
+
+/**
+ * An {@code admin}-namespaced controller whose logical name {@code book} exists only in the
+ * {@code admin} namespace, used to exercise the unambiguous single-namespaced resolution case.
+ */
+@Artefact('Controller')
+class BookController {
+    static namespace = 'admin'
+    def index() {}
+    def list() {}
+}
+
+/**
+ * An {@code admin}-namespaced controller whose logical name {@code report} also exists in the
+ * {@code sales} namespace, used to exercise the ambiguous multiple-namespaced resolution case.
+ */
+@Artefact('Controller')
+class ReportController {
+    static namespace = 'admin'
+    def index() {}
+}
diff --git a/grails-test-examples/namespaces/grails-app/controllers/namespaces/admin/PageController.groovy b/grails-web-url-mappings/src/test/groovy/org/grails/web/mapping/nsinference/root/RootNamespaceControllers.groovy
similarity index 58%
copy from grails-test-examples/namespaces/grails-app/controllers/namespaces/admin/PageController.groovy
copy to grails-web-url-mappings/src/test/groovy/org/grails/web/mapping/nsinference/root/RootNamespaceControllers.groovy
index 1d13c87ac4..8b3bc5a58c 100644
--- a/grails-test-examples/namespaces/grails-app/controllers/namespaces/admin/PageController.groovy
+++ b/grails-web-url-mappings/src/test/groovy/org/grails/web/mapping/nsinference/root/RootNamespaceControllers.groovy
@@ -16,14 +16,25 @@
  *  specific language governing permissions and limitations
  *  under the License.
  */
+package org.grails.web.mapping.nsinference.root
 
-package namespaces.admin
+import grails.artefact.Artefact
 
-class PageController {
-
-    static namespace = "admin"
+/**
+ * A non-namespaced (root) controller whose logical name {@code author} also exists in the
+ * {@code admin} namespace, used to exercise the ambiguous root-plus-namespaced resolution case.
+ */
+@Artefact('Controller')
+class AuthorController {
+    def index() {}
+    def list() {}
+}
 
-    def index() {
-        render view: "/page/index", model: [pageTitle: "Admin Page"]
-    }
+/**
+ * A non-namespaced (root) controller whose logical name {@code home} exists only at the root,
+ * used to exercise the root-only resolution case.
+ */
+@Artefact('Controller')
+class HomeController {
+    def index() {}
 }
diff --git a/grails-test-examples/namespaces/grails-app/controllers/UrlMappings.groovy b/grails-web-url-mappings/src/test/groovy/org/grails/web/mapping/nsinference/sales/SalesNamespaceControllers.groovy
similarity index 68%
copy from grails-test-examples/namespaces/grails-app/controllers/UrlMappings.groovy
copy to grails-web-url-mappings/src/test/groovy/org/grails/web/mapping/nsinference/sales/SalesNamespaceControllers.groovy
index 15da89a7fb..c5b3faec5f 100644
--- a/grails-test-examples/namespaces/grails-app/controllers/UrlMappings.groovy
+++ b/grails-web-url-mappings/src/test/groovy/org/grails/web/mapping/nsinference/sales/SalesNamespaceControllers.groovy
@@ -16,21 +16,16 @@
  *  specific language governing permissions and limitations
  *  under the License.
  */
+package org.grails.web.mapping.nsinference.sales
 
-class UrlMappings {
+import grails.artefact.Artefact
 
-    static mappings = {
-        "/$controller/$action?/$id?(.$format)?"{
-            constraints {
-                // apply constraints here
-            }
-        }
-
-        "/admin/$controller/$action?/$id?(.$format)?"{
-            namespace = "admin"
-        }
-
-        "/"(view:"/index")
-        "500"(view:'/error')
-    }
+/**
+ * A {@code sales}-namespaced controller whose logical name {@code report} also exists in the
+ * {@code admin} namespace, used to exercise the ambiguous multiple-namespaced resolution case.
+ */
+@Artefact('Controller')
+class ReportController {
+    static namespace = 'sales'
+    def index() {}
 }