You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@grails.apache.org by jd...@apache.org on 2026/08/05 12:43:13 UTC

(grails-core) 01/01: fix: address review feedback on the GORM registry consolidation

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

jdaugherty pushed a commit to branch fix/gorm-registry-review-feedback
in repository https://gitbox.apache.org/repos/asf/grails-core.git

commit 4d2b83262830b714d0c23a6cd4c5eec1945ccc19
Author: James Daugherty <jd...@jdresources.net>
AuthorDate: Mon Aug 3 15:15:16 2026 -0400

    fix: address review feedback on the GORM registry consolidation
    
    Public API
    - Restore Tenants.withId(Class<? extends Datastore>, ..) and give the domain-class
      variants distinct names (withIdForDomain / withTenantForDomain); both erased to the
      same signature, so ~40 in-tree callers passing a datastore class silently changed
      meaning.
    - CurrentTenantHolder.get() now throws when different datastores hold different
      tenants instead of returning an arbitrary one.
    - Restore GormStaticApi.multiTenancyMode as a derived accessor; MongoStaticApi no
      longer shadows it or persistentEntity.
    - Restore AbstractGormApi.EXCLUDES to public and re-add getMethods,
      getExtendedMethods and setTransactionManager, plus the newly added
      getTransactionManager, getQualifier and getGormPersistentEntity.
    - Remove routesUnqualifiedToMappedConnection() from
      MultipleConnectionSourceCapableDatastore; the SPI existed only to accommodate the
      in-memory test datastore.
    - Add a non-mutating SessionResolver.hasSession(); hasCurrentSession() uses it, so a
      routing probe no longer unbinds another datastore's holder.
    
    Correctness
    - deleteAll(Map) honours flush instead of discarding params.
    - Deterministic unqualified routing: an entity mapped only to non-default connections
      routes to its first declared connection, decided at registration rather than by map
      iteration order.
    - AbstractGormApiRegistry.removeDatastore only evicts on a positive identity match and
      logs at warn when a datastore cannot be read.
    - Hibernate 5/7 getAll matches an entity back to a requested id that the
      ConversionService could not coerce, instead of yielding a null slot.
    - first()/last() no longer order by a non-existent single identity for composite-key
      entities, so the composite-key TCK test passes on Hibernate 7 rather than being
      excluded.
    - TenantDelegatingGormOperations binds through the datastore instance it holds rather
      than looking one up by class.
    
    Cost and clarity
    - Replace the getDatastoreForConnection exception probe with a non-throwing
      connection-source name check on the per-operation path.
    - Only install metaclass missing-method dispatch for entities that are not GormEntity,
      and never over a handler someone else installed.
    - Remove the duplicate constraint-registration pass and give API-factory registration
      its own registerApiFactories() hook.
    - Scope the AstUtils.copyAnnotations dedup guard to the ServiceTransformation call
      sites so repeatable annotations survive elsewhere.
    - Strip the debug scaffolding from Tenants and GrailsTransactionTemplate; narrow the
      catch around getDatastoreForTenantId and log at warn.
    - Drop the DatastoreResolver parameters GormRegistry's create*Api methods ignored.
    
    Docs
    - Guide: new GORM multi-tenancy section covering Tenants and CurrentTenantHolder;
      deleteAll documented under basic CRUD.
    - Grails 8 upgrade notes for the removed GormEnhancer members, the new
      registerApiFactories() hook, and the Tenants/CurrentTenantHolder changes.
    - Developer guide no longer documents the removed enhance() call.
---
 .../main/docs/understandingApi/gormEnhancer.adoc   |  33 +++-
 .../orm/hibernate/HibernateGormEnhancer.groovy     |   2 +-
 .../org/grails/orm/hibernate/HibernateSession.java |  24 ++-
 .../HibernateSessionRetrieveAllSpec.groovy         |  76 +++++++
 .../orm/hibernate/HibernateGormEnhancer.groovy     |   2 +-
 .../org/grails/orm/hibernate/HibernateSession.java |  24 ++-
 .../orm/hibernate/HibernateSessionSpec.groovy      |  40 ++++
 .../datastore/gorm/mongo/MongoGormEnhancer.groovy  |   4 +-
 .../datastore/gorm/mongo/api/MongoStaticApi.groovy |  17 +-
 .../gorm/mongo/api/MongoStaticApiSpec.groovy       |  27 ++-
 .../mapping/simple/SimpleMapDatastore.java         |   9 -
 .../mapping/simple/SimpleMapDatastoreSpec.groovy   |  13 +-
 .../grails/gorm/api/GormStaticOperations.groovy    |  16 +-
 .../gorm/multitenancy/CurrentTenantHolder.groovy   |  32 ++-
 .../groovy/grails/gorm/multitenancy/Tenants.groovy |  82 +++++---
 .../transactions/GrailsTransactionTemplate.groovy  |  19 +-
 .../grails/datastore/gorm/AbstractGormApi.groovy   |  64 +++---
 .../datastore/gorm/AbstractGormApiRegistry.groovy  |  39 ++--
 .../gorm/ConnectionSourceNameResolver.groovy       |  36 ++++
 .../org/grails/datastore/gorm/GormEnhancer.groovy  |  74 +++++--
 .../org/grails/datastore/gorm/GormEntity.groovy    |  15 +-
 .../org/grails/datastore/gorm/GormRegistry.groovy  | 105 ++++------
 .../org/grails/datastore/gorm/GormStaticApi.groovy |  73 ++++++-
 .../TenantDelegatingGormOperations.groovy          | 219 +++++++++++----------
 .../transform/ServiceTransformation.groovy         |   6 +-
 .../multitenancy/CurrentTenantHolderSpec.groovy    |  52 +++++
 .../grails/gorm/multitenancy/TenantsSpec.groovy    |  73 ++++++-
 .../gorm/AbstractGormApiRegistrySpec.groovy        |  65 +++++-
 .../datastore/gorm/AbstractGormApiSpec.groovy      |  27 ++-
 .../gorm/ActiveSessionDatastoreSelectorSpec.groovy |  35 +++-
 .../gorm/ConnectionSourceNameResolverSpec.groovy   |  51 +++++
 .../datastore/gorm/GormEnhancerCoverageSpec.groovy |  48 +++++
 .../datastore/gorm/GormRegistryCoverageSpec.groovy |  71 ++++++-
 .../grails/datastore/gorm/GormStaticApiSpec.groovy |  29 ++-
 .../tck/tests/FirstAndLastMethodSpec.groovy        |   2 +-
 .../data/testing/tck/tests/GetAllSpec.groovy       |  89 +++++++++
 .../datastore/mapping/core/AbstractDatastore.java  |   6 +-
 .../datastore/mapping/core/SessionResolver.groovy  |  12 ++
 ...ransactionSynchronizationSessionResolver.groovy |  17 ++
 .../MultipleConnectionSourceCapableDatastore.java  |  17 --
 .../datastore/mapping/reflect/AstUtils.groovy      |  25 ++-
 ...actionSynchronizationSessionResolverSpec.groovy |  60 ++++++
 .../datastore/mapping/reflect/AstUtilsSpec.groovy  |  34 +++-
 grails-doc/src/en/guide/GORM/multiTenancy.adoc     | 135 +++++++++++++
 .../en/guide/GORM/quickStartGuide/basicCRUD.adoc   |  38 ++++
 grails-doc/src/en/guide/toc.yml                    |   1 +
 .../src/en/guide/upgrading/upgrading80x.adoc       |  84 ++++++++
 47 files changed, 1640 insertions(+), 382 deletions(-)

diff --git a/grails-data-docs/guide-developer/src/main/docs/understandingApi/gormEnhancer.adoc b/grails-data-docs/guide-developer/src/main/docs/understandingApi/gormEnhancer.adoc
index 156a2169a7..d5212989ef 100644
--- a/grails-data-docs/guide-developer/src/main/docs/understandingApi/gormEnhancer.adoc
+++ b/grails-data-docs/guide-developer/src/main/docs/understandingApi/gormEnhancer.adoc
@@ -43,8 +43,7 @@ def mongoDatastore = new MongoDatastore(context)
 mongoDatastore.afterPropertiesSet()
 
 // enhance
-def enhancer = new MongoGormEnhancer(mongoDatastore, new DatastoreTransactionManager(datastore: mongoDatastore))
-enhancer.enhance()
+new MongoGormEnhancer(mongoDatastore, new DatastoreTransactionManager(datastore: mongoDatastore))
 
 // use GORM!
 def books = Book.list()
@@ -54,6 +53,30 @@ The key element for enabling the use of all GORM methods (`list()`, dynamic find
 
 [source,groovy]
 ----
-def enhancer = new GormEnhancer(mongoDatastore, new DatastoreTransactionManager(datastore: mongoDatastore))
-enhancer.enhance()
-----
\ No newline at end of file
+new GormEnhancer(mongoDatastore, new DatastoreTransactionManager(datastore: mongoDatastore))
+----
+
+Constructing the enhancer is all that is required. It registers every entity in the datastore's mapping context
+as it is constructed, and `registerEntity(PersistentEntity)` registers one that is added later. There is no
+separate `enhance()` step.
+
+=== Supplying Your Own API Objects
+
+API objects are built on demand by a `GormApiFactory`, looked up per `(entity, qualifier)`. A datastore that
+needs specialised API objects — for instance a static API exposing driver-specific query methods — registers a
+factory for its datastore type by overriding `registerApiFactories()`, which the enhancer calls once before any
+entity is registered:
+
+[source,groovy]
+----
+class MongoGormEnhancer extends GormEnhancer {
+
+    @Override
+    protected void registerApiFactories() {
+        GormRegistry.instance.registerApiFactory(MongoDatastore, new MongoGormApiFactory())
+    }
+}
+----
+
+Registering the factory before entity registration is what lets code cast the result of a lookup to the
+specialised type, for example `(MongoStaticApi) findStaticApi(..)`.
\ No newline at end of file
diff --git a/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/HibernateGormEnhancer.groovy b/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/HibernateGormEnhancer.groovy
index 7433f4ca9d..4527412b0a 100644
--- a/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/HibernateGormEnhancer.groovy
+++ b/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/HibernateGormEnhancer.groovy
@@ -75,7 +75,7 @@ class HibernateGormEnhancer extends GormEnhancer {
     }
 
     @Override
-    protected void registerConstraints(Datastore datastore) {
+    protected void registerApiFactories() {
         GormRegistry.instance.registerApiFactory(HibernateDatastore, new HibernateGormApiFactory(Thread.currentThread().contextClassLoader))
     }
 }
diff --git a/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/HibernateSession.java b/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/HibernateSession.java
index b1a4d57e12..ce17489a4b 100644
--- a/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/HibernateSession.java
+++ b/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/HibernateSession.java
@@ -190,6 +190,7 @@ public class HibernateSession extends AbstractHibernateSession {
             }
 
             final Map<Object, Object> entitiesById = new HashMap<>();
+            final Map<String, Object> entitiesByIdString = new HashMap<>();
             if (!distinctIds.isEmpty()) {
                 final CriteriaBuilder criteriaBuilder = session.getCriteriaBuilder();
                 CriteriaQuery criteriaQuery = criteriaBuilder.createQuery(type);
@@ -204,19 +205,38 @@ public class HibernateSession extends AbstractHibernateSession {
 
                 final List results = new HibernateHqlQuery(this, persistentEntity, jpaQuery).list();
                 for (Object entity : results) {
-                    entitiesById.put(session.getIdentifier(entity), entity);
+                    Object identifier = session.getIdentifier(entity);
+                    entitiesById.put(identifier, entity);
+                    entitiesByIdString.put(String.valueOf(identifier), entity);
                 }
             }
 
             // Reassemble in the requested order, leaving a null slot for missing ids.
             final List ordered = new ArrayList<>(requestedIds.size());
             for (Serializable requestedId : requestedIds) {
-                ordered.add(requestedId == null ? null : entitiesById.get(requestedId));
+                ordered.add(requestedId == null ? null : resolveEntity(requestedId, entitiesById, entitiesByIdString));
             }
             return ordered;
         });
     }
 
+    /**
+     * Looks up the entity returned for a requested id.
+     *
+     * The direct lookup covers ids that reached the query already in the identifier's type. When
+     * {@link #convertToIdentifierType} could not coerce a key, that key was passed through raw and
+     * Hibernate coerced it instead, so the returned entity's identifier does not equal the requested
+     * key and the direct lookup misses. The string index catches exactly that case: identifiers are
+     * unique within an entity type, so their string forms are too.
+     */
+    private Object resolveEntity(Serializable requestedId, Map<Object, Object> entitiesById, Map<String, Object> entitiesByIdString) {
+        Object entity = entitiesById.get(requestedId);
+        if (entity == null) {
+            entity = entitiesByIdString.get(String.valueOf(requestedId));
+        }
+        return entity;
+    }
+
     private Serializable convertToIdentifierType(Object key, Class idType, ConversionService conversionService) {
         if (key == null || idType.isInstance(key)) {
             return (Serializable) key;
diff --git a/grails-data-hibernate5/core/src/test/groovy/org/grails/orm/hibernate/HibernateSessionRetrieveAllSpec.groovy b/grails-data-hibernate5/core/src/test/groovy/org/grails/orm/hibernate/HibernateSessionRetrieveAllSpec.groovy
new file mode 100644
index 0000000000..9829bdab93
--- /dev/null
+++ b/grails-data-hibernate5/core/src/test/groovy/org/grails/orm/hibernate/HibernateSessionRetrieveAllSpec.groovy
@@ -0,0 +1,76 @@
+/*
+ *  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.orm.hibernate
+
+import grails.gorm.annotation.Entity
+import grails.gorm.tests.HibernateGormDatastoreSpec
+
+/**
+ * The shared TCK {@code GetAllSpec} covers the portable contract (order, duplicates, null slots).
+ * This spec covers the Hibernate-specific part: a requested id that the {@code ConversionService}
+ * cannot coerce is passed to the query raw and coerced by Hibernate instead, so the entity that
+ * comes back does not carry the requested key as its identifier and has to be matched back to it.
+ */
+class HibernateSessionRetrieveAllSpec extends HibernateGormDatastoreSpec {
+
+    void setupSpec() {
+        manager.registerDomainClasses(HSRABook)
+    }
+
+    void "retrieveAll resolves an id supplied as a String, which Hibernate coerces for the query"() {
+        given:
+        def session = manager.session
+        def id1 = session.persist(new HSRABook(title: 'Coerce1'))
+        def id2 = session.persist(new HSRABook(title: 'Coerce2'))
+        session.flush()
+        session.clear()
+
+        when: "ids arrive as strings, as they do straight off a request parameter"
+        def results = session.retrieveAll(HSRABook, id1.toString(), id2.toString())
+
+        then: "the entity's own identifier is matched back to the requested key, not left as a null slot"
+        results.size() == 2
+        results*.title == ['Coerce1', 'Coerce2']
+    }
+
+    void "retrieveAll preserves the requested order, duplicates and a null slot for a missing id"() {
+        given:
+        def session = manager.session
+        def id1 = session.persist(new HSRABook(title: 'Order1'))
+        def id2 = session.persist(new HSRABook(title: 'Order2'))
+        session.flush()
+        session.clear()
+        def missingId = id2 + 100000L
+
+        when:
+        def results = session.retrieveAll(HSRABook, id2, id1, id2, missingId)
+
+        then:
+        results.size() == 4
+        results[0].title == 'Order2'
+        results[1].title == 'Order1'
+        results[2].title == 'Order2'
+        results[3] == null
+    }
+}
+
+@Entity
+class HSRABook {
+    String title
+}
diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/HibernateGormEnhancer.groovy b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/HibernateGormEnhancer.groovy
index 225bcb9f4a..3e77c2127d 100644
--- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/HibernateGormEnhancer.groovy
+++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/HibernateGormEnhancer.groovy
@@ -90,7 +90,7 @@ class HibernateGormEnhancer extends GormEnhancer {
     }
 
     @Override
-    protected void registerConstraints(Datastore datastore) {
+    protected void registerApiFactories() {
         GormRegistry.instance.registerApiFactory(HibernateDatastore, new HibernateGormApiFactory(Thread.currentThread().contextClassLoader))
     }
 
diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/HibernateSession.java b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/HibernateSession.java
index da5b381273..ec4e56953d 100644
--- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/HibernateSession.java
+++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/HibernateSession.java
@@ -414,6 +414,7 @@ public class HibernateSession extends AbstractAttributeStoringSession implements
             }
 
             final Map<Object, Object> entitiesById = new HashMap<>();
+            final Map<String, Object> entitiesByIdString = new HashMap<>();
             if (!distinctIds.isEmpty()) {
                 HqlQueryContext queryContext = HqlQueryContext.prepare(
                     persistentEntity,
@@ -434,19 +435,38 @@ public class HibernateSession extends AbstractAttributeStoringSession implements
                     queryContext
                 ).list();
                 for (Object entity : results) {
-                    entitiesById.put(session.getIdentifier(entity), entity);
+                    Object identifier = session.getIdentifier(entity);
+                    entitiesById.put(identifier, entity);
+                    entitiesByIdString.put(String.valueOf(identifier), entity);
                 }
             }
 
             // Reassemble in the requested order, leaving a null slot for missing ids.
             final List ordered = new ArrayList<>(requestedIds.size());
             for (Serializable requestedId : requestedIds) {
-                ordered.add(requestedId == null ? null : entitiesById.get(requestedId));
+                ordered.add(requestedId == null ? null : resolveEntity(requestedId, entitiesById, entitiesByIdString));
             }
             return ordered;
         });
     }
 
+    /**
+     * Looks up the entity returned for a requested id.
+     *
+     * The direct lookup covers ids that reached the query already in the identifier's type. When
+     * {@link #convertToIdentifierType} could not coerce a key, that key was passed through raw and
+     * Hibernate coerced it instead, so the returned entity's identifier does not equal the requested
+     * key and the direct lookup misses. The string index catches exactly that case: identifiers are
+     * unique within an entity type, so their string forms are too.
+     */
+    private Object resolveEntity(Serializable requestedId, Map<Object, Object> entitiesById, Map<String, Object> entitiesByIdString) {
+        Object entity = entitiesById.get(requestedId);
+        if (entity == null) {
+            entity = entitiesByIdString.get(String.valueOf(requestedId));
+        }
+        return entity;
+    }
+
     private Serializable convertToIdentifierType(Object key, Class idType, ConversionService conversionService) {
         if (key == null || idType.isInstance(key)) {
             return (Serializable) key;
diff --git a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/HibernateSessionSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/HibernateSessionSpec.groovy
index 9f696c5d30..5dca57facb 100644
--- a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/HibernateSessionSpec.groovy
+++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/HibernateSessionSpec.groovy
@@ -368,6 +368,46 @@ class HibernateSessionSpec extends HibernateGormDatastoreSpec {
         results.size() == 2
     }
 
+    void "retrieveAll resolves an id supplied as a String, which Hibernate coerces for the query"() {
+        given:
+        def session = getSession()
+        def b1 = new HSBook(title: "Coerce1")
+        def b2 = new HSBook(title: "Coerce2")
+        def id1 = session.persist(b1)
+        def id2 = session.persist(b2)
+        session.flush()
+        session.clear()
+
+        when: "ids arrive as strings, as they do straight off a request parameter"
+        def results = session.retrieveAll(HSBook, id1.toString(), id2.toString())
+
+        then: "the entity's own identifier is matched back to the requested key, not left as a null slot"
+        results.size() == 2
+        results*.title == ['Coerce1', 'Coerce2']
+    }
+
+    void "retrieveAll preserves the requested order, duplicates and a null slot for a missing id"() {
+        given:
+        def session = getSession()
+        def b1 = new HSBook(title: "Order1")
+        def b2 = new HSBook(title: "Order2")
+        def id1 = session.persist(b1)
+        def id2 = session.persist(b2)
+        session.flush()
+        session.clear()
+        def missingId = id2 + 100000L
+
+        when:
+        def results = session.retrieveAll(HSBook, id2, id1, id2, missingId)
+
+        then:
+        results.size() == 4
+        results[0].title == 'Order2'
+        results[1].title == 'Order1'
+        results[2].title == 'Order2'
+        results[3] == null
+    }
+
     // -------------------------------------------------------------------------
     // Bulk criteria operations
     // -------------------------------------------------------------------------
diff --git a/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/gorm/mongo/MongoGormEnhancer.groovy b/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/gorm/mongo/MongoGormEnhancer.groovy
index 0a86670be8..fd8220878b 100644
--- a/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/gorm/mongo/MongoGormEnhancer.groovy
+++ b/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/gorm/mongo/MongoGormEnhancer.groovy
@@ -25,7 +25,6 @@ import org.springframework.transaction.PlatformTransactionManager
 import org.grails.datastore.gorm.GormEnhancer
 import org.grails.datastore.gorm.GormRegistry
 import org.grails.datastore.gorm.finders.DynamicFinder
-import org.grails.datastore.mapping.core.Datastore
 import org.grails.datastore.mapping.mongo.MongoDatastore
 import org.grails.datastore.mapping.mongo.connections.MongoConnectionSourceSettings
 
@@ -62,8 +61,7 @@ class MongoGormEnhancer extends GormEnhancer {
     }
 
     @Override
-    protected void registerConstraints(Datastore datastore) {
-        super.registerConstraints(datastore)
+    protected void registerApiFactories() {
         GormRegistry.instance.registerApiFactory(MongoDatastore, new MongoGormApiFactory())
     }
 
diff --git a/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/gorm/mongo/api/MongoStaticApi.groovy b/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/gorm/mongo/api/MongoStaticApi.groovy
index 54f979c3bb..16e3fc7013 100644
--- a/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/gorm/mongo/api/MongoStaticApi.groovy
+++ b/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/gorm/mongo/api/MongoStaticApi.groovy
@@ -64,15 +64,8 @@ import org.grails.datastore.mapping.multitenancy.MultiTenancySettings
 @CompileStatic
 class MongoStaticApi<D> extends GormStaticApi<D> implements MongoAllOperations<D> {
 
-    protected final PersistentEntity persistentEntity
-    protected final MultiTenancySettings.MultiTenancyMode multiTenancyMode
-
     MongoStaticApi(Class<D> persistentClass, Datastore datastore, List<FinderMethod> finders, PlatformTransactionManager transactionManager) {
         super(persistentClass, datastore, finders, transactionManager)
-        this.persistentEntity = datastore.mappingContext.getPersistentEntity(persistentClass.name)
-        this.multiTenancyMode = (datastore instanceof MongoDatastore)
-                ? ((MongoDatastore) datastore).multiTenancyMode
-                : MultiTenancySettings.MultiTenancyMode.NONE
     }
 
     FindIterable<D> find(Bson filter) {
@@ -292,9 +285,10 @@ class MongoStaticApi<D> extends GormStaticApi<D> implements MongoAllOperations<D
     }
 
     protected Bson wrapFilterWithMultiTenancy(Bson filter) {
-        if (multiTenancyMode == MultiTenancySettings.MultiTenancyMode.DISCRIMINATOR && persistentEntity.isMultiTenant()) {
+        PersistentEntity entity = getGormPersistentEntity()
+        if (multiTenancyMode == MultiTenancySettings.MultiTenancyMode.DISCRIMINATOR && entity.isMultiTenant()) {
             filter = Filters.and(
-                    Filters.eq(MappingUtils.getTargetKey(persistentEntity.tenantId), Tenants.currentId((Class<Datastore>) datastore.getClass())),
+                    Filters.eq(MappingUtils.getTargetKey(entity.tenantId), Tenants.currentId((Class<Datastore>) datastore.getClass())),
                     filter
             )
         }
@@ -303,9 +297,10 @@ class MongoStaticApi<D> extends GormStaticApi<D> implements MongoAllOperations<D
 
     private List<Bson> preparePipeline(List pipeline) {
         List<Bson> newPipeline = new ArrayList<Bson>()
-        if (multiTenancyMode == MultiTenancySettings.MultiTenancyMode.DISCRIMINATOR && persistentEntity.isMultiTenant()) {
+        PersistentEntity entity = getGormPersistentEntity()
+        if (multiTenancyMode == MultiTenancySettings.MultiTenancyMode.DISCRIMINATOR && entity.isMultiTenant()) {
             newPipeline.add(
-                    Aggregates.match(Filters.eq(MappingUtils.getTargetKey(persistentEntity.tenantId), Tenants.currentId((Class<Datastore>) datastore.getClass())))
+                    Aggregates.match(Filters.eq(MappingUtils.getTargetKey(entity.tenantId), Tenants.currentId((Class<Datastore>) datastore.getClass())))
             )
         }
         for (o in pipeline) {
diff --git a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/api/MongoStaticApiSpec.groovy b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/api/MongoStaticApiSpec.groovy
index 71e6176dc5..45fab6d8b6 100644
--- a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/api/MongoStaticApiSpec.groovy
+++ b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/api/MongoStaticApiSpec.groovy
@@ -25,27 +25,34 @@ import spock.lang.AutoCleanup
 import spock.lang.Specification
 
 /**
- * The diff added two new constructor-time field assignments: {@code persistentEntity} (resolved
- * eagerly from the datastore's mapping context) and {@code multiTenancyMode} (read from the
- * datastore when it's a real {@code MongoDatastore}, else defaulting to {@code NONE}). The
- * existing {@code MongoStaticApiMultiTenancySpec} (a real, Docker-backed
- * {@code AutoStartedMongoSpec}) already covers the "real MongoDatastore" branch; this spec covers
- * the "not a MongoDatastore" fallback cheaply with a {@link SimpleMapDatastore}, avoiding the need
- * to spin up a real MongoDB instance just to prove a defaulting branch.
+ * {@code MongoStaticApi} inherits {@code multiTenancyMode} and the persistent-entity lookup from
+ * {@code GormStaticApi} rather than re-declaring them, so there is exactly one of each per API object.
+ * The existing {@code MongoStaticApiMultiTenancySpec} (a real, Docker-backed
+ * {@code AutoStartedMongoSpec}) covers the "real MongoDatastore" branch; this spec covers the
+ * "not multi-tenant" fallback cheaply with a {@link SimpleMapDatastore}, avoiding the need to spin up a
+ * real MongoDB instance just to prove a defaulting branch.
  */
 class MongoStaticApiSpec extends Specification {
 
     @AutoCleanup
     SimpleMapDatastore datastore = new SimpleMapDatastore(MongoStaticApiSpecThing)
 
-    void "constructing MongoStaticApi with a non-MongoDatastore defaults multiTenancyMode to NONE"() {
+    void "MongoStaticApi over a datastore with no multi-tenancy reports mode NONE"() {
         when:
         def api = new MongoStaticApi<MongoStaticApiSpecThing>(MongoStaticApiSpecThing, datastore, [], datastore.transactionManager)
 
         then:
         api.multiTenancyMode == MultiTenancySettings.MultiTenancyMode.NONE
-        api.persistentEntity != null
-        api.persistentEntity.javaClass == MongoStaticApiSpecThing
+    }
+
+    void "MongoStaticApi resolves its persistent entity through the inherited accessor"() {
+        when:
+        def api = new MongoStaticApi<MongoStaticApiSpecThing>(MongoStaticApiSpecThing, datastore, [], datastore.transactionManager)
+
+        then: "there is no shadowing field — base-class code and MongoStaticApi see the same entity"
+        api.gormPersistentEntity != null
+        api.gormPersistentEntity.javaClass == MongoStaticApiSpecThing
+        !MongoStaticApi.declaredFields.any { it.name in ['persistentEntity', 'multiTenancyMode'] }
     }
 }
 
diff --git a/grails-data-simple/src/main/groovy/org/grails/datastore/mapping/simple/SimpleMapDatastore.java b/grails-data-simple/src/main/groovy/org/grails/datastore/mapping/simple/SimpleMapDatastore.java
index b25d56a6a4..68d4b651ee 100644
--- a/grails-data-simple/src/main/groovy/org/grails/datastore/mapping/simple/SimpleMapDatastore.java
+++ b/grails-data-simple/src/main/groovy/org/grails/datastore/mapping/simple/SimpleMapDatastore.java
@@ -373,15 +373,6 @@ public class SimpleMapDatastore extends AbstractDatastore implements Closeable,
         throw new ConfigurationException("No datastore found for connection named [" + connectionName + "]");
     }
 
-    @Override
-    public boolean routesUnqualifiedToMappedConnection() {
-        // This in-memory mock manages a single session on the parent datastore; its per-connection
-        // children exist only for explicit connection access. An entity mapped to a non-default
-        // datasource must therefore keep its unqualified operations on the parent, not on a child
-        // whose session the unit-test harness never flushes.
-        return false;
-    }
-
     @Override
     public void close() throws IOException {
         try {
diff --git a/grails-data-simple/src/test/groovy/org/grails/datastore/mapping/simple/SimpleMapDatastoreSpec.groovy b/grails-data-simple/src/test/groovy/org/grails/datastore/mapping/simple/SimpleMapDatastoreSpec.groovy
index 1e96e7923a..e7e1b5baeb 100644
--- a/grails-data-simple/src/test/groovy/org/grails/datastore/mapping/simple/SimpleMapDatastoreSpec.groovy
+++ b/grails-data-simple/src/test/groovy/org/grails/datastore/mapping/simple/SimpleMapDatastoreSpec.groovy
@@ -29,8 +29,7 @@ import spock.lang.Specification
  * enable it). The diff here reworked {@code getDatastoreForConnection} to be idempotent for a leaf
  * (single-connection, no-children) datastore resolving its own connection name back to itself
  * (needed so an API already bound to a tenant's own datastore can still be wrapped in
- * {@code Tenants.withId(tenantId)} without failing), and added
- * {@code routesUnqualifiedToMappedConnection()} returning {@code false}.
+ * {@code Tenants.withId(tenantId)} without failing).
  */
 class SimpleMapDatastoreSpec extends Specification {
 
@@ -59,8 +58,12 @@ class SimpleMapDatastoreSpec extends Specification {
         thrown(ConfigurationException)
     }
 
-    void "routesUnqualifiedToMappedConnection always returns false"() {
-        expect:
-        !multiDatastore.routesUnqualifiedToMappedConnection()
+    void "each connection source gets its own child datastore with its own store"() {
+        given:
+        Datastore secondary = multiDatastore.getDatastoreForConnection('secondary')
+
+        expect: "a per-connection child is a real, independent datastore, not a view onto the parent"
+        !secondary.is(multiDatastore)
+        secondary.getMappingContext().is(multiDatastore.getMappingContext())
     }
 }
diff --git a/grails-datamapping-core/src/main/groovy/grails/gorm/api/GormStaticOperations.groovy b/grails-datamapping-core/src/main/groovy/grails/gorm/api/GormStaticOperations.groovy
index ada4b10fa7..1b31ee5fb6 100644
--- a/grails-datamapping-core/src/main/groovy/grails/gorm/api/GormStaticOperations.groovy
+++ b/grails-datamapping-core/src/main/groovy/grails/gorm/api/GormStaticOperations.groovy
@@ -106,14 +106,24 @@ interface GormStaticOperations<D> {
     List<Serializable> saveAll(Iterable<?> objectsToSave)
 
     /**
-     * Deletes all objects
+     * Deletes every persisted instance of this class.
+     *
+     * To delete a subset, build a query and delete through it — for example
+     * {@code Book.where { author == 'X' }.deleteAll()}.
+     *
      * @return The number of objects deleted
      */
     Number deleteAll()
 
     /**
-     * Deletes all objects for the given arguments
-     * @param params The arguments
+     * Deletes every persisted instance of this class.
+     *
+     * {@code params} controls how the delete is executed; it does not narrow what is deleted. The
+     * supported argument is {@code flush}, which flushes the session once the delete has been issued.
+     * To delete a subset, build a query and delete through it — for example
+     * {@code Book.where { author == 'X' }.deleteAll()}.
+     *
+     * @param params The arguments, e.g. {@code [flush: true]}
      * @return The number of objects deleted
      */
     Number deleteAll(Map params)
diff --git a/grails-datamapping-core/src/main/groovy/grails/gorm/multitenancy/CurrentTenantHolder.groovy b/grails-datamapping-core/src/main/groovy/grails/gorm/multitenancy/CurrentTenantHolder.groovy
index a3b9e27c88..336843cafd 100644
--- a/grails-datamapping-core/src/main/groovy/grails/gorm/multitenancy/CurrentTenantHolder.groovy
+++ b/grails-datamapping-core/src/main/groovy/grails/gorm/multitenancy/CurrentTenantHolder.groovy
@@ -22,7 +22,17 @@ import groovy.transform.CompileStatic
 
 import org.grails.datastore.mapping.core.Datastore
 import org.grails.datastore.mapping.core.connections.ConnectionSource
+import org.grails.datastore.mapping.multitenancy.exceptions.TenantException
 
+/**
+ * Holds the tenant bound to the current thread, per datastore.
+ *
+ * A thread may have a different tenant bound for each datastore in play, so lookups are scoped by
+ * datastore. Bindings are made either against a datastore instance or against a datastore type; an
+ * instance binding wins over a type binding for the same datastore.
+ *
+ * @since 8.0
+ */
 @CompileStatic
 class CurrentTenantHolder {
 
@@ -34,14 +44,26 @@ class CurrentTenantHolder {
     }
 
     /**
-     * @return Obtain the current tenant (fallback for any datastore)
+     * Obtain the tenant bound to the current thread, irrespective of datastore.
+     *
+     * Prefer {@link #get(Datastore)}. A thread can hold a different tenant for each datastore, and only the
+     * datastore-scoped lookup can say which of them applies to a given operation.
+     *
+     * @return The bound tenant id, or {@code null} if no tenant is bound
+     * @throws TenantException if different tenants are bound for different datastores. There is no correct
+     *         answer to return in that case, and returning an arbitrary one is how cross-tenant reads and
+     *         writes happen.
      */
     static Serializable get() {
-        def map = currentTenantThreadLocal.get()
-        if (!map.isEmpty()) {
-            return map.values().iterator().next()
+        Map<Object, Serializable> map = currentTenantThreadLocal.get()
+        if (map.isEmpty()) {
+            return null
+        }
+        Set<Serializable> boundTenants = new LinkedHashSet<>(map.values())
+        if (boundTenants.size() > 1) {
+            throw new TenantException("Cannot resolve a single current tenant: ${boundTenants} are bound on this thread for different datastores. Use get(Datastore) to select the tenant of the datastore being operated on.")
         }
-        return null
+        return boundTenants.iterator().next()
     }
 
     /**
diff --git a/grails-datamapping-core/src/main/groovy/grails/gorm/multitenancy/Tenants.groovy b/grails-datamapping-core/src/main/groovy/grails/gorm/multitenancy/Tenants.groovy
index bc098fd0fd..85f8f305b9 100644
--- a/grails-datamapping-core/src/main/groovy/grails/gorm/multitenancy/Tenants.groovy
+++ b/grails-datamapping-core/src/main/groovy/grails/gorm/multitenancy/Tenants.groovy
@@ -26,10 +26,12 @@ import org.grails.datastore.gorm.GormRegistry
 import org.grails.datastore.mapping.core.Datastore
 import org.grails.datastore.mapping.core.connections.ConnectionSource
 import org.grails.datastore.mapping.core.connections.ConnectionSources
+import org.grails.datastore.mapping.core.exceptions.ConfigurationException
 import org.grails.datastore.mapping.multitenancy.AllTenantsResolver
 import org.grails.datastore.mapping.multitenancy.MultiTenancySettings
 import org.grails.datastore.mapping.multitenancy.MultiTenantCapableDatastore
 import org.grails.datastore.mapping.multitenancy.TenantResolver
+import org.grails.datastore.mapping.multitenancy.exceptions.TenantException
 
 /**
  * Helper methods for working with multi tenancy
@@ -59,7 +61,13 @@ class Tenants {
     }
 
     /**
-     * Execute the given closure with the given tenant id.
+     * Binds the given tenant id to the current thread for the duration of the closure.
+     *
+     * This binds the tenant only. Unlike {@link #withId(Serializable, Closure)} it opens no session and
+     * manages no connection life cycle: the closure runs in whatever session the caller has already
+     * established, and the caller remains responsible for opening and closing it. Reach for {@code withId}
+     * when the tenant's work needs a session of its own — in DATABASE mode that is also what selects the
+     * tenant's connection.
      *
      * @param tenantId The tenant id
      * @param callable The closure
@@ -213,12 +221,36 @@ class Tenants {
     }
 
     /**
-     * Execute the given closure with given tenant id
+     * Execute the given closure with given tenant id against the datastore of the given type
+     *
+     * @param datastoreClass The datastore type
+     * @param tenantId The tenant id
+     * @param callable The closure
+     * @return The result of the closure
+     */
+    static <T> T withId(Class<? extends Datastore> datastoreClass, Serializable tenantId, Closure<T> callable) {
+        Datastore datastore = datastoreLocator.getDatastore(datastoreClass)
+        if (datastore instanceof MultiTenantCapableDatastore) {
+            MultiTenantCapableDatastore multiTenantCapableDatastore = (MultiTenantCapableDatastore) datastore
+            return withId(multiTenantCapableDatastore, tenantId, callable)
+        }
+        else {
+            throw new UnsupportedOperationException('Datastore implementation does not support multi-tenancy')
+        }
+    }
+
+    /**
+     * Execute the given closure with given tenant id against the datastore that maps the given domain class.
+     *
+     * Distinct from {@link #withId(Class, Serializable, Closure)}, which takes a <i>datastore</i> type: both
+     * erase to the same signature, so the domain-class form needs its own name to stay unambiguous.
+     *
+     * @param domainClass The domain class whose datastore should be used
      * @param tenantId The tenant id
      * @param callable The closure
      * @return The result of the closure
      */
-    static <T> T withId(Class domainClass, Serializable tenantId, Closure<T> callable) {
+    static <T> T withIdForDomain(Class domainClass, Serializable tenantId, Closure<T> callable) {
         Datastore datastore = datastoreLocator.getDatastoreForDomain(domainClass)
         if (datastore instanceof MultiTenantCapableDatastore) {
             MultiTenantCapableDatastore multiTenantCapableDatastore = (MultiTenantCapableDatastore) datastore
@@ -230,12 +262,18 @@ class Tenants {
     }
 
     /**
-     * Execute the given closure with given tenant id for the given datastore. This method will create a new datastore session for the scope of the call and hence is designed to be used to manage the connection life cycle
+     * Binds the given tenant id to the current thread for the datastore that maps the given domain class, for
+     * the duration of the closure.
+     *
+     * This binds the tenant only. Unlike {@link #withIdForDomain(Class, Serializable, Closure)} it opens no
+     * session and manages no connection: the caller owns the session the closure runs in.
+     *
+     * @param domainClass The domain class whose datastore the tenant should be bound for
      * @param tenantId The tenant id
      * @param callable The closure
      * @return The result of the closure
      */
-    static <T> T withTenant(Class domainClass, Serializable tenantId, Closure<T> callable) {
+    static <T> T withTenantForDomain(Class domainClass, Serializable tenantId, Closure<T> callable) {
         Datastore datastore = datastoreLocator.getDatastoreForDomain(domainClass)
         return CurrentTenantHolder.withTenant(datastore.getClass(), tenantId) {
             return CurrentTenantHolder.withTenant(datastore, tenantId, callable)
@@ -287,11 +325,14 @@ class Tenants {
      */
     static <T> T withId(MultiTenantCapableDatastore multiTenantCapableDatastore, Serializable tenantId, Closure<T> callable) {
         log.debug('Tenants.withId called for datastore {} with tenantId {}', multiTenantCapableDatastore, tenantId)
-        org.grails.datastore.mapping.core.Datastore childDatastore = null
+        Datastore childDatastore = null
         try {
             childDatastore = multiTenantCapableDatastore.getDatastoreForTenantId(tenantId)
-        } catch (Throwable e) {
-            log.debug('Ignoring failure resolving datastore for tenant {}: {}', tenantId, e.message)
+        }
+        catch (ConfigurationException | TenantException e) {
+            // An unknown tenant or missing connection: fall through to the session-creating path below,
+            // which resolves the tenant again and reports the failure to the caller rather than hiding it.
+            log.warn('Could not resolve a datastore for tenant [{}]: {}', tenantId, e.message)
         }
         // Only reuse an already-bound per-tenant session for non-shared-connection modes
         // (e.g. DATABASE), where getDatastoreForTenantId yields a distinct child datastore.
@@ -319,21 +360,15 @@ class Tenants {
                 def i = callable.parameterTypes.length
                 if (i == 2) {
                     return multiTenantCapableDatastore.withSession { session ->
-                        def result = callable.call(tenantId, session)
-                        log.debug('Result from shared connection with 2 args: {}', result)
-                        return result
+                        return callable.call(tenantId, session)
                     }
                 }
                 else {
                     switch (i) {
                         case 0:
-                            def result = callable.call()
-                            log.debug('Result from shared connection with 0 args: {}', result)
-                            return result
+                            return callable.call()
                         case 1:
-                            def result = callable.call(tenantId)
-                            log.debug('Result from shared connection with 1 arg: {}', result)
-                            return result
+                            return callable.call(tenantId)
                         default:
                             throw new IllegalArgumentException('Provided closure accepts too many arguments')
                     }
@@ -341,21 +376,14 @@ class Tenants {
             }
             else {
                 return multiTenantCapableDatastore.withNewSession(tenantId) { session ->
-                    log.debug('Inside withNewSession for tenantId {}', tenantId)
                     def i = callable.parameterTypes.length
                     switch (i) {
                         case 0:
-                            def result = callable.call()
-                            log.debug('Result from new session with 0 args: {}', result)
-                            return result
+                            return callable.call()
                         case 1:
-                            def result = callable.call(tenantId)
-                            log.debug('Result from new session with 1 arg: {}', result)
-                            return result
+                            return callable.call(tenantId)
                         case 2:
-                            def result = callable.call(tenantId, session)
-                            log.debug('Result from new session with 2 args: {}', result)
-                            return result
+                            return callable.call(tenantId, session)
                         default:
                             throw new IllegalArgumentException('Provided closure accepts too many arguments')
                     }
diff --git a/grails-datamapping-core/src/main/groovy/grails/gorm/transactions/GrailsTransactionTemplate.groovy b/grails-datamapping-core/src/main/groovy/grails/gorm/transactions/GrailsTransactionTemplate.groovy
index 424aefd923..4b8c559d53 100644
--- a/grails-datamapping-core/src/main/groovy/grails/gorm/transactions/GrailsTransactionTemplate.groovy
+++ b/grails-datamapping-core/src/main/groovy/grails/gorm/transactions/GrailsTransactionTemplate.groovy
@@ -73,28 +73,13 @@ class GrailsTransactionTemplate {
             Object result = transactionTemplate.execute(new TransactionCallback() {
                 Object doInTransaction(TransactionStatus status) {
                     try {
-                        if (log.isDebugEnabled()) {
-                            log.debug('executeAndRollback doInTransaction - starting on transaction manager: ' + transactionTemplate.getTransactionManager())
-                        }
-                        Object val = action.call(status)
-                        if (log.isDebugEnabled()) {
-                            log.debug('executeAndRollback doInTransaction - finished action')
-                            log.debug('executeAndRollback action finished. status=' + status)
-                        }
-                        return val
+                        return action.call(status)
                     }
                     catch (Throwable e) {
-                        if (log.isDebugEnabled()) {
-                            log.debug('executeAndRollback doInTransaction - caught exception: ' + e)
-                            log.debug('executeAndRollback action caught: ' + e)
-                        }
+                        log.debug('Rolling back after the action threw', e)
                         return new ThrowableHolder(e)
                     } finally {
                         status.setRollbackOnly()
-                        if (log.isDebugEnabled()) {
-                            log.debug('executeAndRollback doInTransaction - setRollbackOnly called. isRollbackOnly=' + status.isRollbackOnly())
-                            log.debug('executeAndRollback setRollbackOnly called. status.isRollbackOnly=' + status.isRollbackOnly())
-                        }
                     }
                 }
             })
diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/AbstractGormApi.groovy b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/AbstractGormApi.groovy
index 139310b883..5565ff7889 100644
--- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/AbstractGormApi.groovy
+++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/AbstractGormApi.groovy
@@ -35,7 +35,6 @@ import org.grails.datastore.mapping.core.Session
 import org.grails.datastore.mapping.core.SessionCallback
 import org.grails.datastore.mapping.core.VoidSessionCallback
 import org.grails.datastore.mapping.core.connections.ConnectionSource
-import org.grails.datastore.mapping.core.connections.MultipleConnectionSourceCapableDatastore
 import org.grails.datastore.mapping.model.MappingContext
 import org.grails.datastore.mapping.model.PersistentEntity
 import org.grails.datastore.mapping.multitenancy.MultiTenantCapableDatastore
@@ -49,10 +48,30 @@ import org.grails.datastore.mapping.multitenancy.MultiTenantCapableDatastore
 @CompileStatic
 abstract class AbstractGormApi<D> extends AbstractDatastoreApi {
 
-    protected static final List<String> EXCLUDES = [
-        'wait', 'notify', 'notifyAll', 'toString', 'hashCode', 'equals', 'getClass',
-        'getMetaClass', 'setMetaClass', 'getProperty', 'setProperty', 'invokeMethod'
-    ]
+    /**
+     * The names of methods that are part of the API object's own plumbing and must never be reported by
+     * {@link #getMethods()} / {@link #getExtendedMethods()} as part of the GORM surface of an entity.
+     */
+    static final List<String> EXCLUDES = [
+        'setProperty',
+        'getProperty',
+        'getMetaClass',
+        'setMetaClass',
+        'invokeMethod',
+        'getMethods',
+        'getExtendedMethods',
+        'wait',
+        'equals',
+        'toString',
+        'hashCode',
+        'getClass',
+        'notify',
+        'notifyAll',
+        'setTransactionManager',
+        'getTransactionManager',
+        'getQualifier',
+        'getGormPersistentEntity'
+    ].asImmutable()
 
     private static final Map<Class, List<Method>> METHODS_CACHE = new ConcurrentHashMap<>()
     private static final Map<Class, List<Method>> EXTENDED_METHODS_CACHE = new ConcurrentHashMap<>()
@@ -109,23 +128,17 @@ abstract class AbstractGormApi<D> extends AbstractDatastoreApi {
         if (currentQualifier != null && !ConnectionSource.DEFAULT.equals(currentQualifier) && !ConnectionSource.OLD_DEFAULT.equalsIgnoreCase(currentQualifier)) {
             if (isMultiTenantEntity && isMultiTenantCapable) {
                 // Determine whether the qualifier names a datasource connection or is a tenant ID.
-                // A datasource connection qualifier resolves via getDatastoreForConnection(); a tenant ID
-                // (e.g. from withTenant("t1")) does not. When it IS a connection qualifier we must not
-                // bind it as the tenant ID — doing so overwrites the tenant context set by the
-                // TenantResolver (e.g. SystemPropertyTenantResolver) and causes discriminator filters to
-                // match the connection name instead of the real tenant.
-                boolean isConnectionQualifier = false
-                if (ds instanceof MultipleConnectionSourceCapableDatastore) {
-                    try {
-                        Datastore resolved = ((MultipleConnectionSourceCapableDatastore) ds)
-                                .getDatastoreForConnection(currentQualifier)
-                        if (resolved != null) {
-                            isConnectionQualifier = true
-                        }
-                    } catch (Exception ignored) {
-                        // qualifier is not a known datasource name; treat it as a tenant ID below
-                    }
-                }
+                // A datasource connection qualifier is one of the datastore's configured connection
+                // sources; a tenant ID (e.g. from withTenant("t1")) is not. When it IS a connection
+                // qualifier we must not bind it as the tenant ID — doing so overwrites the tenant context
+                // set by the TenantResolver (e.g. SystemPropertyTenantResolver) and causes discriminator
+                // filters to match the connection name instead of the real tenant.
+                //
+                // This is checked against the declared connection source names rather than by probing
+                // getDatastoreForConnection(), which throws ConfigurationException for an unknown name:
+                // in DISCRIMINATOR mode the qualifier is always a tenant ID, so probing would fill in a
+                // stack trace on every single operation.
+                boolean isConnectionQualifier = ConnectionSourceNameResolver.isConnectionSourceName(ds, currentQualifier)
                 if (!isConnectionQualifier) {
                     // Qualifier is a tenant ID — bind it so the session and any discriminator filter
                     // both see the correct tenant for this operation.
@@ -150,6 +163,13 @@ abstract class AbstractGormApi<D> extends AbstractDatastoreApi {
     }
 
     /**
+     * The qualifier this API instance is bound to: either a connection source name, or a tenant id.
+     *
+     * Tenant ids are represented here — and throughout GORM's connection resolution, including
+     * {@code MultiTenantCapableDatastore.getDatastoreForTenantId} — by their {@code toString()}. A tenant id
+     * must therefore be uniquely representable as a string: two ids whose {@code toString()} collide (for
+     * example {@code 1L} and {@code "1"}) are the same qualifier and resolve to the same cached API.
+     *
      * @return The qualifier for this API instance
      */
     String getQualifier() {
diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/AbstractGormApiRegistry.groovy b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/AbstractGormApiRegistry.groovy
index 60dd5150c5..28175d56d6 100644
--- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/AbstractGormApiRegistry.groovy
+++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/AbstractGormApiRegistry.groovy
@@ -19,6 +19,7 @@
 package org.grails.datastore.gorm
 
 import groovy.transform.CompileStatic
+import groovy.util.logging.Slf4j
 import org.grails.datastore.mapping.core.Datastore
 import org.grails.datastore.mapping.core.connections.ConnectionSource
 import org.grails.datastore.mapping.core.connections.MultipleConnectionSourceCapableDatastore
@@ -27,6 +28,7 @@ import org.grails.datastore.mapping.multitenancy.MultiTenancySettings
 
 import java.util.concurrent.ConcurrentHashMap
 
+@Slf4j
 @CompileStatic
 abstract class AbstractGormApiRegistry<T extends AbstractDatastoreApi> {
 
@@ -140,24 +142,19 @@ abstract class AbstractGormApiRegistry<T extends AbstractDatastoreApi> {
         if (datastore == null) return
         Iterator<Map.Entry<String, T>> it = apis.entrySet().iterator()
         while (it.hasNext()) {
-            try {
-                if (it.next().value.getDatastore() == datastore) {
-                    it.remove()
-                }
-            } catch (Exception e) {
+            Map.Entry<String, T> entry = it.next()
+            if (belongsTo(entry.value, datastore, entry.key, null)) {
                 it.remove()
             }
         }
         Iterator<Map.Entry<String, Map<String, T>>> qit = qualifiedApis.entrySet().iterator()
         while (qit.hasNext()) {
-            Map<String, T> classQualifiedApis = qit.next().value
+            Map.Entry<String, Map<String, T>> classEntry = qit.next()
+            Map<String, T> classQualifiedApis = classEntry.value
             Iterator<Map.Entry<String, T>> eit = classQualifiedApis.entrySet().iterator()
             while (eit.hasNext()) {
-                try {
-                    if (eit.next().value.getDatastore() == datastore) {
-                        eit.remove()
-                    }
-                } catch (Exception e) {
+                Map.Entry<String, T> entry = eit.next()
+                if (belongsTo(entry.value, datastore, classEntry.key, entry.key)) {
                     eit.remove()
                 }
             }
@@ -167,6 +164,26 @@ abstract class AbstractGormApiRegistry<T extends AbstractDatastoreApi> {
         }
     }
 
+    /**
+     * Whether the given API is backed by the datastore being removed.
+     *
+     * An API whose datastore cannot be read is deliberately kept: {@code getDatastore()} routes through a
+     * {@link DatastoreResolver}, and a tenant-aware one throws whenever no tenant is bound on the calling
+     * thread. That is an ordinary transient condition, not evidence the API belongs to this datastore, so
+     * only a positive identity match evicts. Evicting on failure would permanently lose the entity's API
+     * registration and leave subsequent lookups either failing or falling back to a different datastore.
+     */
+    private boolean belongsTo(T api, Datastore datastore, String className, String qualifier) {
+        try {
+            return api.getDatastore() == datastore
+        }
+        catch (Exception e) {
+            log.warn('Could not resolve the datastore of the GORM API registered for [{}]; leaving it registered',
+                    qualifier == null ? className : "${className} (qualifier [${qualifier}])", e)
+            return false
+        }
+    }
+
     protected String className(Class entity) {
         return registry.normalizeEntityKey(entity)
     }
diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/ConnectionSourceNameResolver.groovy b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/ConnectionSourceNameResolver.groovy
index bad0e2eda0..c55cfbb2e3 100644
--- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/ConnectionSourceNameResolver.groovy
+++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/ConnectionSourceNameResolver.groovy
@@ -67,4 +67,40 @@ class ConnectionSourceNameResolver {
         }
         return ConnectionSource.DEFAULT
     }
+
+    /**
+     * Whether the given name is one of the datastore's configured connection sources.
+     *
+     * This answers "is this qualifier a datasource name or a tenant id?" without probing
+     * {@code getDatastoreForConnection}, which throws for an unknown name and would put stack-trace
+     * construction on the per-operation path of every discriminator-mode query.
+     *
+     * @param datastore The datastore to inspect
+     * @param name The candidate connection source name
+     * @return {@code true} if the datastore declares a connection source with that name
+     */
+    static boolean isConnectionSourceName(Object datastore, String name) {
+        if (name == null || !(datastore instanceof ConnectionSourcesProvider)) {
+            return false
+        }
+        ConnectionSources connectionSources = ((ConnectionSourcesProvider) datastore).connectionSources
+        if (connectionSources == null) {
+            return false
+        }
+        // Check the default first: it is the common case, needs no iteration, and a child datastore
+        // built for a single connection resolves its own name through it.
+        if (name == connectionSources.defaultConnectionSource?.name) {
+            return true
+        }
+        Iterable<ConnectionSource> allConnections = connectionSources.allConnectionSources
+        if (allConnections == null) {
+            return false
+        }
+        for (ConnectionSource connectionSource in allConnections) {
+            if (name == connectionSource.name) {
+                return true
+            }
+        }
+        return false
+    }
 }
diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormEnhancer.groovy b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormEnhancer.groovy
index d000ffbfb4..828be20cd7 100644
--- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormEnhancer.groovy
+++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormEnhancer.groovy
@@ -105,6 +105,7 @@ class GormEnhancer implements Closeable {
         String qualifier = ConnectionSourceNameResolver.resolveDefaultConnectionSourceName(datastore)
         registry.initializeDatastore(datastore, qualifier)
 
+        registerApiFactories()
         registerConstraints(datastore)
 
         for (entity in datastore.mappingContext.persistentEntities) {
@@ -112,6 +113,20 @@ class GormEnhancer implements Closeable {
         }
     }
 
+    /**
+     * Registers the {@link GormApiFactory} implementations this enhancer's datastore needs, before any
+     * entity is registered.
+     *
+     * Adapters that build specialised API objects (for example Mongo's {@code MongoStaticApi}, which
+     * {@code MongoEntity} casts to) override this. It is deliberately separate from
+     * {@link #registerConstraints(Datastore)}: factory registration has nothing to do with constraints,
+     * and hanging it off the constraints hook would break the moment a subclass reordered or skipped that
+     * step.
+     */
+    protected void registerApiFactories() {
+        // no-op by default: the registry falls back to DefaultGormApiFactory
+    }
+
     /**
      * Registers a new entity with the GORM enhancer
      *
@@ -121,13 +136,32 @@ class GormEnhancer implements Closeable {
         if (!entity.isExternal()) {
             // Delegate entity registration orchestration to the registry
             registry.registerEntity(entity, this)
-            
-            // Add dynamic methods to the class
-            addStaticMethods(entity)
-            addInstanceMethods(entity)
+
+            if (requiresMetaClassDispatch(entity)) {
+                addStaticMethods(entity)
+                addInstanceMethods(entity)
+            }
         }
     }
 
+    /**
+     * Whether the entity needs GORM's missing-method/missing-property dispatch installed on its
+     * {@link ExpandoMetaClass}.
+     *
+     * A {@link GormEntity} does not: the trait supplies {@code methodMissing}/{@code propertyMissing}
+     * and {@code GormEntityTransformation} compiles in {@code $static_methodMissing} /
+     * {@code $static_propertyMissing}, so dispatch already resolves without touching the metaclass.
+     * Installing closures over the top would shadow those compiled implementations — letting the two
+     * dispatch paths diverge — force an {@code ExpandoMetaClass} for every persistent entity at
+     * startup, and move dynamic-finder resolution onto a per-call metaclass miss.
+     *
+     * Classes that are not {@code GormEntity} — a Java entity, for instance — have no such dispatch of
+     * their own, so the metaclass is the only route available to them.
+     */
+    protected boolean requiresMetaClassDispatch(PersistentEntity entity) {
+        return !GormEntity.isAssignableFrom(entity.javaClass)
+    }
+
     /**
      * Obtain all of the qualifiers (typically the connection names) for the datastore and entity
      *
@@ -223,20 +257,13 @@ class GormEnhancer implements Closeable {
     @CompileDynamic
     protected void removeConstraints() {
         try {
-            def cls = Class.forName('org.grails.datastore.gorm.validation.constraints.eval.ConstraintsEvaluator', false, GormEnhancer.classLoader)
-            if (cls != null) {
-                def factory = datastore.mappingContext.mappingFactory
-                if (factory.hasProperty('entityContext')) {
-                    def constraintsEvaluator = factory.entityContext.getBean(cls)
-                    if (constraintsEvaluator != null) {
-                        for (entity in datastore.mappingContext.persistentEntities) {
-                            constraintsEvaluator.removeConstraints(entity.javaClass)
-                        }
-                    }
-                }
+            String className = 'org.apache.groovy.grails.validation.ConstrainedProperty'
+            ClassLoader classLoader = getClass().getClassLoader()
+            if (ClassUtils.isPresent(className, classLoader)) {
+                classLoader.loadClass(className).removeConstraint('unique')
             }
         } catch (Throwable e) {
-            log.debug("Not running in Grails environment, cannot de-register constraints. ${e.message}")
+            log.debug("Not running in Grails 2 environment, cannot de-register constraints. This exception can be safely ignored if you are not using Grails 2. ${e.message}", e)
         }
     }
 
@@ -257,7 +284,14 @@ class GormEnhancer implements Closeable {
     protected void addStaticMethods(PersistentEntity e) {
         def cls = e.javaClass
         ExpandoMetaClass mc = MetaClassUtils.getExpandoMetaClass(cls)
-        
+
+        if (mc.getStaticMetaMethod('methodMissing', [String, Object] as Class[]) != null) {
+            // An application or another plugin owns this class's static dispatch. Overwriting it here
+            // would silently break whatever it handles, so leave it in place.
+            log.debug('Not installing GORM static missing-method dispatch on {}: a handler is already installed', cls.name)
+            return
+        }
+
         mc.static.methodMissing = { String name, args ->
             def api = registry.findStaticApi(cls, null)
             try {
@@ -286,6 +320,12 @@ class GormEnhancer implements Closeable {
         Class cls = e.javaClass
         ExpandoMetaClass mc = MetaClassUtils.getExpandoMetaClass(cls)
 
+        if (mc.getMetaMethod('methodMissing', [String, Object] as Class[]) != null) {
+            // See addStaticMethods: defer to a handler someone else installed rather than clobbering it.
+            log.debug('Not installing GORM instance missing-method dispatch on {}: a handler is already installed', cls.name)
+            return
+        }
+
         mc.methodMissing = { String name, args ->
             def api = registry.findInstanceApi(cls, null)
             try {
diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormEntity.groovy b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormEntity.groovy
index b07d7da97e..a9f6792221 100644
--- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormEntity.groovy
+++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormEntity.groovy
@@ -545,7 +545,11 @@ trait GormEntity<D> implements GormValidateable, DirtyCheckable, GormEntityApi<D
     }
 
     /**
-     * Deletes all objects
+     * Deletes every persisted instance of this class.
+     *
+     * To delete a subset, build a query and delete through it — for example
+     * {@code Book.where { author == 'X' }.deleteAll()}.
+     *
      * @return The number of objects deleted
      */
     @Generated
@@ -554,8 +558,13 @@ trait GormEntity<D> implements GormValidateable, DirtyCheckable, GormEntityApi<D
     }
 
     /**
-     * Deletes all objects for the given arguments
-     * @param params The arguments
+     * Deletes every persisted instance of this class.
+     *
+     * {@code params} controls how the delete is executed; it does not narrow what is deleted. The
+     * supported argument is {@code flush}. To delete a subset, build a query and delete through it —
+     * for example {@code Book.where { author == 'X' }.deleteAll()}.
+     *
+     * @param params The arguments, e.g. {@code [flush: true]}
      * @return The number of objects deleted
      */
     @Generated
diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormRegistry.groovy b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormRegistry.groovy
index 9edef53bc6..00d85d5fa3 100644
--- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormRegistry.groovy
+++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormRegistry.groovy
@@ -20,7 +20,6 @@ package org.grails.datastore.gorm
 
 import java.util.concurrent.ConcurrentHashMap
 
-import groovy.transform.CompileDynamic
 import groovy.transform.CompileStatic
 import groovy.util.logging.Slf4j
 
@@ -236,9 +235,10 @@ class GormRegistry {
                 if (ds != null) {
                     return ds
                 }
-                if (ConnectionSource.DEFAULT.equals(normalizedQualifier) && !mappedDatastores.isEmpty()) {
-                    return mappedDatastores.values().iterator().next()
-                }
+                // No fallback to "whichever datastore this entity happens to be mapped to". The entity's
+                // unqualified routing is decided once, in declaration order, at registration time and stored
+                // under the DEFAULT key; picking an arbitrary map entry here would make an entity mapped only
+                // to non-default connections read and write a different database between runs.
                 Datastore qualifierDs = datastoresByQualifier.get(normalizedQualifier)
                 if (qualifierDs != null && qualifierDs.getMappingContext()?.getPersistentEntity(normalizedClassName) != null) {
                     return qualifierDs
@@ -774,18 +774,13 @@ class GormRegistry {
         }
 
         // If the entity does not explicitly include DEFAULT, route its unqualified (no-connection)
-        // operations. Real datastores manage a session per connection, so DEFAULT goes to the first
-        // explicit connection's datastore. A single-session mock (the in-memory datastore used by
-        // unit tests) keeps unqualified operations on the parent it manages — its connection
-        // children exist only for explicit access and have no harness-flushed session.
+        // operations to the datastore of its FIRST declared connection. A datastore manages a session
+        // per connection, so an entity mapped only to non-default connections belongs to one of them,
+        // and which one has to be decided here — deterministically, in declaration order — rather than
+        // left to an arbitrary map entry at lookup time.
         if (!qualifiers.collect { String it -> normalizeQualifier(it) }.contains(ConnectionSource.DEFAULT)) {
-            Datastore defaultConnectionDatastore = primaryDatastore
-            if (defaultDatastore instanceof MultipleConnectionSourceCapableDatastore &&
-                    !((MultipleConnectionSourceCapableDatastore) defaultDatastore).routesUnqualifiedToMappedConnection()) {
-                defaultConnectionDatastore = defaultDatastore
-            }
-            if (defaultConnectionDatastore != null) {
-                newEntityDatastores.put(ConnectionSource.DEFAULT, defaultConnectionDatastore)
+            if (primaryDatastore != null) {
+                newEntityDatastores.put(ConnectionSource.DEFAULT, primaryDatastore)
             }
         }
 
@@ -799,6 +794,10 @@ class GormRegistry {
 
     /**
      * Registers an entity-specific datastore override.
+     *
+     * If the entity has no unqualified routing yet, the first connection registered for it also becomes
+     * its unqualified route, so that an entity mapped only to non-default connections resolves to a
+     * datastore chosen at registration time rather than to whichever entry a map iterator yields.
      */
     void registerEntityDatastore(String className, String qualifier, Datastore datastore) {
         if (datastore != null) {
@@ -807,7 +806,9 @@ class GormRegistry {
                 return
             }
             String normalizedQualifier = normalizeQualifier(qualifier)
-            getInternalMap(entityDatastores, normalizedClassName).put(normalizedQualifier, datastore)
+            Map<String, Datastore> mappedDatastores = getInternalMap(entityDatastores, normalizedClassName)
+            mappedDatastores.put(normalizedQualifier, datastore)
+            mappedDatastores.putIfAbsent(ConnectionSource.DEFAULT, datastore)
         }
     }
 
@@ -844,7 +845,6 @@ class GormRegistry {
      * Create a DatastoreResolver for a class and optional qualifier.
      */
     DatastoreResolver createClassDatastoreResolver(Class cls, String qualifier = ConnectionSource.DEFAULT) {
-        String normalizedClassName = normalizeEntityKey(cls)
         String normalizedQualifier = normalizeQualifier(qualifier)
         return new DatastoreResolver() {
             @Override
@@ -855,31 +855,32 @@ class GormRegistry {
     }
 
     /**
-     * Create a GormStaticApi instance.
-     * Uses a bound resolver (always returns the given datastore) at registration time so that
-     * tenant-resolving DatastoreResolvers are not invoked before any tenant context is active.
+     * Create a GormStaticApi bound to the given datastore.
+     *
+     * The API is bound to the concrete datastore rather than to a resolving {@link DatastoreResolver}, so
+     * that tenant-resolving lookups are not invoked at registration time, before any tenant context exists.
      */
-    GormStaticApi createStaticApi(Class cls, Datastore datastore, DatastoreResolver resolver, String qualifier) {
+    GormStaticApi createStaticApi(Class cls, Datastore datastore, String qualifier) {
         DatastoreResolver boundResolver = { datastore } as DatastoreResolver
         return getApiFactory(datastore).createStaticApi(cls, datastore.mappingContext, boundResolver, qualifier, this)
     }
 
     /**
-     * Create a GormInstanceApi instance.
-     * Uses a bound resolver (always returns the given datastore) at registration time so that
-     * tenant-resolving DatastoreResolvers are not invoked before any tenant context is active.
+     * Create a GormInstanceApi bound to the given datastore.
+     *
+     * @see #createStaticApi(Class, Datastore, String) for why the binding is to the datastore itself
      */
-    GormInstanceApi createInstanceApi(Class cls, Datastore datastore, DatastoreResolver resolver, boolean failOnError, boolean markDirty) {
+    GormInstanceApi createInstanceApi(Class cls, Datastore datastore, boolean failOnError, boolean markDirty) {
         DatastoreResolver boundResolver = { datastore } as DatastoreResolver
         return getApiFactory(datastore).createInstanceApi(cls, datastore.mappingContext, boundResolver, this, failOnError, markDirty)
     }
 
     /**
-     * Create a GormValidationApi instance.
-     * Uses a bound resolver (always returns the given datastore) at registration time so that
-     * tenant-resolving DatastoreResolvers are not invoked before any tenant context is active.
+     * Create a GormValidationApi bound to the given datastore.
+     *
+     * @see #createStaticApi(Class, Datastore, String) for why the binding is to the datastore itself
      */
-    GormValidationApi createValidationApi(Class cls, Datastore datastore, DatastoreResolver resolver) {
+    GormValidationApi createValidationApi(Class cls, Datastore datastore) {
         DatastoreResolver boundResolver = { datastore } as DatastoreResolver
         return getApiFactory(datastore).createValidationApi(cls, datastore.mappingContext, boundResolver, this)
     }
@@ -893,46 +894,19 @@ class GormRegistry {
     }
 
     /**
-     * Register constraints for all entities in a datastore.
-     * Delegates to the ConstraintsEvaluator if available in the mapping context.
+     * Initialize a datastore with GORM, registering it under its default connection source qualifier
+     * and by its type.
      *
-     * @param datastore The datastore containing the entities
-     */
-    @CompileDynamic
-    void registerConstraints(Object datastore) {
-        if (datastore == null) return
-        
-        try {
-            def context = ((Datastore) datastore).mappingContext
-            def factory = context.mappingFactory
-            if (factory.hasProperty('entityContext')) {
-                def constraintsEvaluator = factory.entityContext.getBean(Class.forName('org.grails.datastore.gorm.validation.constraints.eval.ConstraintsEvaluator', false, GormRegistry.classLoader))
-                if (constraintsEvaluator != null) {
-                    for (entity in context.persistentEntities) {
-                        constraintsEvaluator.evaluate(entity.javaClass)
-                    }
-                }
-            }
-        } catch (Throwable e) {
-            log.debug('Could not register GORM constraints: {}', e.message)
-        }
-    }
-
-    /**
-     * Initialize a datastore with GORM.
-     * Orchestrates constraint registration and datastore registration.
-     * Note: Entity-specific registration is still handled by GormEnhancer.
+     * Constraint registration is not done here: {@link GormEnhancer#registerConstraints(Datastore)} is
+     * the single path for that, and duplicating it would mean an extra O(entities) pass on every
+     * bootstrap. Entity-specific registration is handled by {@link GormEnhancer}.
      *
      * @param datastore The datastore to initialize
      * @param defaultQualifier The default connection source qualifier
      */
     void initializeDatastore(Object datastore, String defaultQualifier) {
         if (datastore == null) return
-        
-        // Register constraints
-        registerConstraints(datastore)
-        
-        // Register datastore with default qualifier
+
         Datastore typedDatastore = (Datastore) datastore
         registerDatastore(defaultQualifier, typedDatastore)
         datastoresByType.put(typedDatastore.getClass(), typedDatastore)
@@ -957,12 +931,11 @@ class GormRegistry {
 
         // Always (re)register API singletons so classloader or datastore changes do not leave stale API instances.
         final Class cls = persistentEntity.javaClass
-        DatastoreResolver resolver = createClassDatastoreResolver(cls)
         Datastore datastore = enhancer.datastore
 
-        GormStaticApi staticApi = createStaticApi(cls, datastore, resolver, ConnectionSource.DEFAULT)
-        GormInstanceApi instanceApi = createInstanceApi(cls, datastore, resolver, enhancer.failOnError, enhancer.markDirty)
-        GormValidationApi validationApi = createValidationApi(cls, datastore, resolver)
+        GormStaticApi staticApi = createStaticApi(cls, datastore, ConnectionSource.DEFAULT)
+        GormInstanceApi instanceApi = createInstanceApi(cls, datastore, enhancer.failOnError, enhancer.markDirty)
+        GormValidationApi validationApi = createValidationApi(cls, datastore)
 
         registerEntityApis(className, staticApi, instanceApi, validationApi)
 
diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormStaticApi.groovy b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormStaticApi.groovy
index f92ba34baa..83e9b42712 100644
--- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormStaticApi.groovy
+++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormStaticApi.groovy
@@ -45,6 +45,7 @@ import org.grails.datastore.mapping.core.connections.ConnectionSources
 import org.grails.datastore.mapping.core.connections.ConnectionSourcesProvider
 import org.grails.datastore.mapping.model.MappingContext
 import org.grails.datastore.mapping.model.PersistentEntity
+import org.grails.datastore.mapping.multitenancy.MultiTenancySettings
 import org.grails.datastore.mapping.multitenancy.MultiTenantCapableDatastore
 import org.grails.datastore.mapping.query.api.BuildableCriteria
 import org.grails.datastore.mapping.transactions.TransactionCapableDatastore
@@ -98,6 +99,28 @@ class GormStaticApi<D> extends AbstractGormApi<D> implements GormAllOperations<D
         return null
     }
 
+    /**
+     * The multi-tenancy mode of the datastore this API currently resolves to.
+     *
+     * Derived on each call rather than captured at construction: an API is no longer bound to a single
+     * datastore for its lifetime, so a captured value could describe a datastore this API is no longer
+     * operating against.
+     *
+     * @return The multi-tenancy mode, or {@link MultiTenancySettings.MultiTenancyMode#NONE} when the
+     *         datastore declares none
+     */
+    MultiTenancySettings.MultiTenancyMode getMultiTenancyMode() {
+        Datastore ds = getDatastore()
+        if (ds instanceof MultiTenantCapableDatastore) {
+            return ((MultiTenantCapableDatastore) ds).getMultiTenancyMode() ?: MultiTenancySettings.MultiTenancyMode.NONE
+        }
+        if (ds instanceof ConnectionSourcesProvider) {
+            ConnectionSources connectionSources = ((ConnectionSourcesProvider) ds).getConnectionSources()
+            return connectionSources?.defaultConnectionSource?.settings?.multiTenancy?.mode ?: MultiTenancySettings.MultiTenancyMode.NONE
+        }
+        return MultiTenancySettings.MultiTenancyMode.NONE
+    }
+
     @Override
     protected <T1> T1 executeQualified(String qualifier, SessionCallback<T1> callback) {
         GormStaticApi<D> qualifiedApi = registry.findStaticApi(persistentClass, qualifier)
@@ -387,14 +410,21 @@ class GormStaticApi<D> extends AbstractGormApi<D> implements GormAllOperations<D
     @Override
     D first(Map params) {
         Map queryParams = new LinkedHashMap(params ?: [:])
-        queryParams.max = 1
-        queryParams.order = 'asc'
         if (!queryParams.containsKey('sort')) {
-            String idPropertyName = getGormPersistentEntity()?.identity?.name
+            String idPropertyName = singleIdentityPropertyName()
             if (idPropertyName) {
                 queryParams.sort = idPropertyName
             }
         }
+        if (!queryParams.containsKey('sort')) {
+            // Nothing to order by — an entity with a composite key has no single identity property.
+            // Issuing `order` with no `sort` and a limit of 1 would return an arbitrary row, so read
+            // the result in its natural order and take the first element.
+            List<D> unordered = list(queryParams)
+            return unordered ? unordered[0] : null
+        }
+        queryParams.max = 1
+        queryParams.order = 'asc'
         List<D> resultList = list(queryParams)
         resultList ? resultList[0] : null
     }
@@ -412,18 +442,37 @@ class GormStaticApi<D> extends AbstractGormApi<D> implements GormAllOperations<D
     @Override
     D last(Map params) {
         Map queryParams = new LinkedHashMap(params ?: [:])
-        queryParams.max = 1
-        queryParams.order = 'desc'
         if (!queryParams.containsKey('sort')) {
-            String idPropertyName = getGormPersistentEntity()?.identity?.name
+            String idPropertyName = singleIdentityPropertyName()
             if (idPropertyName) {
                 queryParams.sort = idPropertyName
             }
         }
+        if (!queryParams.containsKey('sort')) {
+            // See first(Map): with no property to order by, read the natural order and take the last
+            // element rather than reversing an unordered query.
+            List<D> unordered = list(queryParams)
+            return unordered ? unordered[-1] : null
+        }
+        queryParams.max = 1
+        queryParams.order = 'desc'
         List<D> resultList = list(queryParams)
         resultList ? resultList[0] : null
     }
 
+    /**
+     * @return The name of the entity's single identity property, or {@code null} when it has none —
+     *         an entity mapped with a composite key has a {@code compositeIdentity} instead, whose
+     *         constituent properties are not a meaningful substitute for identity ordering.
+     */
+    private String singleIdentityPropertyName() {
+        PersistentEntity entity = getGormPersistentEntity()
+        if (entity == null || entity.getCompositeIdentity()) {
+            return null
+        }
+        return entity.identity?.name
+    }
+
     @Override
     BuildableCriteria createCriteria() {
         execute({ Session session ->
@@ -477,14 +526,18 @@ class GormStaticApi<D> extends AbstractGormApi<D> implements GormAllOperations<D
 
     @Override
     Number deleteAll() {
-        execute({ Session session ->
-            session.deleteAll(new DetachedCriteria(persistentClass))
-        } as SessionCallback<Number>)
+        deleteAll(Collections.emptyMap())
     }
 
     @Override
     Number deleteAll(Map params) {
-        deleteAll()
+        execute({ Session session ->
+            Number deleted = session.deleteAll(new DetachedCriteria(persistentClass))
+            if (params?.flush) {
+                session.flush()
+            }
+            return deleted
+        } as SessionCallback<Number>)
     }
 
     @Override
diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/multitenancy/TenantDelegatingGormOperations.groovy b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/multitenancy/TenantDelegatingGormOperations.groovy
index e5bfd22142..3c64be9695 100644
--- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/multitenancy/TenantDelegatingGormOperations.groovy
+++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/multitenancy/TenantDelegatingGormOperations.groovy
@@ -29,6 +29,7 @@ import grails.gorm.multitenancy.Tenants
 import org.grails.datastore.gorm.finders.FinderMethod
 import org.grails.datastore.mapping.core.Datastore
 import org.grails.datastore.mapping.model.PersistentEntity
+import org.grails.datastore.mapping.multitenancy.MultiTenantCapableDatastore
 import org.grails.datastore.mapping.query.api.BuildableCriteria
 import org.grails.datastore.mapping.query.api.Criteria
 
@@ -53,119 +54,119 @@ class TenantDelegatingGormOperations<D> implements GormAllOperations<D> {
 
     @Override
     def propertyMissing(D instance, String name) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.propertyMissing(instance, name)
         }
     }
 
     @Override
     boolean instanceOf(D instance, Class cls) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.instanceOf(instance, cls)
         }
     }
 
     @Override
     D lock(D instance) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.lock(instance)
         }
     }
 
     @Override
     def <T> T mutex(D instance, Closure<T> callable) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.mutex(instance, callable)
         }
     }
 
     @Override
     D refresh(D instance) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.refresh(instance)
         }
     }
 
     @Override
     D save(D instance) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.save(instance)
         }
     }
 
     @Override
     D insert(D instance) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.insert(instance)
         }
     }
 
     @Override
     D insert(D instance, Map params) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.insert(instance, params)
         }
     }
 
     @Override
     D merge(D instance, Map params) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.merge(instance, params)
         }
     }
 
     @Override
     D save(D instance, boolean validate) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.save(instance, validate)
         }
     }
 
     @Override
     D save(D instance, Map params) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.save(instance, params)
         }
     }
 
     @Override
     Serializable ident(D instance) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.ident(instance)
         }
     }
 
     @Override
     D attach(D instance) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.attach(instance)
         }
     }
 
     @Override
     boolean isAttached(D instance) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.isAttached(instance)
         }
     }
 
     @Override
     void discard(D instance) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.discard(instance)
         }
     }
 
     @Override
     void delete(D instance) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.delete(instance)
         }
     }
 
     @Override
     void delete(D instance, Map params) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.delete(instance, params)
         }
     }
@@ -182,98 +183,98 @@ class TenantDelegatingGormOperations<D> implements GormAllOperations<D> {
 
     @Override
     DetachedCriteria<D> where(Closure callable) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.where(callable)
         }
     }
 
     @Override
     DetachedCriteria<D> whereLazy(Closure callable) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.whereLazy(callable)
         }
     }
 
     @Override
     DetachedCriteria<D> whereAny(Closure callable) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.whereAny(callable)
         }
     }
 
     @Override
     List<D> findAll(Closure callable) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.findAll(callable)
         }
     }
 
     @Override
     List<D> findAll(Map args, Closure callable) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.findAll(args, callable)
         }
     }
 
     @Override
     D find(Closure callable) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.find(callable)
         }
     }
 
     @Override
     List<Serializable> saveAll(Object... objectsToSave) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.saveAll(objectsToSave)
         }
     }
 
     @Override
     List<Serializable> saveAll(Iterable<?> objectsToSave) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.saveAll(objectsToSave)
         }
     }
 
     @Override
     Number deleteAll() {
-        (Number)Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        (Number)withTenantId() {
             allOperations.deleteAll()
         }
     }
 
     @Override
     Number deleteAll(Map params) {
-        (Number)Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        (Number)withTenantId() {
             allOperations.deleteAll(params)
         }
     }
 
     @Override
     void deleteAll(Object... objectsToDelete) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.deleteAll(objectsToDelete)
         }
     }
 
     @Override
     void deleteAll(Map params, Object... objectsToDelete) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.deleteAll(params, objectsToDelete)
         }
     }
 
     @Override
     void deleteAll(Iterable objectsToDelete) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.deleteAll(objectsToDelete)
         }
     }
 
     @Override
     void deleteAll(Map params, Iterable objectsToDelete) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.deleteAll(params, objectsToDelete)
         }
     }
@@ -285,497 +286,497 @@ class TenantDelegatingGormOperations<D> implements GormAllOperations<D> {
 
     @Override
     D get(Serializable id) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.get(id)
         }
     }
 
     @Override
     D read(Serializable id) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.read(id)
         }
     }
 
     @Override
     D load(Serializable id) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.load(id)
         }
     }
 
     @Override
     D proxy(Serializable id) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.proxy(id)
         }
     }
 
     @Override
     List<D> getAll(Iterable<Serializable> ids) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.getAll(ids)
         }
     }
 
     @Override
     List<D> getAll(Serializable... ids) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.getAll(ids)
         }
     }
 
     @Override
     List<D> getAll() {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.getAll()
         }
     }
 
     @Override
     BuildableCriteria createCriteria() {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.createCriteria()
         }
     }
 
     @Override
     def <T> T withCriteria(@DelegatesTo(Criteria) Closure<T> callable) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.withCriteria(callable)
         }
     }
 
     @Override
     def <T> T withCriteria(Map builderArgs, @DelegatesTo(Criteria) Closure callable) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.withCriteria(builderArgs, callable)
         }
     }
 
     @Override
     D lock(Serializable id) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.lock(id)
         }
     }
 
     @Override
     D merge(D d) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.merge(d)
         }
     }
 
     @Override
     Integer count() {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.count()
         }
     }
 
     @Override
     Integer getCount() {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.getCount()
         }
     }
 
     @Override
     boolean exists(Serializable id) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.exists(id)
         }
     }
 
     @Override
     List<D> list(Map params) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.list(params)
         }
     }
 
     @Override
     List<D> list() {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.list()
         }
     }
 
     @Override
     List<D> findAll(Map params) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.findAll(params)
         }
     }
 
     @Override
     List<D> findAll() {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.findAll()
         }
     }
 
     @Override
     List<D> findAll(D example) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.findAll(example)
         }
     }
 
     @Override
     List<D> findAll(D example, Map args) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.findAll(example, args)
         }
     }
 
     @Override
     D first() {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.first()
         }
     }
 
     @Override
     D first(String propertyName) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.first(propertyName)
         }
     }
 
     @Override
     D first(Map queryParams) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.first(queryParams)
         }
     }
 
     @Override
     D last() {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.last()
         }
     }
 
     @Override
     D last(String propertyName) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.last(propertyName)
         }
     }
 
     @Override
     Object methodMissing(String methodName, Object arg) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.methodMissing(methodName, arg)
         }
     }
 
     @Override
     Object propertyMissing(String property) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.propertyMissing(property)
         }
     }
 
     @Override
     void propertyMissing(String property, Object value) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.propertyMissing(property, value)
         }
     }
 
     @Override
     D last(Map queryParams) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.last(queryParams)
         }
     }
 
     @Override
     List<D> findAllWhere(Map queryMap) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.findAllWhere(queryMap)
         }
     }
 
     @Override
     List<D> findAllWhere(Map queryMap, Map args) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.findAllWhere(queryMap, args)
         }
     }
 
     @Override
     D find(D example) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.find(example)
         }
     }
 
     @Override
     D find(D example, Map args) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.find(example, args)
         }
     }
 
     @Override
     D findWhere(Map queryMap) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.findWhere(queryMap)
         }
     }
 
     @Override
     D findWhere(Map queryMap, Map args) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.findWhere(queryMap, args)
         }
     }
 
     @Override
     D findOrCreateWhere(Map queryMap) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.findOrCreateWhere(queryMap)
         }
     }
 
     @Override
     D findOrSaveWhere(Map queryMap) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.findOrSaveWhere(queryMap)
         }
     }
 
     @Override
     def <T> T withSession(Closure<T> callable) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.withSession(callable)
         }
     }
 
     @Override
     def <T> T withDatastoreSession(Closure<T> callable) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.withDatastoreSession(callable)
         }
     }
 
     @Override
     def <T> T withTransaction(Closure<T> callable) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.withTransaction(callable)
         }
     }
 
     @Override
     def <T> T withNewTransaction(Closure<T> callable) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.withNewTransaction(callable)
         }
     }
 
     @Override
     def <T> T withTransaction(Map transactionProperties, Closure<T> callable) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.withTransaction(transactionProperties, callable)
         }
     }
 
     @Override
     def <T> T withNewTransaction(Map transactionProperties, Closure<T> callable) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.withNewTransaction(transactionProperties, callable)
         }
     }
 
     @Override
     def <T> T withTransaction(TransactionDefinition definition, Closure<T> callable) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.withTransaction(definition, callable)
         }
     }
 
     @Override
     def <T> T withNewSession(Closure<T> callable) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.withNewSession(callable)
         }
     }
 
     @Override
     def <T> T withStatelessSession(Closure<T> callable) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.withStatelessSession(callable)
         }
     }
 
     @Override
     List executeQuery(CharSequence query) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.executeQuery(query)
         }
     }
 
     @Override
     List executeQuery(CharSequence query, Map args) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.executeQuery(query, args)
         }
     }
 
     @Override
     List executeQuery(CharSequence query, Map params, Map args) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.executeQuery(query, params, args)
         }
     }
 
     @Override
     List executeQuery(CharSequence query, Collection params) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.executeQuery(query, params)
         }
     }
 
     @Override
     List executeQuery(CharSequence query, Object... params) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.executeQuery(query, params)
         }
     }
 
     @Override
     List executeQuery(CharSequence query, Collection params, Map args) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.executeQuery(query, params, args)
         }
     }
 
     @Override
     Integer executeUpdate(CharSequence query) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.executeUpdate(query)
         }
     }
 
     @Override
     Integer executeUpdate(CharSequence query, Map args) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.executeUpdate(query, args)
         }
     }
 
     @Override
     Integer executeUpdate(CharSequence query, Map params, Map args) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.executeUpdate(query, params, args)
         }
     }
 
     @Override
     Integer executeUpdate(CharSequence query, Collection params) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.executeUpdate(query, params)
         }
     }
 
     @Override
     Integer executeUpdate(CharSequence query, Object... params) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.executeUpdate(query, params)
         }
     }
 
     @Override
     Integer executeUpdate(CharSequence query, Collection params, Map args) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.executeUpdate(query, params, args)
         }
     }
 
     @Override
     D find(CharSequence query) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.find(query)
         }
     }
 
     @Override
     D find(CharSequence query, Map params) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.find(query, params)
         }
     }
 
     @Override
     D find(CharSequence query, Map params, Map args) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.find(query, params, args)
         }
     }
 
     @Override
     D find(CharSequence query, Collection params) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.find(query, params)
         }
     }
 
     @Override
     D find(CharSequence query, Object[] params) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.find(query, params)
         }
     }
 
     @Override
     D find(CharSequence query, Collection params, Map args) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.find(query, params, args)
         }
     }
 
     @Override
     List<D> findAll(CharSequence query) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.findAll(query)
         }
     }
 
     @Override
     List<D> findAll(CharSequence query, Map params) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.findAll(query, params)
         }
     }
 
     @Override
     List<D> findAll(CharSequence query, Map params, Map args) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.findAll(query, params, args)
         }
     }
 
     @Override
     List<D> findAll(CharSequence query, Collection params) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.findAll(query, params)
         }
     }
 
     @Override
     List<D> findAll(CharSequence query, Object[] params) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.findAll(query, params)
         }
     }
 
     @Override
     List<D> findAll(CharSequence query, Collection params, Map args) {
-        Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+        withTenantId() {
             allOperations.findAll(query, params, args)
         }
     }
@@ -794,4 +795,18 @@ class TenantDelegatingGormOperations<D> implements GormAllOperations<D> {
     GormAllOperations<D> withTenant(Serializable tenantId) {
         allOperations.withTenant(tenantId)
     }
+
+    /**
+     * Runs the delegated call with this wrapper's tenant bound.
+     *
+     * Uses the datastore instance this wrapper was constructed with rather than looking one up by
+     * its class: the instance is already known, and a type lookup would fail for a datastore that is
+     * not registered by type, or resolve to a sibling when several share one type.
+     */
+    private <T> T withTenantId(Closure<T> callable) {
+        if (datastore instanceof MultiTenantCapableDatastore) {
+            return Tenants.withId((MultiTenantCapableDatastore) datastore, tenantId, callable)
+        }
+        return Tenants.withId((Class<? extends Datastore>) datastore.getClass(), tenantId, callable)
+    }
 }
diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/services/transform/ServiceTransformation.groovy b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/services/transform/ServiceTransformation.groovy
index ba6c3806dc..15ea4f7ce7 100644
--- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/services/transform/ServiceTransformation.groovy
+++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/services/transform/ServiceTransformation.groovy
@@ -243,7 +243,7 @@ class ServiceTransformation extends AbstractTraitApplyingGormASTTransformation i
 
             addDatastoreMethods(impl, datastoreType, targetDomainClass, propertiesFields)
 
-            copyAnnotations(classNode, impl)
+            copyAnnotations(classNode, impl, null, null, true)
             AnnotationNode serviceAnnotation = findAnnotation((AnnotatedNode)impl, Service)
             if (serviceAnnotation != null && serviceAnnotation.getMember('name') == null) {
 
@@ -326,7 +326,9 @@ class ServiceTransformation extends AbstractTraitApplyingGormASTTransformation i
 
                         // Copy annotations after implement() so that @Query GString values are
                         // already replaced with constX(IMPLEMENTED) before being copied to methodImpl.
-                        copyAnnotations(method, methodImpl)
+                        // Skip types methodImpl already carries: @NotTransactional above and anything
+                        // implement() added would otherwise be duplicated, which the compiler rejects.
+                        copyAnnotations(method, methodImpl, null, null, true)
 
                         if (!Modifier.isProtected(method.modifiers)) {
                             if (!TransactionalTransform.hasTransactionalAnnotation(methodImpl)) {
diff --git a/grails-datamapping-core/src/test/groovy/grails/gorm/multitenancy/CurrentTenantHolderSpec.groovy b/grails-datamapping-core/src/test/groovy/grails/gorm/multitenancy/CurrentTenantHolderSpec.groovy
index 73a5222341..b994a5bc7e 100644
--- a/grails-datamapping-core/src/test/groovy/grails/gorm/multitenancy/CurrentTenantHolderSpec.groovy
+++ b/grails-datamapping-core/src/test/groovy/grails/gorm/multitenancy/CurrentTenantHolderSpec.groovy
@@ -20,6 +20,7 @@ package grails.gorm.multitenancy
 
 import org.grails.datastore.mapping.core.Datastore
 import org.grails.datastore.mapping.core.connections.ConnectionSource
+import org.grails.datastore.mapping.multitenancy.exceptions.TenantException
 import spock.lang.Specification
 
 /**
@@ -49,6 +50,57 @@ class CurrentTenantHolderSpec extends Specification {
         }
     }
 
+    void "get() returns the tenant when several datastores are bound to the same one"() {
+        given: "the shape Tenants.withTenant produces — the same tenant keyed by both instance and type"
+        def datastore = Mock(Datastore)
+        CurrentTenantHolder.set(datastore.class, 'tenant1')
+        CurrentTenantHolder.set(datastore, 'tenant1')
+
+        expect: "the answer is unambiguous, so it is returned"
+        CurrentTenantHolder.get() == 'tenant1'
+
+        cleanup:
+        CurrentTenantHolder.remove(datastore)
+        CurrentTenantHolder.remove(datastore.class)
+    }
+
+    void "get() throws rather than guessing when different datastores hold different tenants"() {
+        given:
+        def datastoreA = Mock(Datastore)
+        def datastoreB = Mock(Datastore)
+        CurrentTenantHolder.set(datastoreA, 'tenantA')
+        CurrentTenantHolder.set(datastoreB, 'tenantB')
+
+        when:
+        CurrentTenantHolder.get()
+
+        then: "returning an arbitrary one of the two is how cross-tenant data leaks happen"
+        TenantException e = thrown()
+        e.message.contains('tenantA')
+        e.message.contains('tenantB')
+        e.message.contains('get(Datastore)')
+
+        cleanup:
+        CurrentTenantHolder.remove(datastoreA)
+        CurrentTenantHolder.remove(datastoreB)
+    }
+
+    void "get(Datastore) still answers unambiguously when different datastores hold different tenants"() {
+        given:
+        def datastoreA = Mock(Datastore)
+        def datastoreB = Mock(Datastore)
+        CurrentTenantHolder.set(datastoreA, 'tenantA')
+        CurrentTenantHolder.set(datastoreB, 'tenantB')
+
+        expect:
+        CurrentTenantHolder.get(datastoreA) == 'tenantA'
+        CurrentTenantHolder.get(datastoreB) == 'tenantB'
+
+        cleanup:
+        CurrentTenantHolder.remove(datastoreA)
+        CurrentTenantHolder.remove(datastoreB)
+    }
+
     void "get(Datastore) returns null when nothing is bound for that datastore"() {
         given:
         def datastore = Mock(Datastore)
diff --git a/grails-datamapping-core/src/test/groovy/grails/gorm/multitenancy/TenantsSpec.groovy b/grails-datamapping-core/src/test/groovy/grails/gorm/multitenancy/TenantsSpec.groovy
index 10197c44fd..3b76565bf0 100644
--- a/grails-datamapping-core/src/test/groovy/grails/gorm/multitenancy/TenantsSpec.groovy
+++ b/grails-datamapping-core/src/test/groovy/grails/gorm/multitenancy/TenantsSpec.groovy
@@ -22,6 +22,7 @@ import org.grails.datastore.mapping.core.Datastore
 import org.grails.datastore.mapping.core.Session
 import org.grails.datastore.mapping.core.connections.ConnectionSource
 import org.grails.datastore.mapping.core.connections.ConnectionSources
+import org.grails.datastore.mapping.core.exceptions.ConfigurationException
 import org.grails.datastore.mapping.multitenancy.AllTenantsResolver
 import org.grails.datastore.mapping.multitenancy.MultiTenancySettings
 import org.grails.datastore.mapping.multitenancy.MultiTenantCapableDatastore
@@ -284,7 +285,30 @@ class TenantsSpec extends Specification {
         thrown(UnsupportedOperationException)
     }
 
-    void "withId(domainClass, tenantId, callable) resolves the datastore for that domain class before delegating"() {
+    void "withId(datastoreClass, tenantId, callable) resolves the datastore by type before delegating"() {
+        given:
+        def mtds = Stub(MixedDatastore) {
+            getMultiTenancyMode() >> MultiTenancySettings.MultiTenancyMode.DATABASE
+            withNewSession(_, _) >> { Serializable tid, Closure c -> c.call(Stub(Session)) }
+        }
+        useLocator(Stub(Datastore), mtds, Stub(Datastore))
+
+        expect: "the datastore type, not the domain class, decides which datastore is used"
+        Tenants.withId(MixedDatastore, 'tenant1') { -> 'ran' } == 'ran'
+    }
+
+    void "withId(datastoreClass, tenantId, callable) throws for a datastore that does not support multi-tenancy"() {
+        given:
+        useLocator(Stub(Datastore), Stub(Datastore))
+
+        when:
+        Tenants.withId(MixedDatastore, 'tenant1') { }
+
+        then:
+        thrown(UnsupportedOperationException)
+    }
+
+    void "withIdForDomain(domainClass, tenantId, callable) resolves the datastore for that domain class before delegating"() {
         given:
         def mtds = Stub(MixedDatastore) {
             getMultiTenancyMode() >> MultiTenancySettings.MultiTenancyMode.DATABASE
@@ -293,27 +317,41 @@ class TenantsSpec extends Specification {
         useLocator(Stub(Datastore), null, mtds)
 
         expect:
-        Tenants.withId(String, 'tenant1') { -> 'ran' } == 'ran'
+        Tenants.withIdForDomain(String, 'tenant1') { -> 'ran' } == 'ran'
     }
 
-    void "withId(domainClass, tenantId, callable) throws for a datastore that does not support multi-tenancy"() {
+    void "withIdForDomain(domainClass, tenantId, callable) throws for a datastore that does not support multi-tenancy"() {
         given:
         useLocator(Stub(Datastore), null, Stub(Datastore))
 
         when:
-        Tenants.withId(String, 'tenant1') { }
+        Tenants.withIdForDomain(String, 'tenant1') { }
 
         then:
         thrown(UnsupportedOperationException)
     }
 
-    void "withTenant(domainClass, tenantId, callable) binds the tenant for the datastore resolved for that domain class"() {
+    void "withTenantForDomain(domainClass, tenantId, callable) binds the tenant for the datastore resolved for that domain class"() {
         given:
         def ds = Stub(Datastore)
         useLocator(Stub(Datastore), null, ds)
 
         expect:
-        Tenants.withTenant(String, 'tenant1') { CurrentTenantHolder.get(ds) } == 'tenant1'
+        Tenants.withTenantForDomain(String, 'tenant1') { CurrentTenantHolder.get(ds) } == 'tenant1'
+    }
+
+    void "withTenantForDomain(domainClass, tenantId, callable) binds the tenant without opening a session"() {
+        given: "a datastore that fails the spec if any session is opened"
+        def ds = Mock(Datastore)
+        useLocator(Stub(Datastore), null, ds)
+
+        when:
+        def result = Tenants.withTenantForDomain(String, 'tenant1') { CurrentTenantHolder.get(ds) }
+
+        then: "as the javadoc states, the caller owns the session — none is created here"
+        result == 'tenant1'
+        0 * ds.connect()
+        0 * ds.connect(_)
     }
 
     void "withoutId(MultiTenantCapableDatastore, callable) runs a 0-arg closure directly for a shared-connection mode"() {
@@ -402,19 +440,34 @@ class TenantsSpec extends Specification {
         thrown(IllegalArgumentException)
     }
 
-    void "withId(MultiTenantCapableDatastore, tenantId, callable) swallows getDatastoreForTenantId failures and falls through"() {
-        given:
+    void "withId(MultiTenantCapableDatastore, tenantId, callable) falls through when the tenant has no datastore yet"() {
+        given: "a misconfiguration the probe cannot resolve — an unknown tenant or a missing connection"
         def session = Stub(Session)
         def mtds = Stub(MixedDatastore) {
             getMultiTenancyMode() >> MultiTenancySettings.MultiTenancyMode.DATABASE
-            getDatastoreForTenantId('tenant1') >> { throw new IllegalStateException('boom') }
+            getDatastoreForTenantId('tenant1') >> { throw new ConfigurationException('DataSource not found') }
             withNewSession('tenant1', _) >> { String tid, Closure body -> body.call(session) }
         }
 
-        expect:
+        expect: "the session-creating path is still taken, which resolves the tenant again and reports any real failure"
         Tenants.withId(mtds, 'tenant1') { -> 'ran' } == 'ran'
     }
 
+    void "withId(MultiTenantCapableDatastore, tenantId, callable) propagates unexpected failures from getDatastoreForTenantId"() {
+        given:
+        def mtds = Stub(MixedDatastore) {
+            getMultiTenancyMode() >> MultiTenancySettings.MultiTenancyMode.DATABASE
+            getDatastoreForTenantId('tenant1') >> { throw new IllegalStateException('boom') }
+        }
+
+        when:
+        Tenants.withId(mtds, 'tenant1') { -> 'ran' }
+
+        then: "only unknown-tenant/missing-connection failures are tolerated; anything else surfaces to the caller"
+        IllegalStateException e = thrown()
+        e.message == 'boom'
+    }
+
     void "withId(MultiTenantCapableDatastore, tenantId, callable) uses the shared-connection withSession path, dispatching by closure arity"() {
         given:
         def session = Stub(Session)
diff --git a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/AbstractGormApiRegistrySpec.groovy b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/AbstractGormApiRegistrySpec.groovy
index bdccc59d48..08c79159ea 100644
--- a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/AbstractGormApiRegistrySpec.groovy
+++ b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/AbstractGormApiRegistrySpec.groovy
@@ -113,6 +113,59 @@ class AbstractGormApiRegistrySpec extends Specification {
         secondaryApi == api
     }
 
+    void "removeDatastore evicts only the APIs backed by the datastore being removed"() {
+        given:
+        def registry = GormRegistry.instance
+        def testRegistry = new TestGormApiRegistry(registry)
+        def removed = Stub(Datastore)
+        def retained = Stub(Datastore)
+        testRegistry.register(TestEntity.name, new DummyApi(removed))
+        testRegistry.register(OtherTestEntity.name, new DummyApi(retained))
+
+        when:
+        testRegistry.removeDatastore(removed)
+
+        then:
+        !testRegistry.containsKey(TestEntity.name)
+        testRegistry.containsKey(OtherTestEntity.name)
+    }
+
+    void "removeDatastore keeps an API whose datastore cannot be resolved"() {
+        given: "an API backed by a resolver that throws, as a tenant-aware resolver does with no tenant bound"
+        def testRegistry = new TestGormApiRegistry(GormRegistry.instance)
+        def throwingApi = new DummyApi({ throw new IllegalStateException('No tenant bound') } as DatastoreResolver)
+        testRegistry.register(TestEntity.name, throwingApi)
+
+        when:
+        testRegistry.removeDatastore(Stub(Datastore))
+
+        then: "the registration survives — a transient resolution failure is not a positive identity match"
+        testRegistry.get(TestEntity.name).is(throwingApi)
+    }
+
+    void "removeDatastore keeps a qualified API whose datastore cannot be resolved"() {
+        given:
+        def registry = GormRegistry.instance
+        def testRegistry = new TestGormApiRegistry(registry)
+        def defaultDatastore = Stub(Datastore)
+        def secondaryDatastore = Stub(Datastore)
+        testRegistry.register(TestEntity.name, new DummyApi(defaultDatastore))
+        registry.registerDatastore(ConnectionSource.DEFAULT, defaultDatastore)
+        registry.registerDatastore('secondary', secondaryDatastore)
+        registry.registerEntityDatastore(TestEntity.name, ConnectionSource.DEFAULT, defaultDatastore)
+        registry.registerEntityDatastore(TestEntity.name, 'secondary', secondaryDatastore)
+        testRegistry.qualifyWith = new DummyApi({ throw new IllegalStateException('No tenant bound') } as DatastoreResolver)
+
+        and: "the qualified API has been materialized"
+        testRegistry.get(TestEntity.name, 'secondary')
+
+        when:
+        testRegistry.removeDatastore(Stub(Datastore))
+
+        then:
+        testRegistry.isAllocated(TestEntity.name, 'secondary')
+    }
+
     void "test clear"() {
         given:
         def testRegistry = new TestGormApiRegistry(GormRegistry.instance)
@@ -147,15 +200,23 @@ class AbstractGormApiRegistrySpec extends Specification {
 
     static class TestEntity {
     }
-    
+
+    static class OtherTestEntity {
+    }
+
     static class DummyApi extends AbstractDatastoreApi {
         DummyApi(Datastore ds) {
             super(ds)
         }
+
+        DummyApi(DatastoreResolver resolver) {
+            super(resolver)
+        }
     }
 
     static class TestGormApiRegistry extends AbstractGormApiRegistry<AbstractDatastoreApi> {
         AbstractDatastoreApi qualifiedApi
+        AbstractDatastoreApi qualifyWith
 
         TestGormApiRegistry(GormRegistry registry) {
             super(registry)
@@ -163,7 +224,7 @@ class AbstractGormApiRegistrySpec extends Specification {
 
         @Override
         protected AbstractDatastoreApi qualify(AbstractDatastoreApi api, String qualifier) {
-            qualifiedApi = new DummyApi(api.datastore)
+            qualifiedApi = qualifyWith ?: new DummyApi(api.datastore)
             return qualifiedApi
         }
 
diff --git a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/AbstractGormApiSpec.groovy b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/AbstractGormApiSpec.groovy
index 42836fb026..4dcbfea01f 100644
--- a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/AbstractGormApiSpec.groovy
+++ b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/AbstractGormApiSpec.groovy
@@ -116,8 +116,17 @@ class AbstractGormApiSpec extends Specification {
         extendedMethods.any { it.name == 'customExtraMethod' }
         !extendedMethods.any { it.name == 'get' }
 
-        and: "excluded Object/GroovyObject-level methods never appear in either list"
-        !methods.any { it.name in AbstractGormApi.EXCLUDES }
+        and: "the api object's own plumbing never appears in either list, whatever EXCLUDES happens to contain"
+        !methods.any {
+            it.name in ['getClass', 'getMetaClass', 'setMetaClass', 'getProperty', 'setProperty', 'invokeMethod',
+                        'wait', 'notify', 'notifyAll', 'equals', 'hashCode', 'toString',
+                        'getMethods', 'getExtendedMethods', 'getTransactionManager', 'setTransactionManager',
+                        'getQualifier', 'getGormPersistentEntity']
+        }
+        !extendedMethods.any {
+            it.name in ['getMethods', 'getExtendedMethods', 'getTransactionManager', 'setTransactionManager',
+                        'getQualifier', 'getGormPersistentEntity']
+        }
 
         when: "requesting the methods again"
         def methodsAgain = api.getMethods()
@@ -126,6 +135,20 @@ class AbstractGormApiSpec extends Specification {
         methodsAgain.is(methods)
     }
 
+    void "EXCLUDES stays publicly readable and immutable"() {
+        when: "read from outside the AbstractGormApi hierarchy, as third-party GORM implementations do"
+        List<String> excludes = AbstractGormApi.EXCLUDES
+
+        then:
+        excludes.containsAll(['getMethods', 'getExtendedMethods', 'setTransactionManager', 'getTransactionManager'])
+
+        when:
+        excludes << 'somethingElse'
+
+        then:
+        thrown(UnsupportedOperationException)
+    }
+
     void "ConstantDatastoreResolver always resolves to the datastore it was constructed with"() {
         given:
         def ds = Stub(Datastore)
diff --git a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/ActiveSessionDatastoreSelectorSpec.groovy b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/ActiveSessionDatastoreSelectorSpec.groovy
index 5a5c788301..c2dd1161b2 100644
--- a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/ActiveSessionDatastoreSelectorSpec.groovy
+++ b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/ActiveSessionDatastoreSelectorSpec.groovy
@@ -18,8 +18,14 @@
  */
 package org.grails.datastore.gorm
 
+import grails.gorm.annotation.Entity
 import org.grails.datastore.mapping.core.Datastore
+import org.grails.datastore.mapping.core.DatastoreUtils
+import org.grails.datastore.mapping.core.Session
 import org.grails.datastore.mapping.core.connections.ConnectionSource
+import org.grails.datastore.mapping.simple.SimpleMapDatastore
+import org.grails.datastore.mapping.transactions.SessionHolder
+import org.springframework.transaction.support.TransactionSynchronizationManager
 import spock.lang.Specification
 
 class ActiveSessionDatastoreSelectorSpec extends Specification {
@@ -68,6 +74,33 @@ class ActiveSessionDatastoreSelectorSpec extends Specification {
         selector.select(registry, TestEntity.name) == null
     }
 
-    private static class TestEntity {
+    void 'the discovery scan does not disturb the thread-bound sessions of the datastores it probes'() {
+        given: "two real datastores, only one of which has a session bound on this thread"
+        def selector = new ActiveSessionDatastoreSelector()
+        GormRegistry registry = GormRegistry.instance
+        def probed = new SimpleMapDatastore(TestEntity)
+        def owner = new SimpleMapDatastore(TestEntity)
+        registry.registerDatastore(ConnectionSource.DEFAULT, owner)
+        registry.registerDatastore('probed', probed)
+        Session probedSession = probed.connect()
+        DatastoreUtils.bindSession(probedSession)
+        SessionHolder probedHolder = (SessionHolder) TransactionSynchronizationManager.getResource(probed)
+
+        when: "routing asks every registered datastore whether it holds a session"
+        selector.select(registry, TestEntity.name)
+
+        then: "the probed datastore's binding survives the probe — a routing decision for one entity must not unbind another datastore's holder"
+        TransactionSynchronizationManager.getResource(probed).is(probedHolder)
+        probed.hasCurrentSession()
+
+        cleanup:
+        DatastoreUtils.unbindSession(probedSession)
+        probed.close()
+        owner.close()
+    }
+
+    @Entity
+    static class TestEntity {
+        String name
     }
 }
diff --git a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/ConnectionSourceNameResolverSpec.groovy b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/ConnectionSourceNameResolverSpec.groovy
index bb3a5ffc54..9611194ea9 100644
--- a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/ConnectionSourceNameResolverSpec.groovy
+++ b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/ConnectionSourceNameResolverSpec.groovy
@@ -51,4 +51,55 @@ class ConnectionSourceNameResolverSpec extends Specification {
         ConnectionSourceNameResolver.resolveConnectionSourceNames(datastore) == [ConnectionSource.DEFAULT]
         ConnectionSourceNameResolver.resolveDefaultConnectionSourceName(datastore) == ConnectionSource.DEFAULT
     }
+
+    void "isConnectionSourceName recognises a declared connection source"() {
+        expect:
+        ConnectionSourceNameResolver.isConnectionSourceName(datastore, ConnectionSource.DEFAULT)
+    }
+
+    void "isConnectionSourceName answers false for a tenant id without throwing"() {
+        expect: "an unknown name is a plain false — no ConfigurationException, so no stack trace is built per operation"
+        !ConnectionSourceNameResolver.isConnectionSourceName(datastore, 'someTenantId')
+    }
+
+    void "isConnectionSourceName answers false for a null name or a datastore with no connection sources"() {
+        given:
+        def noConnectionSources = Stub(ConnectionSourcesProvider) {
+            getConnectionSources() >> null
+        }
+
+        expect:
+        !ConnectionSourceNameResolver.isConnectionSourceName(datastore, null)
+        !ConnectionSourceNameResolver.isConnectionSourceName(new Object(), 'anything')
+        !ConnectionSourceNameResolver.isConnectionSourceName(noConnectionSources, 'anything')
+    }
+
+    void "isConnectionSourceName recognises a datastore's own default connection"() {
+        given: "a child datastore built for a single connection, which enumerates only that connection"
+        def leaf = Stub(ConnectionSourcesProvider) {
+            getConnectionSources() >> Stub(ConnectionSources) {
+                getDefaultConnectionSource() >> Stub(ConnectionSource) { getName() >> 'secondary' }
+            }
+        }
+
+        expect: "resolving its own connection name is recognised, so it is not mistaken for a tenant id"
+        ConnectionSourceNameResolver.isConnectionSourceName(leaf, 'secondary')
+        !ConnectionSourceNameResolver.isConnectionSourceName(leaf, 'tenant1')
+    }
+
+    void "isConnectionSourceName recognises every declared connection, not just the default"() {
+        given:
+        def provider = Stub(ConnectionSourcesProvider) {
+            getConnectionSources() >> Stub(ConnectionSources) {
+                getAllConnectionSources() >> [
+                        Stub(ConnectionSource) { getName() >> ConnectionSource.DEFAULT },
+                        Stub(ConnectionSource) { getName() >> 'analytics' }
+                ]
+            }
+        }
+
+        expect:
+        ConnectionSourceNameResolver.isConnectionSourceName(provider, 'analytics')
+        !ConnectionSourceNameResolver.isConnectionSourceName(provider, 'reporting')
+    }
 }
diff --git a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/GormEnhancerCoverageSpec.groovy b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/GormEnhancerCoverageSpec.groovy
index 49360fba5d..fe199bf94a 100644
--- a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/GormEnhancerCoverageSpec.groovy
+++ b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/GormEnhancerCoverageSpec.groovy
@@ -173,6 +173,54 @@ class GormEnhancerCoverageSpec extends Specification {
         then:
         thrown(MissingMethodException)
     }
+
+    void "registerApiFactories runs before any entity is registered, so adapter APIs are used from the first entity on"() {
+        given: "an enhancer that registers a factory through the dedicated hook, as the Hibernate and Mongo adapters do"
+        def registry = GormRegistry.instance
+        def order = []
+
+        when:
+        new GormEnhancer(datastore, datastore.transactionManager, new ConnectionSourceSettings()) {
+            @Override
+            protected void registerApiFactories() {
+                order << 'factories'
+                registry.registerApiFactory(SimpleMapDatastore, new DefaultGormApiFactory())
+            }
+
+            @Override
+            void registerEntity(org.grails.datastore.mapping.model.PersistentEntity entity) {
+                order << "entity:${entity.name}".toString()
+                super.registerEntity(entity)
+            }
+        }
+
+        then: "factory registration happens first — an entity registered before it would get the wrong API type"
+        order.first() == 'factories'
+        order.size() > 1
+    }
+
+    void "registerApiFactories is independent of registerConstraints"() {
+        given: "a subclass that suppresses constraint registration entirely"
+        def registry = GormRegistry.instance
+        boolean factoriesRegistered = false
+
+        when:
+        new GormEnhancer(datastore, datastore.transactionManager, new ConnectionSourceSettings()) {
+            @Override
+            protected void registerApiFactories() {
+                factoriesRegistered = true
+                registry.registerApiFactory(SimpleMapDatastore, new DefaultGormApiFactory())
+            }
+
+            @Override
+            protected void registerConstraints(Datastore ds) {
+                // deliberately skipped
+            }
+        }
+
+        then: "factory registration still happens — it is not coupled to the constraints lifecycle step"
+        factoriesRegistered
+    }
 }
 
 @Entity
diff --git a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/GormRegistryCoverageSpec.groovy b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/GormRegistryCoverageSpec.groovy
index cda16f65bd..10289b26fd 100644
--- a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/GormRegistryCoverageSpec.groovy
+++ b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/GormRegistryCoverageSpec.groovy
@@ -307,7 +307,6 @@ class GormRegistryCoverageSpec extends Specification {
         def secondaryDs = Stub(Datastore)
         def multiConnectionDs = Stub(MultipleConnectionSourceDatastore) {
             getDatastoreForConnection('secondary') >> secondaryDs
-            routesUnqualifiedToMappedConnection() >> true
         }
 
         when:
@@ -317,6 +316,59 @@ class GormRegistryCoverageSpec extends Specification {
         registry.getDatastoreByString(GormRegistryCoverageThing.name, 'secondary') == secondaryDs
     }
 
+    void "registerEntityDatastores routes unqualified operations to the first declared connection, deterministically"() {
+        given: "an entity mapped only to non-default connections"
+        def registry = new GormRegistry()
+        def analyticsDs = Stub(Datastore)
+        def reportingDs = Stub(Datastore)
+        def multiConnectionDs = Stub(MultipleConnectionSourceDatastore) {
+            getDatastoreForConnection('analytics') >> analyticsDs
+            getDatastoreForConnection('reporting') >> reportingDs
+        }
+
+        when:
+        registry.registerEntityDatastores(GormRegistryCoverageThing.name, multiConnectionDs, ['analytics', 'reporting'], null)
+
+        then: "the unqualified route is the FIRST declared connection, not whichever entry an iterator yields"
+        registry.getDatastoreByString(GormRegistryCoverageThing.name, ConnectionSource.DEFAULT) == analyticsDs
+
+        when: "the same entity is registered with the declaration order reversed"
+        registry.registerEntityDatastores(GormRegistryCoverageThing.name, multiConnectionDs, ['reporting', 'analytics'], null)
+
+        then: "the unqualified route follows declaration order rather than staying arbitrary"
+        registry.getDatastoreByString(GormRegistryCoverageThing.name, ConnectionSource.DEFAULT) == reportingDs
+    }
+
+    void "registerEntityDatastore establishes the unqualified route from the first connection registered"() {
+        given:
+        def registry = new GormRegistry()
+        def analyticsDs = Stub(Datastore)
+        def reportingDs = Stub(Datastore)
+
+        when:
+        registry.registerEntityDatastore(GormRegistryCoverageThing.name, 'analytics', analyticsDs)
+        registry.registerEntityDatastore(GormRegistryCoverageThing.name, 'reporting', reportingDs)
+
+        then: "both qualified routes resolve, and the unqualified one is pinned to the first registration"
+        registry.getDatastoreByString(GormRegistryCoverageThing.name, 'analytics') == analyticsDs
+        registry.getDatastoreByString(GormRegistryCoverageThing.name, 'reporting') == reportingDs
+        registry.getDatastoreByString(GormRegistryCoverageThing.name, ConnectionSource.DEFAULT) == analyticsDs
+    }
+
+    void "registerEntityDatastore lets an explicit DEFAULT registration override the pinned unqualified route"() {
+        given:
+        def registry = new GormRegistry()
+        def analyticsDs = Stub(Datastore)
+        def defaultDs = Stub(Datastore)
+
+        when:
+        registry.registerEntityDatastore(GormRegistryCoverageThing.name, 'analytics', analyticsDs)
+        registry.registerEntityDatastore(GormRegistryCoverageThing.name, ConnectionSource.DEFAULT, defaultDs)
+
+        then:
+        registry.getDatastoreByString(GormRegistryCoverageThing.name, ConnectionSource.DEFAULT) == defaultDs
+    }
+
     void "registerEntityDatastores swallows connection resolution failures and keeps the parent datastore"() {
         given:
         def registry = new GormRegistry()
@@ -379,13 +431,22 @@ class GormRegistryCoverageSpec extends Specification {
         !registry.createDynamicFinders(datastore).isEmpty()
     }
 
-    void "registerConstraints does nothing for a null datastore and swallows failures for one without constraint support"() {
+    void "initializeDatastore ignores a null datastore and registers a real one by qualifier and type"() {
         given:
         def registry = new GormRegistry()
 
-        expect:
-        registry.registerConstraints(null) == null
-        registry.registerConstraints(datastore) == null
+        when:
+        registry.initializeDatastore(null, ConnectionSource.DEFAULT)
+
+        then:
+        registry.getDatastore(ConnectionSource.DEFAULT) == null
+
+        when:
+        registry.initializeDatastore(datastore, ConnectionSource.DEFAULT)
+
+        then:
+        registry.getDatastore(ConnectionSource.DEFAULT).is(datastore)
+        registry.apiResolver.findDatastoreByType(SimpleMapDatastore).is(datastore)
     }
 
     void "registerEntity registers static, instance and validation apis plus the entity's default datastore mapping"() {
diff --git a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/GormStaticApiSpec.groovy b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/GormStaticApiSpec.groovy
index ec8e4e9bf8..91a7439d4b 100644
--- a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/GormStaticApiSpec.groovy
+++ b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/GormStaticApiSpec.groovy
@@ -22,6 +22,7 @@ import grails.gorm.annotation.Entity
 import grails.gorm.multitenancy.Tenants
 import org.grails.datastore.mapping.core.Datastore
 import org.grails.datastore.mapping.core.Session
+import org.grails.datastore.mapping.core.SessionCallback
 import org.grails.datastore.mapping.core.connections.ConnectionSource
 import org.grails.datastore.mapping.core.connections.ConnectionSources
 import org.grails.datastore.mapping.core.connections.ConnectionSourcesProvider
@@ -353,18 +354,42 @@ class GormStaticApiSpec extends Specification {
         api.count() == 0
     }
 
-    void "deleteAll(Map) delegates to deleteAll()"() {
+    void "deleteAll(Map) deletes every instance and reports how many"() {
         given:
         def api = new GormStaticApi(GormStaticApiThing, datastore, [])
         new GormStaticApiThing(name: 'a').save(flush: true)
+        new GormStaticApiThing(name: 'b').save(flush: true)
 
         when:
-        api.deleteAll([:])
+        def deleted = api.deleteAll([:])
 
         then:
+        deleted == 2
         api.count() == 0
     }
 
+    void "deleteAll(Map) honours the flush argument"() {
+        given:
+        def session = Mock(Session)
+        def api = new GormStaticApi(GormStaticApiThing, datastore, []) {
+            @Override protected Object execute(SessionCallback callback) { callback.doInSession(session) }
+        }
+
+        when:
+        api.deleteAll([flush: true])
+
+        then:
+        1 * session.deleteAll(_) >> 3
+        1 * session.flush()
+
+        when:
+        api.deleteAll([:])
+
+        then: "without flush: true the session is left to the caller's flush policy"
+        1 * session.deleteAll(_) >> 3
+        0 * session.flush()
+    }
+
     void "deleteAll(Iterable) and deleteAll(varargs) remove exactly the given instances"() {
         given:
         def api = new GormStaticApi(GormStaticApiThing, datastore, [])
diff --git a/grails-datamapping-tck/src/main/groovy/org/apache/grails/data/testing/tck/tests/FirstAndLastMethodSpec.groovy b/grails-datamapping-tck/src/main/groovy/org/apache/grails/data/testing/tck/tests/FirstAndLastMethodSpec.groovy
index 7c65cdc6f5..2b86c982f0 100644
--- a/grails-datamapping-tck/src/main/groovy/org/apache/grails/data/testing/tck/tests/FirstAndLastMethodSpec.groovy
+++ b/grails-datamapping-tck/src/main/groovy/org/apache/grails/data/testing/tck/tests/FirstAndLastMethodSpec.groovy
@@ -163,7 +163,7 @@ class FirstAndLastMethodSpec extends GrailsDataTckSpec {
     }
 
     @PendingFeatureIf(
-            value = { System.getProperty('hibernate5.gorm.suite') || System.getProperty('hibernate7.gorm.suite') },
+            value = { System.getProperty('hibernate5.gorm.suite') },
             reason = 'Was previously @Ignore'
     )
     void "Test first and last method with composite key"() {
diff --git a/grails-datamapping-tck/src/main/groovy/org/apache/grails/data/testing/tck/tests/GetAllSpec.groovy b/grails-datamapping-tck/src/main/groovy/org/apache/grails/data/testing/tck/tests/GetAllSpec.groovy
new file mode 100644
index 0000000000..fa7ffff656
--- /dev/null
+++ b/grails-datamapping-tck/src/main/groovy/org/apache/grails/data/testing/tck/tests/GetAllSpec.groovy
@@ -0,0 +1,89 @@
+/*
+ * 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.apache.grails.data.testing.tck.tests
+
+import org.apache.grails.data.testing.tck.base.GrailsDataTckSpec
+import org.apache.grails.data.testing.tck.domains.Person
+
+/**
+ * The contract for {@code getAll(ids)}: the result is positionally aligned with the ids supplied,
+ * so callers can zip the two lists together. Every adapter must honour it.
+ */
+class GetAllSpec extends GrailsDataTckSpec {
+
+    @Override
+    void setupSpec() {
+        manager.registerDomainClasses(Person)
+    }
+
+    def 'getAll returns entities in the order the ids were supplied, not the order the database chose'() {
+        given:
+        def bob = new Person(firstName: 'Bob', lastName: 'Builder').save(flush: true)
+        def fred = new Person(firstName: 'Fred', lastName: 'Flintstone').save(flush: true)
+        def joe = new Person(firstName: 'Joe', lastName: 'Doe').save(flush: true)
+        manager.session.clear()
+
+        when:
+        def results = Person.getAll(joe.id, bob.id, fred.id)
+
+        then:
+        results.size() == 3
+        results*.firstName == ['Joe', 'Bob', 'Fred']
+    }
+
+    def 'getAll preserves duplicate ids, returning one element per requested id'() {
+        given:
+        def bob = new Person(firstName: 'Bob', lastName: 'Builder').save(flush: true)
+        def fred = new Person(firstName: 'Fred', lastName: 'Flintstone').save(flush: true)
+        manager.session.clear()
+
+        when:
+        def results = Person.getAll(bob.id, fred.id, bob.id)
+
+        then:
+        results.size() == 3
+        results*.firstName == ['Bob', 'Fred', 'Bob']
+    }
+
+    def 'getAll yields a null at the position of an id that resolves to no row'() {
+        given:
+        def bob = new Person(firstName: 'Bob', lastName: 'Builder').save(flush: true)
+        def fred = new Person(firstName: 'Fred', lastName: 'Flintstone').save(flush: true)
+        def missingId = fred.id
+        fred.delete(flush: true)
+        manager.session.clear()
+
+        when:
+        def results = Person.getAll(bob.id, missingId)
+
+        then: 'the slot is kept so positions still line up with the requested ids'
+        results.size() == 2
+        results[0].firstName == 'Bob'
+        results[1] == null
+    }
+
+    def 'getAll returns an empty list for empty input'() {
+        given:
+        new Person(firstName: 'Bob', lastName: 'Builder').save(flush: true)
+        manager.session.clear()
+
+        expect:
+        Person.getAll([]) == []
+    }
+}
diff --git a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/AbstractDatastore.java b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/AbstractDatastore.java
index f5e9c7afb5..8313792d87 100644
--- a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/AbstractDatastore.java
+++ b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/AbstractDatastore.java
@@ -218,10 +218,12 @@ public abstract class AbstractDatastore implements Datastore, StatelessDatastore
     /**
      * @return Whether {@link #getCurrentSession()} would return an existing session rather than
      * opening a new one: a validated (still connected) session is bound to the current thread.
-     * Delegates to {@link #getSessionResolver()} so both methods read the same state.
+     * Reads the same state as {@link #getCurrentSession()} but leaves it untouched — this is a
+     * predicate, and callers such as datastore-routing scans invoke it on datastores they are only
+     * inspecting.
      */
     public boolean hasCurrentSession() {
-        return getSessionResolver().resolve() != null;
+        return getSessionResolver().hasSession();
     }
 
     /**
diff --git a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/SessionResolver.groovy b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/SessionResolver.groovy
index 035dbe8cf4..08ae76158d 100644
--- a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/SessionResolver.groovy
+++ b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/SessionResolver.groovy
@@ -43,6 +43,18 @@ interface SessionResolver {
      */
     Session resolve()
 
+    /**
+     * Whether {@link #resolve()} would return a session, answered <b>without side effects</b>.
+     *
+     * {@link #resolve()} does housekeeping — it evicts disconnected sessions and may unbind an
+     * emptied holder — which makes it unsuitable for purely interrogative callers. Routing and
+     * discovery code asks many datastores whether they hold a session before choosing one; that
+     * probe must not mutate the thread-bound state of the datastores it merely inspects.
+     *
+     * @return {@code true} if a still-connected session is bound to the current context
+     */
+    boolean hasSession()
+
     /**
      * Binds a session owned by this resolver's datastore to the current context, making it the
      * session {@link #resolve()} returns. Nested bindings stack: binding a second session pushes
diff --git a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/TransactionSynchronizationSessionResolver.groovy b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/TransactionSynchronizationSessionResolver.groovy
index d33b37ba90..19d39d8d54 100644
--- a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/TransactionSynchronizationSessionResolver.groovy
+++ b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/TransactionSynchronizationSessionResolver.groovy
@@ -68,6 +68,23 @@ class TransactionSynchronizationSessionResolver implements SessionResolver {
         return session
     }
 
+    @Override
+    boolean hasSession() {
+        SessionHolder holder = (SessionHolder) TransactionSynchronizationManager.getResource(datastore)
+        if (holder == null) {
+            return false
+        }
+        // Read the holder without popping: unlike resolve(), this must leave the thread-bound state
+        // exactly as it found it, because callers use it to interrogate datastores they are not
+        // (yet) operating on.
+        for (Session session in holder.getSessions()) {
+            if (session != null && session.isConnected()) {
+                return true
+            }
+        }
+        return false
+    }
+
     @Override
     void bind(Session session) {
         if (session.getDatastore() != datastore) {
diff --git a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/connections/MultipleConnectionSourceCapableDatastore.java b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/connections/MultipleConnectionSourceCapableDatastore.java
index 3b427cd1d7..5a4335603a 100644
--- a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/connections/MultipleConnectionSourceCapableDatastore.java
+++ b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/connections/MultipleConnectionSourceCapableDatastore.java
@@ -36,21 +36,4 @@ public interface MultipleConnectionSourceCapableDatastore extends Datastore {
      * @return The {@link Datastore}
      */
     Datastore getDatastoreForConnection(String connectionName);
-
-    /**
-     * Whether an entity mapped only to non-default connection sources should route its unqualified
-     * (no explicit connection) operations to its first mapped connection's datastore.
-     *
-     * Real datastores manage an independent session per connection, so such an entity's default
-     * operations belong to that mapped connection's datastore. The in-memory mock used for unit
-     * testing manages a single session on this (parent) datastore and only fabricates isolated
-     * children for explicit connection access, so it overrides this to keep unqualified operations
-     * on the parent — otherwise they would target a child whose session the test harness never
-     * flushes.
-     *
-     * @return {@code true} to route unqualified operations to the mapped connection datastore
-     */
-    default boolean routesUnqualifiedToMappedConnection() {
-        return true;
-    }
 }
diff --git a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/reflect/AstUtils.groovy b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/reflect/AstUtils.groovy
index 30a27d010d..6b04d55de2 100644
--- a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/reflect/AstUtils.groovy
+++ b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/reflect/AstUtils.groovy
@@ -357,19 +357,36 @@ class AstUtils {
     }
 
     static void copyAnnotations(final AnnotatedNode from, final AnnotatedNode to) {
-        copyAnnotations(from, to, null, null)
+        copyAnnotations(from, to, null, null, false)
     }
 
     static void copyAnnotations(final AnnotatedNode from, final AnnotatedNode to, final Set<String> included, final Set<String> excluded) {
+        copyAnnotations(from, to, included, excluded, false)
+    }
+
+    /**
+     * Copies the annotations of one node onto another.
+     *
+     * @param from The node to copy the annotations from
+     * @param to The node to copy the annotations to
+     * @param included If non-null, only annotations whose class name appears here are copied
+     * @param excluded If non-null, annotations whose class name appears here are not copied
+     * @param skipExisting When {@code true} an annotation is not copied if {@code to} already carries one of the
+     *        same type. Callers that may write onto a node they have already annotated themselves should opt in.
+     *        The default is {@code false}, because repeatable annotations legitimately occur more than once on the
+     *        same node and skipping them would silently produce an incomplete set.
+     */
+    static void copyAnnotations(final AnnotatedNode from, final AnnotatedNode to, final Set<String> included, final Set<String> excluded, final boolean skipExisting) {
         final List<AnnotationNode> annotationsToCopy = from.getAnnotations()
         for (final AnnotationNode node : annotationsToCopy) {
             String annotationClassName = node.getClassNode().getName()
             if ((excluded == null || !excluded.contains(annotationClassName)) &&
                     (included == null || included.contains(annotationClassName))) {
-                if (to.getAnnotations(node.getClassNode()).isEmpty()) {
-                    final AnnotationNode copyOfAnnotationNode = cloneAnnotation(node)
-                    to.addAnnotation(copyOfAnnotationNode)
+                if (skipExisting && !to.getAnnotations(node.getClassNode()).isEmpty()) {
+                    continue
                 }
+                final AnnotationNode copyOfAnnotationNode = cloneAnnotation(node)
+                to.addAnnotation(copyOfAnnotationNode)
             }
         }
     }
diff --git a/grails-datastore-core/src/test/groovy/org/grails/datastore/mapping/core/TransactionSynchronizationSessionResolverSpec.groovy b/grails-datastore-core/src/test/groovy/org/grails/datastore/mapping/core/TransactionSynchronizationSessionResolverSpec.groovy
index 228720557a..af5cb784c7 100644
--- a/grails-datastore-core/src/test/groovy/org/grails/datastore/mapping/core/TransactionSynchronizationSessionResolverSpec.groovy
+++ b/grails-datastore-core/src/test/groovy/org/grails/datastore/mapping/core/TransactionSynchronizationSessionResolverSpec.groovy
@@ -153,6 +153,66 @@ class TransactionSynchronizationSessionResolverSpec extends Specification {
         TransactionSynchronizationManager.getResource(datastore).is(holder)
     }
 
+    def "hasSession() reports whether a connected session is bound"() {
+        expect:
+        !resolver.hasSession()
+
+        when:
+        Session session = connectedSession()
+        resolver.bind(session)
+
+        then:
+        resolver.hasSession()
+
+        when:
+        resolver.unbind()
+
+        then:
+        !resolver.hasSession()
+    }
+
+    def "hasSession() is false when the only bound session is disconnected"() {
+        given:
+        Session session = Mock(Session)
+        session.getDatastore() >> datastore
+        session.isConnected() >> false
+        DatastoreUtils.bindSession(session)
+
+        expect:
+        !resolver.hasSession()
+    }
+
+    def "hasSession() sees a connected session beneath a disconnected one"() {
+        given:
+        Session live = connectedSession()
+        Session stale = Mock(Session)
+        stale.getDatastore() >> datastore
+        stale.isConnected() >> false
+        DatastoreUtils.bindSession(live)
+        DatastoreUtils.bindNewSession(stale)
+
+        expect:
+        resolver.hasSession()
+    }
+
+    def "hasSession() does not evict a disconnected session or unbind the holder"() {
+        given: "a bound holder whose only session has been disconnected — resolve() would clean this up"
+        Session session = Mock(Session)
+        session.getDatastore() >> datastore
+        session.isConnected() >> false
+        DatastoreUtils.bindSession(session)
+        SessionHolder holder = (SessionHolder) TransactionSynchronizationManager.getResource(datastore)
+
+        when: "the datastore is merely interrogated, as a routing scan does across many datastores"
+        boolean answer = resolver.hasSession()
+
+        then: "the answer is correct and the thread-bound state is exactly as it was"
+        !answer
+        TransactionSynchronizationManager.getResource(datastore).is(holder)
+        holder.containsSession(session)
+        holder.size() == 1
+    }
+
     def "a session bound on one thread is not visible to another thread"() {
         given:
         Session session = connectedSession()
diff --git a/grails-datastore-core/src/test/groovy/org/grails/datastore/mapping/reflect/AstUtilsSpec.groovy b/grails-datastore-core/src/test/groovy/org/grails/datastore/mapping/reflect/AstUtilsSpec.groovy
index 84e1e6b58d..28b060c570 100644
--- a/grails-datastore-core/src/test/groovy/org/grails/datastore/mapping/reflect/AstUtilsSpec.groovy
+++ b/grails-datastore-core/src/test/groovy/org/grails/datastore/mapping/reflect/AstUtilsSpec.groovy
@@ -57,7 +57,22 @@ class AstUtilsSpec extends Specification {
         to.getAnnotations(annotationType).size() == 1
     }
 
-    void "copyAnnotations does not add a second annotation of a type the target already carries"() {
+    void "copyAnnotations copies repeatable annotations by default"() {
+        given: "a source carrying the same annotation type twice, as a repeatable annotation legitimately does"
+        def from = newMethodNode()
+        def to = newMethodNode()
+        def annotationType = ClassHelper.make("jakarta.validation.constraints.Pattern")
+        from.addAnnotation(new AnnotationNode(annotationType))
+        from.addAnnotation(new AnnotationNode(annotationType))
+
+        when:
+        AstUtils.copyAnnotations(from, to)
+
+        then: "both are copied — the default must not silently drop all but the first"
+        to.getAnnotations(annotationType).size() == 2
+    }
+
+    void "copyAnnotations with skipExisting does not add a type the target already carries"() {
         given: "a target that a caller has already annotated directly, e.g. to avoid double-adding @NotTransactional/@ReadOnly"
         def from = newMethodNode()
         def to = newMethodNode()
@@ -66,12 +81,27 @@ class AstUtilsSpec extends Specification {
         to.addAnnotation(new AnnotationNode(annotationType))
 
         when:
-        AstUtils.copyAnnotations(from, to)
+        AstUtils.copyAnnotations(from, to, null, null, true)
 
         then: "the target keeps its own single instance rather than gaining a duplicate, which Groovy's compiler rejects"
         to.getAnnotations(annotationType).size() == 1
     }
 
+    void "copyAnnotations without skipExisting keeps the pre-existing annotation and the copy"() {
+        given:
+        def from = newMethodNode()
+        def to = newMethodNode()
+        def annotationType = ClassHelper.make("grails.gorm.transactions.NotTransactional")
+        from.addAnnotation(new AnnotationNode(annotationType))
+        to.addAnnotation(new AnnotationNode(annotationType))
+
+        when:
+        AstUtils.copyAnnotations(from, to)
+
+        then: "the default is unchanged from the general-purpose contract — callers opt in to deduplication"
+        to.getAnnotations(annotationType).size() == 2
+    }
+
     private static MethodNode newMethodNode() {
         new MethodNode("test", 0, ClassHelper.VOID_TYPE, Parameter.EMPTY_ARRAY, ClassNode.EMPTY_ARRAY, null)
     }
diff --git a/grails-doc/src/en/guide/GORM/multiTenancy.adoc b/grails-doc/src/en/guide/GORM/multiTenancy.adoc
new file mode 100644
index 0000000000..ae740fcd88
--- /dev/null
+++ b/grails-doc/src/en/guide/GORM/multiTenancy.adoc
@@ -0,0 +1,135 @@
+////
+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.
+////
+
+A multi-tenant application serves several tenants from one deployment. GORM resolves the tenant for each
+operation from the tenant bound to the current thread, so the entry points below are how application code
+says "run this work as tenant X".
+
+=== Running Work As a Tenant
+
+`grails.gorm.multitenancy.Tenants` is the entry point.
+
+==== withId
+
+`Tenants.withId(tenantId) { ... }` runs the closure as the given tenant *and manages the session for it*: a
+session is opened for the scope of the call and closed on exit. In `DATABASE` mode that is also what selects
+the tenant's connection, so this is the method to reach for in almost all application code.
+
+[source,groovy]
+----
+import grails.gorm.multitenancy.Tenants
+
+Tenants.withId('acme') {
+    new Person(name: 'Fred').save()
+    Person.count()
+}
+----
+
+The closure may take up to two parameters — the tenant id and the session:
+
+[source,groovy]
+----
+Tenants.withId('acme') { String tenantId -> Person.count() }
+Tenants.withId('acme') { String tenantId, session -> Person.count() }
+----
+
+Two further overloads select which datastore the tenant applies to when an application has more than one:
+
+[source,groovy]
+----
+// by datastore type
+Tenants.withId(HibernateDatastore, 'acme') { Person.count() }
+
+// by the datastore that maps a given domain class
+Tenants.withIdForDomain(Person, 'acme') { Person.count() }
+----
+
+NOTE: `withId(Class, ...)` takes a *datastore* type; `withIdForDomain(Class, ...)` takes a *domain* class. They
+need separate names because both erase to the same signature.
+
+==== withTenant
+
+`Tenants.withTenant(tenantId) { ... }` binds the tenant to the current thread and nothing more. It opens no
+session and manages no connection — the closure runs in whatever session the caller has already established,
+and the caller remains responsible for opening and closing it.
+
+[source,groovy]
+----
+Person.withNewSession {
+    Tenants.withTenant('acme') {
+        Person.count()
+    }
+}
+----
+
+Reach for `withTenant` when a session already exists and you only need to change which tenant the enclosed
+work resolves to; reach for `withId` when the work needs a session of its own. As with `withId`, a
+`withTenantForDomain(Class, tenantId, closure)` overload picks the datastore by domain class.
+
+==== Other Entry Points
+
+[cols="1,3"]
+|===
+|Method |Description
+
+|`Tenants.currentId()`
+|The tenant id currently in effect, resolved from the bound tenant or, failing that, the configured tenant resolver. Throws if none can be found.
+
+|`Tenants.withCurrent { ... }`
+|Runs the closure under the tenant `currentId()` resolves to.
+
+|`Tenants.withoutId { ... }`
+|Runs the closure with no tenant. In `DATABASE` mode this targets the default data source; in the shared-connection modes it drops the tenant filter from queries. Use with care — it sees every tenant's data.
+
+|`Tenants.eachTenant { ... }`
+|Runs the closure once per tenant.
+|===
+
+=== Reading the Bound Tenant
+
+`grails.gorm.multitenancy.CurrentTenantHolder` holds the tenant bound to the current thread. A thread can
+hold a different tenant for each datastore in play, so lookups are scoped by datastore:
+
+[source,groovy]
+----
+import grails.gorm.multitenancy.CurrentTenantHolder
+
+Serializable tenantId = CurrentTenantHolder.get(datastore)
+----
+
+`get()` without a datastore returns the bound tenant when the answer is unambiguous, `null` when no tenant is
+bound, and throws `TenantException` when different datastores hold different tenants — returning an arbitrary
+one of them is how cross-tenant reads and writes happen. Prefer `get(Datastore)`.
+
+The remaining methods bind and unbind:
+
+[source,groovy]
+----
+CurrentTenantHolder.set(datastore, 'acme')
+CurrentTenantHolder.remove(datastore)
+
+CurrentTenantHolder.withTenant(datastore, 'acme') { /* bound for this closure only */ }
+CurrentTenantHolder.withoutTenant(datastore) { /* no tenant for this closure only */ }
+----
+
+Bindings may also be made against a datastore *type* rather than an instance; a binding for a specific
+instance wins over one for its type.
+
+TIP: Prefer `Tenants` in application code. `CurrentTenantHolder` is the lower-level thread binding that
+`Tenants` is built on, and is useful mainly when integrating with code that manages sessions itself.
diff --git a/grails-doc/src/en/guide/GORM/quickStartGuide/basicCRUD.adoc b/grails-doc/src/en/guide/GORM/quickStartGuide/basicCRUD.adoc
index bdcc7abd69..e17c73693c 100644
--- a/grails-doc/src/en/guide/GORM/quickStartGuide/basicCRUD.adoc
+++ b/grails-doc/src/en/guide/GORM/quickStartGuide/basicCRUD.adoc
@@ -157,6 +157,44 @@ Unlike the link:{domainClassesRef}save.html[save] method, the `delete` method do
 
 Using `flush: true` ensures the delete is executed immediately, so any errors are raised at that point.
 
+==== Deleting Every Instance
+
+`deleteAll()` deletes every persisted instance of a domain class and returns the number of rows removed:
+
+[source,groovy]
+----
+Number removed = Person.deleteAll()
+----
+
+An argument map may be supplied. It controls *how* the delete is executed — it does not narrow *what* is
+deleted. The supported argument is `flush`:
+
+[source,groovy]
+----
+Person.deleteAll(flush: true)
+----
+
+WARNING: `deleteAll()` and `deleteAll(Map)` remove every row for the domain class. There is no argument that
+turns them into a partial delete.
+
+To delete a subset, build a query and delete through it:
+
+[source,groovy]
+----
+Person.where { age < 18 }.deleteAll()
+----
+
+The other `deleteAll` overloads delete only the instances you hand them:
+
+[source,groovy]
+----
+def (fred, bob) = [Person.get(1), Person.get(2)]
+
+Person.deleteAll(fred, bob)              // varargs
+Person.deleteAll([fred, bob])            // any Iterable
+Person.deleteAll([flush: true], fred, bob)
+----
+
 === Querying
 
 To dynamically build queries based on optional parameters a common pattern is to use `DetachedCriteria` and progressively compose filters depending on the provided inputs.
diff --git a/grails-doc/src/en/guide/toc.yml b/grails-doc/src/en/guide/toc.yml
index b0e4a5c3ff..6fc9fcb78a 100644
--- a/grails-doc/src/en/guide/toc.yml
+++ b/grails-doc/src/en/guide/toc.yml
@@ -106,6 +106,7 @@ GORM:
   quickStartGuide:
     title: Quick Start Guide
     basicCRUD: Basic CRUD
+  multiTenancy: Multi-Tenancy
   furtherReading: Further Reading on GORM
     
 theWebLayer:
diff --git a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc
index cfb81c4111..7fca641818 100644
--- a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc
+++ b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc
@@ -1940,3 +1940,87 @@ refreshes. Any context not started through `SpringApplication` — the CLI, plai
 tests — keeps the previous fallback behaviour unchanged: a plain `DefaultStackTraceFilterer` with no
 configuration applied. The filterer is JVM-global rather than per-context, so in a JVM hosting more than one
 application context the last one booted supplies the filterer for all of them.
+
+==== 39. GORM API Resolution Moves to a Registry
+
+GORM no longer allocates an API object for every (entity, connection) pair up front. Per-entity, per-qualifier
+state now lives in a registry, and APIs are built on first use by a pluggable factory. For applications this is
+invisible — `Book.list()`, dynamic finders, and `Book.withNewSession { }` behave as before. It does change the
+extension points that out-of-tree GORM implementations subclass.
+
+===== GormEnhancer
+
+The following members of `org.grails.datastore.gorm.GormEnhancer` were removed:
+
+[cols="1,2", options="header"]
+|===
+| Removed
+| Migration note
+
+| `enhance()` and `enhance(PersistentEntity)`
+| Entity registration now happens as each entity is registered — `GormEnhancer`'s constructor registers every
+entity in the datastore's mapping context, and `registerEntity(PersistentEntity)` registers one. Delete calls to
+`enhance()`; a datastore is usable as soon as its enhancer is constructed.
+
+| `dynamicEnhance`
+| The flag gated `enhance()` and was always `false` when constructed from `ConnectionSourceSettings`. GORM now
+installs `methodMissing`/`propertyMissing` on an entity's metaclass only for classes that do not implement
+`GormEntity` — a `GormEntity` already carries compiled dispatch, so nothing is installed over it.
+
+| `finders`
+| Use `createDynamicFinders(Datastore)`, or read the finders from the API object you are working with.
+
+| `transactionManager`
+| Obtain the transaction manager from the datastore, e.g. `((TransactionCapableDatastore) datastore).transactionManager`.
+
+| `GormEnhancer(Datastore)` and `GormEnhancer(Datastore, PlatformTransactionManager, boolean, boolean, boolean)`
+| Use `GormEnhancer(Datastore, PlatformTransactionManager, ConnectionSourceSettings)`, which carries `failOnError`
+and `markDirty`.
+|===
+
+A new `registerApiFactories()` hook is called once, before any entity is registered. Adapters that build
+specialised API objects should override it:
+
+[source,groovy]
+----
+class MyGormEnhancer extends GormEnhancer {
+
+    @Override
+    protected void registerApiFactories() {
+        GormRegistry.instance.registerApiFactory(MyDatastore, new MyGormApiFactory())
+    }
+}
+----
+
+WARNING: Earlier snapshots of this work registered API factories from `registerConstraints(Datastore)`.
+Move that code to `registerApiFactories()`; factory registration must not depend on the constraints lifecycle step.
+
+===== GormStaticApi
+
+`GormStaticApi.multiTenancyMode` is no longer a field captured at construction. It is now a derived property
+resolved from the datastore the API currently routes to, because an API is no longer bound to one datastore for
+its lifetime. Reading `multiTenancyMode` from a subclass continues to work; assigning it does not.
+
+Subclasses should read the persistent entity through `getGormPersistentEntity()` rather than the deprecated
+`persistentEntity` field, and must not re-declare either member — a shadowing field diverges from the base
+class's value with no compiler warning.
+
+===== Tenants
+
+`Tenants.withId(Class, Serializable, Closure)` continues to take a *datastore* type. A new
+`Tenants.withIdForDomain(Class, Serializable, Closure)` takes a *domain* class instead, and
+`Tenants.withTenantForDomain(Class, Serializable, Closure)` is its `withTenant` counterpart. The names differ
+because all three erase to the same signature; existing `withId(SomeDatastore, id) { }` calls are unaffected.
+
+See <<multiTenancy,Multi-Tenancy>> for the full multi-tenancy API.
+
+===== CurrentTenantHolder.get()
+
+`grails.gorm.multitenancy.CurrentTenantHolder.get()` — the no-argument form — now throws `TenantException` when
+different datastores hold different tenants on the calling thread, rather than returning an arbitrary one of
+them. Pass the datastore you are operating on:
+
+[source,groovy]
+----
+CurrentTenantHolder.get(datastore)
+----