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

(grails-core) branch test/gorm-registry-core-tests created (now 7bab6ad4dc)

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

borinquenkid pushed a change to branch test/gorm-registry-core-tests
in repository https://gitbox.apache.org/repos/asf/grails-core.git


      at 7bab6ad4dc test: add core-class unit tests for the GormRegistry O(M+N) rewrite

This branch includes the following new commits:

     new 7bab6ad4dc test: add core-class unit tests for the GormRegistry O(M+N) rewrite

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.



(grails-core) 01/01: test: add core-class unit tests for the GormRegistry O(M+N) rewrite

Posted by bo...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

borinquenkid pushed a commit to branch test/gorm-registry-core-tests
in repository https://gitbox.apache.org/repos/asf/grails-core.git

commit 7bab6ad4dc227f6a2518ef51b96125398955a7b4
Author: Walter Duque de Estrada <wb...@mac.com>
AuthorDate: Mon Jun 29 11:03:41 2026 -0500

    test: add core-class unit tests for the GormRegistry O(M+N) rewrite
    
    Harvest the net-new GormRegistry unit specs onto core-impl so the rewrite's core classes have
    dedicated coverage they were missing: GormApiResolver and the four datastore selectors
    (Preferred/Qualified/ActiveSession/Default), GormRegistry (concurrency, entity registration, factory),
    the api registries (Abstract/Static/Instance/Validation) and the GormApiFactory SPI
    (Default/GormApiFactory), CurrentTenantHolder, Tenants, the @CurrentTenant transform
    (MultiTenantCurrentTenantTransformSpec), MultiTenantEventListener, ConnectionSourceNameResolver,
    DefaultTransactionTemplateFactory, and a service transform spec.
    
    All 23 specs pass against core-impl's implementation. Two adjustments from the source specs:
    - ServiceTransformSpec: align the no-datastore exception assertion to core-impl's actual message
      ("No GORM implementations configured" from GormApiResolver.findSingleDatastore).
    - Dropped TenantContextProfilingSpec: a timing micro-benchmark (prints durations, asserts only true) —
      brittle and non-deterministic, not portable unit coverage.
    
    Tests-only change; no production code touched. Branched from the GormRegistry core-impl work.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <no...@anthropic.com>
---
 .../MultiTenantCurrentTenantTransformSpec.groovy   | 143 ++++++
 .../multitenancy/CurrentTenantHolderSpec.groovy    | 136 ++++++
 .../grails/gorm/multitenancy/TenantsSpec.groovy    | 118 +++++
 .../transform/ServiceTransformClasses.groovy       | 382 +++++++++++++++
 .../services/transform/ServiceTransformSpec.groovy | 532 +++++++++++++++++++++
 .../gorm/AbstractGormApiRegistrySpec.groovy        | 178 +++++++
 .../gorm/ActiveSessionDatastoreSelectorSpec.groovy |  73 +++
 .../gorm/ConnectionSourceNameResolverSpec.groovy   | 175 +++++++
 .../gorm/DefaultDatastoreSelectorSpec.groovy       |  95 ++++
 .../gorm/DefaultGormApiFactorySpec.groovy          |  63 +++
 .../datastore/gorm/GormApiFactorySpec.groovy       | 132 +++++
 .../datastore/gorm/GormApiRegistrySpec.groovy      | 123 +++++
 .../datastore/gorm/GormApiResolverSpec.groovy      | 202 ++++++++
 .../gorm/GormInstanceApiRegistrySpec.groovy        |  67 +++
 .../datastore/gorm/GormInstanceApiSpec.groovy      | 142 ++++++
 .../gorm/GormRegistryConcurrencySpec.groovy        | 132 +++++
 .../gorm/GormRegistryEntityRegistrationSpec.groovy | 185 +++++++
 .../datastore/gorm/GormRegistryFactorySpec.groovy  |  73 +++
 .../gorm/GormStaticApiRegistrySpec.groovy          |  67 +++
 .../gorm/GormValidationApiRegistrySpec.groovy      |  67 +++
 .../gorm/PreferredDatastoreSelectorSpec.groovy     |  75 +++
 .../gorm/QualifiedDatastoreSelectorSpec.groovy     |  96 ++++
 .../MultiTenantEventListenerSpec.groovy            | 111 +++++
 .../DefaultTransactionTemplateFactorySpec.groovy   |  90 ++++
 24 files changed, 3457 insertions(+)

diff --git a/grails-datamapping-core/src/test/groovy/grails/gorm/annotation/multitenancy/MultiTenantCurrentTenantTransformSpec.groovy b/grails-datamapping-core/src/test/groovy/grails/gorm/annotation/multitenancy/MultiTenantCurrentTenantTransformSpec.groovy
new file mode 100644
index 0000000000..b6629c8e53
--- /dev/null
+++ b/grails-datamapping-core/src/test/groovy/grails/gorm/annotation/multitenancy/MultiTenantCurrentTenantTransformSpec.groovy
@@ -0,0 +1,143 @@
+/*
+ *  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 grails.gorm.annotation.multitenancy
+
+import spock.lang.Specification
+
+/**
+ * Created by graemerocher on 16/01/2017.
+ */
+class MultiTenantCurrentTenantTransformSpec extends Specification {
+
+    void "test @CurrentTenant transforms a service and makes a method that is wrapped in current tenant handling"() {
+        given:"A service with @CurrentTenant applied as the class level"
+        def bookService = new GroovyShell().evaluate('''
+import grails.gorm.multitenancy.CurrentTenant
+class BookService {
+    @CurrentTenant
+    List listBooks() {
+        return ["The Stand"]
+    }
+}
+new BookService()
+
+''')
+        when:"the list books method is invoked"
+        def result = bookService.listBooks()
+
+        then:"An exception was thrown because GORM is not setup"
+        thrown(IllegalStateException)
+
+    }
+
+    void "test @CurrentTenant transforms a service class and makes a method in current tenant handling"() {
+        given:"A service with @CurrentTenant applied as the class level"
+        def bookService = new GroovyShell().evaluate('''
+import grails.gorm.multitenancy.CurrentTenant
+
+@CurrentTenant
+class BookService {
+   
+    List listBooks() {
+        return ["The Stand"]
+    }
+}
+new BookService()
+
+''')
+        when:"the list books method is invoked"
+        def result = bookService.listBooks()
+
+        then:"An exception was thrown because GORM is not setup"
+        thrown(IllegalStateException)
+
+    }
+
+    void "test @CurrentTenant transforms a service class and a method marked with @WithoutTenant in no tenant handling"() {
+        given:"A service with @CurrentTenant applied as the class level"
+        def bookService = new GroovyShell().evaluate('''
+import grails.gorm.multitenancy.CurrentTenant
+import grails.gorm.multitenancy.WithoutTenant
+
+@CurrentTenant
+class BookService {
+   
+   @WithoutTenant
+    List listBooks() {
+        return ["The Stand"]
+    }
+}
+new BookService()
+
+''')
+        when:"the list books method is invoked"
+        def result = bookService.listBooks()
+
+        then:"An exception was thrown because GORM is not setup"
+        thrown(IllegalStateException)
+
+    }
+
+    void "test @WithoutTenant transforms a service class and makes a method that is wrapped in without tenant handling"() {
+        given:"A service with @CurrentTenant applied as the class level"
+        def bookService = new GroovyShell().evaluate('''import grails.gorm.multitenancy.WithoutTenant
+
+@WithoutTenant
+class BookService {
+   
+    List listBooks() {
+        return ["The Stand"]
+    }
+}
+new BookService()
+
+''')
+        when:"the list books method is invoked"
+        def result = bookService.listBooks()
+
+        then:"An exception was thrown because GORM is not setup"
+        thrown(IllegalStateException)
+
+    }
+
+    void "test @WithoutTenant transforms a service class and a method marked with @CurrentTenant in current tenant handling"() {
+        given:"A service with @CurrentTenant applied as the class level"
+        def bookService = new GroovyShell().evaluate('''
+import grails.gorm.multitenancy.CurrentTenant
+import grails.gorm.multitenancy.WithoutTenant
+
+@WithoutTenant
+class BookService {
+   
+    @CurrentTenant
+    List listBooks() {
+        return ["The Stand"]
+    }
+}
+new BookService()
+
+''')
+        when:"the list books method is invoked"
+        def result = bookService.listBooks()
+
+        then:"An exception was thrown because GORM is not setup"
+        thrown(IllegalStateException)
+
+    }
+}
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
new file mode 100644
index 0000000000..71929a9a2c
--- /dev/null
+++ b/grails-datamapping-core/src/test/groovy/grails/gorm/multitenancy/CurrentTenantHolderSpec.groovy
@@ -0,0 +1,136 @@
+/*
+ * Copyright 2026 the original author or authors.
+ *
+ * Licensed 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 grails.gorm.multitenancy
+
+import org.grails.datastore.mapping.core.Datastore
+import org.grails.datastore.mapping.core.connections.ConnectionSource
+import spock.lang.Specification
+
+class CurrentTenantHolderSpec extends Specification {
+
+    void "test set, get, and remove for Datastore instance"() {
+        given:
+        Datastore mockDatastore = Mock(Datastore)
+
+        when:
+        CurrentTenantHolder.set(mockDatastore, "tenant1")
+
+        then:
+        CurrentTenantHolder.get(mockDatastore) == "tenant1"
+
+        when:
+        CurrentTenantHolder.remove(mockDatastore)
+
+        then:
+        CurrentTenantHolder.get(mockDatastore) == null
+    }
+
+    void "test set, get, and remove for Datastore class"() {
+        given:
+        Datastore mockDatastore = [:] as Datastore
+        Class<? extends Datastore> mockDatastoreClass = mockDatastore.getClass()
+
+        when:
+        CurrentTenantHolder.set(mockDatastoreClass, "tenant2")
+
+        then:
+        CurrentTenantHolder.get(mockDatastore) == "tenant2"
+
+        when:
+        CurrentTenantHolder.remove(mockDatastoreClass)
+
+        then:
+        CurrentTenantHolder.get(mockDatastore) == null
+    }
+
+    void "test fallback to Datastore class when instance tenant is not found"() {
+        given:
+        Datastore mockDatastore = [:] as Datastore
+        Class<? extends Datastore> datastoreClass = mockDatastore.getClass()
+
+        when:
+        CurrentTenantHolder.set(datastoreClass, "classTenant")
+
+        then:
+        CurrentTenantHolder.get(mockDatastore) == "classTenant"
+
+        when:
+        CurrentTenantHolder.set(mockDatastore, "instanceTenant")
+
+        then:
+        CurrentTenantHolder.get(mockDatastore) == "instanceTenant"
+
+        cleanup:
+        CurrentTenantHolder.remove(datastoreClass)
+        CurrentTenantHolder.remove(mockDatastore)
+    }
+
+    void "test withTenant for Datastore instance"() {
+        given:
+        Datastore mockDatastore = [:] as Datastore
+        CurrentTenantHolder.set(mockDatastore, "previousTenant")
+
+        when:
+        def result = CurrentTenantHolder.withTenant(mockDatastore, "newTenant") {
+            return CurrentTenantHolder.get(mockDatastore)
+        }
+
+        then:
+        result == "newTenant"
+        CurrentTenantHolder.get(mockDatastore) == "previousTenant"
+
+        cleanup:
+        CurrentTenantHolder.remove(mockDatastore)
+    }
+
+    void "test withTenant for Datastore class"() {
+        given:
+        Datastore mockDatastore = [:] as Datastore
+        Class<? extends Datastore> mockDatastoreClass = mockDatastore.getClass()
+        CurrentTenantHolder.set(mockDatastoreClass, "previousClassTenant")
+
+        when:
+        def result = CurrentTenantHolder.withTenant(mockDatastoreClass, "newClassTenant") {
+            return CurrentTenantHolder.get(mockDatastore)
+        }
+
+        then:
+        result == "newClassTenant"
+        CurrentTenantHolder.get(mockDatastore) == "previousClassTenant"
+
+        cleanup:
+        CurrentTenantHolder.remove(mockDatastoreClass)
+    }
+
+    void "test withoutTenant"() {
+        given:
+        Datastore mockDatastore = [:] as Datastore
+        CurrentTenantHolder.set(mockDatastore, "currentTenant")
+
+        when:
+        def result = CurrentTenantHolder.withoutTenant(mockDatastore) {
+            return CurrentTenantHolder.get(mockDatastore)
+        }
+
+        then:
+        result == ConnectionSource.DEFAULT
+        CurrentTenantHolder.get(mockDatastore) == "currentTenant"
+
+        cleanup:
+        CurrentTenantHolder.remove(mockDatastore)
+    }
+}
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
new file mode 100644
index 0000000000..a969d4507a
--- /dev/null
+++ b/grails-datamapping-core/src/test/groovy/grails/gorm/multitenancy/TenantsSpec.groovy
@@ -0,0 +1,118 @@
+/*
+ *  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 grails.gorm.multitenancy
+
+import org.grails.datastore.mapping.core.Datastore
+import org.grails.datastore.mapping.multitenancy.MultiTenantCapableDatastore
+import org.grails.datastore.mapping.multitenancy.TenantResolver
+import org.grails.datastore.mapping.multitenancy.MultiTenancySettings
+import spock.lang.Specification
+
+/**
+ * Tests for the {@link Tenants} helper class.
+ */
+class TenantsSpec extends Specification {
+
+    def "test withId sets and resets tenant for a specific datastore"() {
+        given: "A mock datastore"
+        Datastore datastore = Mock(MultiTenantCapableDatastore)
+        ((MultiTenantCapableDatastore)datastore).getMultiTenancyMode() >> MultiTenancySettings.MultiTenancyMode.DATABASE
+        ((MultiTenantCapableDatastore)datastore).withNewSession(_ as Serializable, _ as Closure) >> { Serializable tenantId, Closure callable ->
+            callable.call(null)
+        }
+
+        when: "Executing withId"
+        def result = Tenants.withId((MultiTenantCapableDatastore)datastore, "tenant1") {
+            return Tenants.currentId((MultiTenantCapableDatastore)datastore)
+        }
+
+        then: "The tenant is correct inside the closure"
+        result == "tenant1"
+
+        and: "The tenant is cleared after execution"
+        CurrentTenantHolder.get(datastore) == null
+    }
+
+    def "test nested withId for different datastores"() {
+        given: "Two mock datastores"
+        Datastore ds1 = Mock(MultiTenantCapableDatastore)
+        Datastore ds2 = Mock(MultiTenantCapableDatastore)
+        ((MultiTenantCapableDatastore)ds1).getMultiTenancyMode() >> MultiTenancySettings.MultiTenancyMode.DATABASE
+        ((MultiTenantCapableDatastore)ds2).getMultiTenancyMode() >> MultiTenancySettings.MultiTenancyMode.DATABASE
+
+        ((MultiTenantCapableDatastore)ds1).withNewSession(_ as Serializable, _ as Closure) >> { Serializable tenantId, Closure callable ->
+            callable.call(null)
+        }
+        ((MultiTenantCapableDatastore)ds2).withNewSession(_ as Serializable, _ as Closure) >> { Serializable tenantId, Closure callable ->
+            callable.call(null)
+        }
+
+        when: "Executing nested withId calls"
+        def results = [:]
+        Tenants.withId((MultiTenantCapableDatastore)ds1, "t1") {
+            results.ds1_inner1 = Tenants.currentId((MultiTenantCapableDatastore)ds1)
+            Tenants.withId((MultiTenantCapableDatastore)ds2, "t2") {
+                results.ds1_inner2 = Tenants.currentId((MultiTenantCapableDatastore)ds1)
+                results.ds2_inner2 = Tenants.currentId((MultiTenantCapableDatastore)ds2)
+            }
+            results.ds1_inner3 = Tenants.currentId((MultiTenantCapableDatastore)ds1)
+            results.ds2_inner3 = CurrentTenantHolder.get(ds2)
+        }
+
+        then: "Each datastore maintains its own tenant context"
+        results.ds1_inner1 == "t1"
+        results.ds1_inner2 == "t1"
+        results.ds2_inner2 == "t2"
+        results.ds1_inner3 == "t1"
+        results.ds2_inner3 == null
+    }
+
+    def "test currentId fallbacks to TenantResolver if no ThreadLocal set"() {
+        given: "A mock datastore with a resolver"
+        Datastore datastore = Mock(MultiTenantCapableDatastore)
+        TenantResolver resolver = Mock(TenantResolver)
+        ((MultiTenantCapableDatastore)datastore).getTenantResolver() >> resolver
+
+        when: "No tenant is set in ThreadLocal"
+        def result = Tenants.currentId((MultiTenantCapableDatastore)datastore)
+
+        then: "The resolver is called"
+        1 * resolver.resolveTenantIdentifier() >> "resolvedTenant"
+        result == "resolvedTenant"
+    }
+
+    def "test withoutId executes without tenant context"() {
+        given: "A mock datastore"
+        Datastore datastore = Mock(MultiTenantCapableDatastore)
+        ((MultiTenantCapableDatastore)datastore).getMultiTenancyMode() >> MultiTenancySettings.MultiTenancyMode.DATABASE
+        ((MultiTenantCapableDatastore)datastore).withNewSession(_ as Serializable, _ as Closure) >> { Serializable tenantId, Closure callable ->
+            callable.call(null)
+        }
+
+        when: "Executing withoutId"
+        def result = Tenants.withoutId((MultiTenantCapableDatastore)datastore) {
+            return CurrentTenantHolder.get(datastore)
+        }
+
+        then: "The current tenant is the default one"
+        result == "default"
+    }
+
+    
+}
diff --git a/grails-datamapping-core/src/test/groovy/grails/gorm/services/transform/ServiceTransformClasses.groovy b/grails-datamapping-core/src/test/groovy/grails/gorm/services/transform/ServiceTransformClasses.groovy
new file mode 100644
index 0000000000..bdfd059142
--- /dev/null
+++ b/grails-datamapping-core/src/test/groovy/grails/gorm/services/transform/ServiceTransformClasses.groovy
@@ -0,0 +1,382 @@
+/*
+ *  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 grails.gorm.services.transform
+
+import grails.gorm.annotation.Entity
+import grails.gorm.services.Service
+import grails.gorm.services.Query
+import grails.gorm.services.Where
+import grails.gorm.services.Join
+import grails.gorm.multitenancy.CurrentTenant
+import grails.gorm.transactions.Transactional
+import org.grails.datastore.gorm.GormEntity
+import jakarta.persistence.criteria.JoinType
+
+@Entity
+class XProj implements GormEntity<XProj> {
+    String a
+    String b
+}
+
+interface IXProj {
+    String getB()
+}
+
+@Service(XProj)
+interface XServiceProj {
+    IXProj getX(String a)
+}
+
+interface HasTitleMarker {
+    String getTitle()
+}
+
+@Entity
+class ArticleMarker implements HasTitleMarker {
+    String title
+    String subtitle
+}
+
+interface ArticleProjectionMarker {
+    String getTitle()
+}
+
+@Service(ArticleMarker)
+interface ArticleServiceMarker {
+    ArticleProjectionMarker getArticle(String title)
+}
+
+@Entity
+class XTenant {
+    String a
+    String b
+}
+
+@Service(XTenant)
+@CurrentTenant
+interface XServiceTenant {
+    XTenant getX(String a)
+}
+
+@Entity
+class FooProt {
+    String title
+}
+
+@Service(FooProt)
+abstract class AbstractMyServiceProt {
+
+    FooProt searchFoo(Serializable id) {
+        find(id)
+    }
+
+    protected abstract FooProt find(Serializable id)
+}
+
+@Entity
+class FooGen {
+    String title
+}
+
+@Service(FooGen)
+interface MyServiceGen {
+    List<FooGen> findByTitleLike(String title)
+}
+
+@Entity
+class FooQ {
+    String title
+    String name
+}
+
+interface IFooQ {
+    String getTitle()
+}
+
+@Service(FooQ)
+interface MyServiceQ {
+    @Query('from FooQ as f where f.title like $title')
+    IFooQ search(String title)
+}
+
+@Entity
+class FooP {
+    String title
+    String name
+}
+
+interface IFooP {
+    String getTitle()
+}
+
+@Service(FooP)
+interface MyServiceP {
+    IFooP find(String title)
+}
+
+@Entity
+class FooL {
+    String title
+    String name
+}
+
+interface IFooL {
+    String getTitle()
+}
+
+@Service(FooL)
+interface MyServiceL {
+    List<IFooL> find(String title)
+}
+
+@Entity
+class FooD {
+    String title
+    String name
+}
+
+interface IFooD {
+    String getTitle()
+}
+
+@Service(FooD)
+interface MyServiceD {
+    IFooD findByTitle(String title)
+}
+
+@Entity
+class FooA {
+    String title
+}
+
+@Service(FooA)
+interface MyServiceA {
+    List<FooA> listFoos()
+}
+
+@Entity
+class BarJ {
+
+}
+
+@Entity
+class FooJ {
+    String title
+    static hasMany = [bars:BarJ]
+}
+
+@Service(FooJ)
+interface MyJoinServiceJ {
+    @Join('bars')
+    FooJ find(String title)
+}
+
+@Entity
+class BarJ2 {
+
+}
+
+@Entity
+class FooJ2 {
+    String title
+    static hasMany = [bars:BarJ2]
+}
+
+@Service(FooJ2)
+interface MyJoinServiceJ2 {
+    @Join(value='bars', type=JoinType.LEFT)
+    FooJ2 findFoo(String title)
+}
+
+@Entity
+class FooS {
+    String title
+}
+
+@Service(FooS)
+interface MyServiceS {
+
+    @Query('from FooS as f where f.title like $pattern')
+    FooS searchByTitle(String pattern)
+}
+
+@Entity
+class FooProj {
+    String title
+    int age
+}
+
+@Service(FooProj)
+abstract class MyServiceProj {
+
+    @Query('select max(${f.age}) from ${FooProj f} where f.title like $pattern')
+    abstract Object searchByTitle(String pattern)
+}
+
+@Entity
+class FooU {
+    String title
+}
+
+@Service(FooU)
+interface MyServiceU {
+
+    @Query('update ${FooU foo} set ${foo.title} = $newTitle where ${foo.title} = $oldTitle')
+    Number updateTitle(String newTitle, String oldTitle)
+
+    @Query('delete ${FooU foo} where ${foo.title} = $title')
+    void kill(String title)
+}
+
+@Entity
+class FooI {
+    String title
+}
+
+@Service(FooI)
+interface MyServiceI {
+
+    @Query('update ${FooI foo} set ${foo.title} = $newTitle where $foo.id = $id')
+    Number updateTitle(String newTitle, Long id)
+
+    @Query('delete ${FooI foo} where ${foo.title} = $title')
+    void kill(String title)
+}
+
+@Entity
+class FooT {
+    String title
+}
+
+@Service(FooT)
+@Transactional("foo")
+interface MyServiceT {
+
+    @Query('update ${FooT foo} set ${foo.title} = $newTitle where ${foo.title} = $oldTitle')
+    Number updateTitle(String newTitle, String oldTitle)
+
+    @Query('delete ${FooT foo} where ${foo.title} = $title')
+    void kill(String title)
+}
+
+@Entity
+class FooV {
+    String title
+}
+
+@Service(FooV)
+interface MyServiceV {
+
+    @Query('select $f.title from ${FooV f} where $f.title like $pattern')
+    List<String> searchByTitle(String pattern)
+}
+
+@Entity
+class FooW {
+    String title
+}
+
+@Service(FooW)
+interface MyServiceW {
+
+    @Where({ title == pattern })
+    FooW searchByTitle(String pattern)
+}
+
+@Entity
+class FooAbs {
+    String title
+}
+
+interface MyServiceInterface {
+    Number deleteMoreFoos(String title)
+
+    void deleteFoos(String title)
+
+    FooAbs delete(Serializable id)
+
+    List<FooAbs> listFoos()
+
+    FooAbs[] listMoreFoos()
+
+    Iterable<FooAbs> listEvenMoreFoos()
+
+    List<FooAbs> findByTitle(String title)
+
+    List<FooAbs> findByTitleLike(String title)
+
+    FooAbs saveFoo(String title)
+}
+
+@Service(FooAbs)
+abstract class AbstractMyServiceAbs implements MyServiceInterface {
+
+    FooAbs readFoo(Serializable id) {
+        FooAbs.read(id)
+    }
+
+    @Override
+    FooAbs delete(Serializable id) {
+        def foo = FooAbs.get(id)
+        foo?.delete()
+        foo?.title = "DELETED"
+        return foo
+    }
+}
+
+@Entity
+class FooInterface {
+    String title
+}
+
+@Service(FooInterface)
+interface MyServiceInterfaceOnly {
+    Number deleteMoreFoos(String title)
+
+    void deleteFoos(String title)
+
+    FooInterface delete(Serializable id)
+
+    List<FooInterface> listFoos()
+
+    FooInterface[] listMoreFoos()
+
+    Iterable<FooInterface> listEvenMoreFoos()
+
+    List<FooInterface> findByTitle(String title)
+
+    List<FooInterface> findByTitleLike(String title)
+
+    FooInterface saveFoo(String title)
+}
+
+@Entity
+class ServiceEntity {}
+
+@Service(ServiceEntity)
+class TestServiceBase {
+    void doStuff() {
+    }
+}
+
+@Service(ServiceEntity)
+class TestServiceBase2 {
+    void doStuff() {
+    }
+}
diff --git a/grails-datamapping-core/src/test/groovy/grails/gorm/services/transform/ServiceTransformSpec.groovy b/grails-datamapping-core/src/test/groovy/grails/gorm/services/transform/ServiceTransformSpec.groovy
new file mode 100644
index 0000000000..20de1b9b43
--- /dev/null
+++ b/grails-datamapping-core/src/test/groovy/grails/gorm/services/transform/ServiceTransformSpec.groovy
@@ -0,0 +1,532 @@
+/*
+ *  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 grails.gorm.services.transform
+
+import grails.gorm.multitenancy.CurrentTenant
+import grails.gorm.multitenancy.TenantService
+import grails.gorm.transactions.ReadOnly
+import grails.gorm.transactions.TransactionService
+import grails.gorm.transactions.Transactional
+
+import org.codehaus.groovy.control.MultipleCompilationErrorsException
+
+import org.grails.datastore.gorm.services.Implemented
+import org.grails.datastore.gorm.services.implementers.FindAllImplementer
+import org.grails.datastore.gorm.services.implementers.FindOneInterfaceProjectionImplementer
+import org.grails.datastore.mapping.services.DefaultServiceRegistry
+import org.grails.datastore.mapping.services.ServiceRegistry
+import spock.lang.Specification
+
+/**
+ * Tests for the @Service transformation
+ */
+class ServiceTransformSpec extends Specification {
+
+    void setup() {
+        def entities = [XProj, ArticleMarker, XTenant, FooProt, FooGen, FooQ, FooP, FooL, FooD, FooA, FooJ, BarJ, FooJ2, BarJ2, FooS, FooProj, FooU, FooI, FooT, FooV, FooW, FooAbs, FooInterface, ServiceEntity]
+        new org.grails.datastore.mapping.simple.SimpleMapDatastore(entities as Class[])
+    }
+
+    def cleanup() {
+        org.grails.datastore.gorm.GormRegistry.reset()
+    }
+
+    void "test interface projection with an entity that implements GormEntity"() {
+        given:
+        Class service = XServiceProj
+
+        expect:
+        def impl = service.classLoader.loadClass("${service.package.name}.\$${service.simpleName}Implementation")
+        impl != null
+        impl.getMethod("getX", String).getAnnotation(Implemented).by() == FindOneInterfaceProjectionImplementer
+    }
+
+    void "test interface projection with an entity that implements a marker interface"() {
+        given:
+        Class service = ArticleServiceMarker
+
+        expect:
+        def impl = service.classLoader.loadClass("${service.package.name}.\$${service.simpleName}Implementation")
+        impl != null
+        impl.getMethod("getArticle", String).getAnnotation(Implemented).by() == FindOneInterfaceProjectionImplementer
+    }
+
+    void "test service transformation with @CurrentTenant"() {
+        given:
+        Class service = XServiceTenant
+
+        expect:
+        def impl = service.classLoader.loadClass("${service.package.name}.\$${service.simpleName}Implementation")
+        impl != null
+        impl.getAnnotation(CurrentTenant) != null
+    }
+
+    void "test service transform on abstract protected methods"() {
+        given:
+        Class service = AbstractMyServiceProt
+
+        expect:
+        !service.isInterface()
+
+        when:"the impl is obtained"
+        Class impl = service.classLoader.loadClass("${service.package.name}.\$${service.simpleName}Implementation")
+
+        then:"The impl is valid"
+        impl.getMethod("searchFoo", Serializable).getAnnotation(ReadOnly) == null
+        impl.getDeclaredMethod("find", Serializable).getAnnotation(ReadOnly) == null
+        impl.getDeclaredMethod("find", Serializable).getAnnotation(grails.gorm.transactions.NotTransactional) != null
+    }
+
+    void "test service transform with generics"() {
+        given:
+        Class service = MyServiceGen
+
+        expect:
+        service.isInterface()
+
+        when:"the impl is obtained"
+        Class impl = service.classLoader.loadClass("${service.package.name}.\$${service.simpleName}Implementation")
+
+        then:"The impl is valid"
+        impl.getMethod("findByTitleLike", String).getAnnotation(ReadOnly) != null
+    }
+
+    void "test interface projection with @Query"() {
+        given:
+        Class service = MyServiceQ
+
+        expect:
+        service.isInterface()
+
+        when:"the impl is obtained"
+        Class impl = service.classLoader.loadClass("${service.package.name}.\$${service.simpleName}Implementation")
+
+        then:"The impl is valid"
+        impl.getMethod("search", String).getAnnotation(ReadOnly) != null
+    }
+
+    void "test interface projection"() {
+        given:
+        Class service = MyServiceP
+
+        expect:
+        service.isInterface()
+
+        when:"the impl is obtained"
+        Class impl = service.classLoader.loadClass("${service.package.name}.\$${service.simpleName}Implementation")
+
+        then:"The impl is valid"
+        impl.getMethod("find", String).getAnnotation(ReadOnly) != null
+    }
+
+    void "test interface projection that returns a list"() {
+        given:
+        Class service = MyServiceL
+
+        expect:
+        service.isInterface()
+
+        when:"the impl is obtained"
+        Class impl = service.classLoader.loadClass("${service.package.name}.\$${service.simpleName}Implementation")
+
+        then:"The impl is valid"
+        impl.getMethod("find", String).getAnnotation(ReadOnly) != null
+    }
+
+    void "test interface projection with dynamic finder"() {
+        given:
+        Class service = MyServiceD
+
+        expect:
+        service.isInterface()
+
+        when:"the impl is obtained"
+        Class impl = service.classLoader.loadClass("${service.package.name}.\$${service.simpleName}Implementation")
+
+        then:"The impl is valid"
+        impl.getMethod("findByTitle", String).getAnnotation(ReadOnly) != null
+    }
+
+    void "test findAll with generics"() {
+        given:
+        Class service = MyServiceA
+
+        expect:
+        service.isInterface()
+
+        when:"the impl is obtained"
+        Class impl = service.classLoader.loadClass("${service.package.name}.\$${service.simpleName}Implementation")
+
+        then:"The impl is valid"
+        impl.getMethod("listFoos").getAnnotation(ReadOnly) != null
+        impl.getMethod("listFoos").getAnnotation(Implemented).by() == FindAllImplementer
+    }
+
+    void "test @Join on finder"() {
+        given:
+        Class serviceClass = MyJoinServiceJ
+
+        expect:
+        serviceClass.isInterface()
+
+        when:"the impl is obtained"
+        Class impl = serviceClass.classLoader.loadClass("${serviceClass.package.name}.\$${serviceClass.simpleName}Implementation")
+
+        then:"The impl is valid"
+        impl.getMethod("find", String).getAnnotation(ReadOnly) != null
+
+        when:"the second impl is obtained"
+        Class serviceClass2 = MyJoinServiceJ2
+        Class impl2 = serviceClass2.classLoader.loadClass("${serviceClass2.package.name}.\$${serviceClass2.simpleName}Implementation")
+
+        then:"The second impl is valid"
+        impl2.getMethod("findFoo", String).getAnnotation(ReadOnly) != null
+    }
+
+    void "test @Query invalid property"() {
+        when:"The service transform is applied to an interface it can't implement"
+        new GroovyClassLoader().parseClass('''
+import grails.gorm.services.*
+import grails.gorm.annotation.Entity
+
+@Service(FooInv)
+interface MyServiceInv {
+    @Query('from FooInv as f where f.title like $wrong') 
+    Integer searchByTitle(String pattern)
+}
+@Entity
+class FooInv {
+    String title
+}
+''')
+
+        then:"A compilation error occurred"
+        def e = thrown(MultipleCompilationErrorsException)
+        e.message.normalize().contains "Invalid property [wrong] of domain class [FooInv] in query."
+    }
+
+    void "test @Query invalid domain"() {
+        when:"The service transform is applied to an interface it can't implement"
+        new GroovyClassLoader().parseClass('''
+import grails.gorm.services.*
+import grails.gorm.annotation.Entity
+
+@Service(FooInvD)
+interface MyServiceInvD {
+
+    @Query('from java.lang.String as f where f.title like $pattern') 
+    Integer searchByTitle(String pattern)
+}
+@Entity
+class FooInvD {
+    String title
+}
+''')
+
+        then:"A compilation error occurred"
+        def e = thrown(MultipleCompilationErrorsException)
+        e.message.normalize().contains "Invalid query class [java.lang.String]. Referenced classes in queries must be domain classes"
+    }
+
+    void "test simple @Query annotation"() {
+        given:
+        Class service = MyServiceS
+
+        expect:
+        service.isInterface()
+
+        when:"the impl is obtained"
+        Class impl = service.classLoader.loadClass("${service.package.name}.\$${service.simpleName}Implementation")
+
+        then:"The impl is valid"
+        org.grails.datastore.mapping.services.Service.isAssignableFrom(impl)
+    }
+
+    void "test @Query annotation with projection"() {
+        given:
+        Class service = MyServiceProj
+
+        expect:
+        !service.isInterface()
+
+        when:"the impl is obtained"
+        Class impl = service.classLoader.loadClass("${service.package.name}.\$${service.simpleName}Implementation")
+
+        then:"The impl is valid"
+        org.grails.datastore.mapping.services.Service.isAssignableFrom(impl)
+    }
+
+
+    void "test @Query update annotation"() {
+        given:
+        Class service = MyServiceU
+
+        expect:
+        service.isInterface()
+
+        when:"the impl is obtained"
+        Class impl = service.classLoader.loadClass("${service.package.name}.\$${service.simpleName}Implementation")
+
+        then:"The impl is valid"
+        impl.getMethod("updateTitle", String, String).getAnnotation(Transactional) != null
+    }
+
+    void "test @Query update annotation using id attribute"() {
+        given:
+        Class service = MyServiceI
+
+        expect:
+        service.isInterface()
+
+        when:"the impl is obtained"
+        Class impl = service.classLoader.loadClass("${service.package.name}.\$${service.simpleName}Implementation")
+
+        then:"The impl is valid"
+        impl.getMethod("updateTitle", String, Long).getAnnotation(Transactional) != null
+    }
+
+
+    void "test @Query update annotation with default transaction attributes at class level"() {
+        given:
+        Class service = MyServiceT
+
+        expect:
+        service.isInterface()
+
+        when:"the impl is obtained"
+        Class impl = service.classLoader.loadClass("${service.package.name}.\$${service.simpleName}Implementation")
+        def instance = impl.newInstance()
+
+        then:"The impl is valid"
+        impl.getAnnotation(Transactional).value() == "foo"
+        org.grails.datastore.mapping.services.Service.isAssignableFrom(impl)
+
+        when:
+        instance.kill("blah")
+
+        then:
+        thrown(RuntimeException)
+    }
+
+    void "test @Query annotation with declared variables"() {
+        given:
+        Class service = MyServiceV
+
+        expect:
+        service.isInterface()
+
+        when:"the impl is obtained"
+        Class impl = service.classLoader.loadClass("${service.package.name}.\$${service.simpleName}Implementation")
+
+        then:"The impl is valid"
+        org.grails.datastore.mapping.services.Service.isAssignableFrom(impl)
+    }
+
+
+    void "test @Query invalid variable property"() {
+        when:"The service transform is applied to an interface it can't implement"
+        new GroovyClassLoader().parseClass('''
+import grails.gorm.services.*
+import grails.gorm.annotation.Entity
+
+@Service(FooInvV)
+interface MyServiceInvV {
+
+    @Query('select f.wrong from ${FooInvV f} where f.title like $pattern') 
+    Integer searchByTitle(String pattern)
+}
+@Entity
+class FooInvV {
+    String title
+}
+''')
+
+        then:"A compilation error occurred"
+        def e = thrown(MultipleCompilationErrorsException)
+        e.message.normalize().contains "Invalid property [wrong] of domain class [FooInvV] in query."
+    }
+
+    void "test @Where annotation"() {
+        given:
+        Class service = MyServiceW
+
+        expect:
+        service.isInterface()
+
+        when:"the impl is obtained"
+        Class impl = service.classLoader.loadClass("${service.package.name}.\$${service.simpleName}Implementation")
+
+        then:"The impl is valid"
+        org.grails.datastore.mapping.services.Service.isAssignableFrom(impl)
+    }
+
+    void "test implement abstract class"() {
+        given:
+        Class service = AbstractMyServiceAbs
+
+        expect:
+        !service.isInterface()
+
+        when:"the impl is obtained"
+        Class impl = service.classLoader.loadClass("${service.package.name}.\$${service.simpleName}Implementation")
+
+        then:"The impl is valid"
+        impl.getMethod("deleteMoreFoos", String).getAnnotation(Transactional) != null
+        impl.getMethod("delete", Serializable).getAnnotation(Transactional) != null
+        impl.getMethod("deleteFoos", String).getAnnotation(Transactional) != null
+        impl.getMethod("listFoos").getAnnotation(ReadOnly) != null
+        impl.getMethod("listMoreFoos").getAnnotation(ReadOnly) != null
+        impl.getMethod("listEvenMoreFoos").getAnnotation(ReadOnly) != null
+        impl.getMethod("findByTitle", String).getAnnotation(ReadOnly) != null
+        impl.getMethod("findByTitleLike", String).getAnnotation(ReadOnly) != null
+        impl.getMethod("saveFoo", String).getAnnotation(Transactional) != null
+
+
+        when:"the implementation is instantiated"
+        def inst = impl.newInstance()
+
+        then:"the results are valid"
+        inst != null
+
+        when:"a method is called that requires a datastore"
+        org.grails.datastore.gorm.GormRegistry.reset()
+        inst.saveFoo("test")
+
+        then:"an exception is thrown if no datastore is present"
+        def e = thrown(IllegalStateException)
+        e.message.contains 'No GORM implementations configured'
+    }
+
+    void "test implement interface"() {
+        given:
+        Class service = MyServiceInterfaceOnly
+
+        expect:
+        service.isInterface()
+
+        when:"the impl is obtained"
+        Class impl = service.classLoader.loadClass("${service.package.name}.\$${service.simpleName}Implementation")
+
+        then:"The impl is valid"
+        impl.getMethod("deleteMoreFoos", String).getAnnotation(Transactional) != null
+        impl.getMethod("delete", Serializable).getAnnotation(Transactional) != null
+        impl.getMethod("deleteFoos", String).getAnnotation(Transactional) != null
+        impl.getMethod("listFoos").getAnnotation(ReadOnly) != null
+        impl.getMethod("listMoreFoos").getAnnotation(ReadOnly) != null
+        impl.getMethod("listEvenMoreFoos").getAnnotation(ReadOnly) != null
+        impl.getMethod("findByTitle", String).getAnnotation(ReadOnly) != null
+        impl.getMethod("findByTitleLike", String).getAnnotation(ReadOnly) != null
+        impl.getMethod("saveFoo", String).getAnnotation(Transactional) != null
+
+
+        when:"the implementation is instantiated"
+        def inst = impl.newInstance()
+
+        then:"the results are valid"
+        inst != null
+
+        when:"a method is called that requires a datastore"
+        org.grails.datastore.gorm.GormRegistry.reset()
+        inst.saveFoo("test")
+
+        then:"an exception is thrown if no datastore is present"
+        def e = thrown(IllegalStateException)
+        e.message.contains 'No GORM implementations configured'
+    }
+
+    void "test service transform applied to interface that can't be implemented"() {
+        when:"The service transform is applied to an interface it can't implement"
+        new GroovyClassLoader().parseClass('''
+import grails.gorm.services.*
+import grails.gorm.annotation.Entity
+
+@Service(FooCant)
+interface MyServiceCant {
+    void doStuff(String pattern)
+}
+@Entity
+class FooCant {
+    String title
+}
+''')
+
+        then:"A compilation error occurred"
+        def e = thrown(MultipleCompilationErrorsException)
+        e.message.normalize().contains "No implementations possible for method 'void doStuff(java.lang.String)'"
+    }
+
+    void "test service transform applied with a dynamic finder for a non-existent property"() {
+        when:"The service transform is applied to an interface it can't implement"
+        new GroovyClassLoader().parseClass('''
+import grails.gorm.services.*
+import grails.gorm.annotation.Entity
+
+@Service(FooNone)
+interface MyServiceNone {
+    FooNone findByNonsense(String pattern)
+}
+@Entity
+class FooNone {
+    String title
+}
+''')
+
+        then:"A compilation error occurred"
+        def e = thrown(MultipleCompilationErrorsException)
+        e.message.normalize().contains "Cannot implement finder for non-existent property [nonsense] of class [FooNone]"
+    }
+
+    void "test service transform applied with a dynamic finder for a property of the wrong type"() {
+        when:"The service transform is applied to an interface it can't implement"
+        new GroovyClassLoader().parseClass('''
+import grails.gorm.services.*
+import grails.gorm.annotation.Entity
+
+@Service(FooWrong)
+interface MyServiceWrong {
+    FooWrong findByTitle(Integer pattern)
+}
+@Entity
+class FooWrong {
+    String title
+}
+''')
+
+        then:"A compilation error occurred"
+        def e = thrown(MultipleCompilationErrorsException)
+        e.message.normalize().contains "Cannot implement dynamic finder [findByTitle] for domain class [FooWrong]. The property [title] has type [java.lang.String] which is not compatible with the argument type [java.lang.Integer]."
+    }
+
+    void "test service transform"() {
+        given:
+        def TestService = TestServiceBase
+        def TestService2 = TestServiceBase2
+        def datastore = new org.grails.datastore.mapping.simple.SimpleMapDatastore(ServiceEntity)
+        ServiceRegistry reg = new DefaultServiceRegistry(datastore, false)
+        reg.initialize()
+
+        expect:
+        org.grails.datastore.mapping.services.Service.isAssignableFrom(TestService)
+        reg.getService(TestService) != null
+        reg.getService(TestService2) != null
+        reg.getService(TestService).datastore != null
+        reg.getService(TransactionService) != null
+        reg.getService(TenantService) != null
+    }
+}
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
new file mode 100644
index 0000000000..bdccc59d48
--- /dev/null
+++ b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/AbstractGormApiRegistrySpec.groovy
@@ -0,0 +1,178 @@
+/*
+ *  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.datastore.gorm
+
+import org.grails.datastore.mapping.core.Datastore
+import org.grails.datastore.mapping.core.connections.ConnectionSource
+import spock.lang.Specification
+
+class AbstractGormApiRegistrySpec extends Specification {
+
+    void setup() {
+        GormRegistry.reset()
+    }
+
+    void cleanup() {
+        GormRegistry.reset()
+    }
+
+    void "test register and get"() {
+        given:
+        def registry = GormRegistry.instance
+        def testRegistry = new TestGormApiRegistry(registry)
+        def api = new DummyApi(Stub(Datastore))
+
+        when: "registering a valid api"
+        testRegistry.register(TestEntity.name, api)
+
+        then:
+        testRegistry.get(TestEntity.name) == api
+        testRegistry.size() == 1
+        testRegistry.containsKey(TestEntity.name)
+        testRegistry.keySet().contains(TestEntity.name)
+
+        when: "registering with null class name"
+        testRegistry.register("   ", new DummyApi(Stub(Datastore)))
+
+        then: "it is ignored"
+        testRegistry.size() == 1
+
+        when: "registering with null api"
+        testRegistry.register("SomeOtherClass", null)
+
+        then: "it is ignored"
+        testRegistry.size() == 1
+    }
+
+    void "test get with qualifier"() {
+        given:
+        def registry = GormRegistry.instance
+        def testRegistry = new TestGormApiRegistry(registry)
+        def defaultDatastore = Stub(Datastore)
+        def secondaryDatastore = Stub(Datastore)
+        
+        def api = new DummyApi(defaultDatastore)
+        testRegistry.register(TestEntity.name, api)
+        
+        registry.registerDatastore(ConnectionSource.DEFAULT, defaultDatastore)
+        registry.registerDatastore("secondary", secondaryDatastore)
+        
+        registry.registerEntityDatastore(TestEntity.name, ConnectionSource.DEFAULT, defaultDatastore)
+        registry.registerEntityDatastore(TestEntity.name, "secondary", secondaryDatastore)
+
+        when: "getting with default qualifier"
+        def defaultApi = testRegistry.get(TestEntity.name, ConnectionSource.DEFAULT)
+
+        then:
+        defaultApi == api
+
+        when: "getting with secondary qualifier"
+        def secondaryApi = testRegistry.get(TestEntity.name, "secondary")
+
+        then:
+        secondaryApi != api
+        secondaryApi instanceof AbstractDatastoreApi
+        testRegistry.qualifiedApi != null // To ensure qualify was called
+    }
+
+    void "test get with qualifier when datastore is the same"() {
+        given:
+        def registry = GormRegistry.instance
+        def testRegistry = new TestGormApiRegistry(registry)
+        def defaultDatastore = Stub(Datastore)
+        
+        def api = new DummyApi(defaultDatastore)
+        testRegistry.register(TestEntity.name, api)
+        
+        registry.registerDatastore(ConnectionSource.DEFAULT, defaultDatastore)
+        registry.registerDatastore("secondary", defaultDatastore)
+        
+        registry.registerEntityDatastore(TestEntity.name, ConnectionSource.DEFAULT, defaultDatastore)
+        registry.registerEntityDatastore(TestEntity.name, "secondary", defaultDatastore)
+
+        when: "getting with secondary qualifier but datastore is identical"
+        def secondaryApi = testRegistry.get(TestEntity.name, "secondary")
+
+        then: "the original api is returned without calling qualify"
+        secondaryApi == api
+    }
+
+    void "test clear"() {
+        given:
+        def testRegistry = new TestGormApiRegistry(GormRegistry.instance)
+        testRegistry.register(TestEntity.name, new DummyApi(Stub(Datastore)))
+
+        when:
+        testRegistry.clear()
+
+        then:
+        testRegistry.size() == 0
+        !testRegistry.containsKey(TestEntity.name)
+    }
+
+    void "test className helper"() {
+        given:
+        def testRegistry = new TestGormApiRegistry(GormRegistry.instance)
+
+        expect:
+        testRegistry.getClassName(TestEntity) == TestEntity.name
+    }
+
+    void "test stateException helper"() {
+        given:
+        def testRegistry = new TestGormApiRegistry(GormRegistry.instance)
+
+        when:
+        def ex = testRegistry.getStateException(TestEntity)
+
+        then:
+        ex.message == "No GORM implementation configured for class [${TestEntity.name}]. Ensure GORM has been initialized correctly"
+    }
+
+    static class TestEntity {
+    }
+    
+    static class DummyApi extends AbstractDatastoreApi {
+        DummyApi(Datastore ds) {
+            super(ds)
+        }
+    }
+
+    static class TestGormApiRegistry extends AbstractGormApiRegistry<AbstractDatastoreApi> {
+        AbstractDatastoreApi qualifiedApi
+
+        TestGormApiRegistry(GormRegistry registry) {
+            super(registry)
+        }
+
+        @Override
+        protected AbstractDatastoreApi qualify(AbstractDatastoreApi api, String qualifier) {
+            qualifiedApi = new DummyApi(api.datastore)
+            return qualifiedApi
+        }
+
+        String getClassName(Class entity) {
+            return className(entity)
+        }
+
+        IllegalStateException getStateException(Class entity) {
+            return stateException(entity)
+        }
+    }
+}
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
new file mode 100644
index 0000000000..5a5c788301
--- /dev/null
+++ b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/ActiveSessionDatastoreSelectorSpec.groovy
@@ -0,0 +1,73 @@
+/*
+ *  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.datastore.gorm
+
+import org.grails.datastore.mapping.core.Datastore
+import org.grails.datastore.mapping.core.connections.ConnectionSource
+import spock.lang.Specification
+
+class ActiveSessionDatastoreSelectorSpec extends Specification {
+
+    void setup() {
+        GormRegistry.reset()
+    }
+
+    void cleanup() {
+        GormRegistry.reset()
+    }
+
+    void 'select returns active datastore when it matches the registered class datastore'() {
+        given:
+        def selector = new ActiveSessionDatastoreSelector()
+        GormRegistry registry = GormRegistry.instance
+        Datastore activeDatastore = Mock(Datastore) {
+            hasCurrentSession() >> true
+        }
+        registry.registerDatastore(ConnectionSource.DEFAULT, activeDatastore)
+
+        expect:
+        selector.select(registry, TestEntity.name).is(activeDatastore)
+    }
+
+    void 'select returns the only active datastore when no class name is supplied'() {
+        given:
+        def selector = new ActiveSessionDatastoreSelector()
+        GormRegistry registry = GormRegistry.instance
+        Datastore activeDatastore = Mock(Datastore) {
+            hasCurrentSession() >> true
+        }
+        registry.registerDatastoreByType(activeDatastore)
+
+        expect:
+        selector.select(registry, null).is(activeDatastore)
+    }
+
+    void 'select returns null when no active datastore matches'() {
+        given:
+        def selector = new ActiveSessionDatastoreSelector()
+        GormRegistry registry = GormRegistry.instance
+        registry.registerDatastore(ConnectionSource.DEFAULT, Mock(Datastore))
+
+        expect:
+        selector.select(registry, TestEntity.name) == null
+    }
+
+    private static class TestEntity {
+    }
+}
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
new file mode 100644
index 0000000000..d394c36196
--- /dev/null
+++ b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/ConnectionSourceNameResolverSpec.groovy
@@ -0,0 +1,175 @@
+/*
+ *  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.datastore.gorm
+
+import org.grails.datastore.mapping.core.connections.ConnectionSource
+import org.grails.datastore.mapping.core.connections.ConnectionSources
+import org.grails.datastore.mapping.core.connections.ConnectionSourcesProvider
+import spock.lang.Specification
+
+/**
+ * Tests for {@link ConnectionSourceNameResolver}
+ *
+ * @author Graeme Rocher
+ */
+class ConnectionSourceNameResolverSpec extends Specification {
+
+    def "resolveConnectionSourceNames returns default when datastore is not a provider"() {
+        given:
+        Object datastore = new Object()
+
+        when:
+        List<String> names = ConnectionSourceNameResolver.resolveConnectionSourceNames(datastore)
+
+        then:
+        names == [ConnectionSource.DEFAULT]
+    }
+
+    def "resolveConnectionSourceNames returns default when connectionSources is null"() {
+        given:
+        ConnectionSourcesProvider provider = Mock {
+            getConnectionSources() >> null
+        }
+
+        when:
+        List<String> names = ConnectionSourceNameResolver.resolveConnectionSourceNames(provider)
+
+        then:
+        names == [ConnectionSource.DEFAULT]
+    }
+
+    def "resolveConnectionSourceNames returns names from collection"() {
+        given:
+        ConnectionSource cs1 = Mock { getName() >> 'db1' }
+        ConnectionSource cs2 = Mock { getName() >> 'db2' }
+        List<ConnectionSource> sources = [cs1, cs2]
+
+        ConnectionSources connectionSources = Mock {
+            getAllConnectionSources() >> sources
+        }
+
+        ConnectionSourcesProvider provider = Mock {
+            getConnectionSources() >> connectionSources
+        }
+
+        when:
+        List<String> names = ConnectionSourceNameResolver.resolveConnectionSourceNames(provider)
+
+        then:
+        names == ['db1', 'db2']
+    }
+
+    def "resolveConnectionSourceNames returns default when iterable is empty"() {
+        given:
+        ConnectionSources connectionSources = Mock {
+            getAllConnectionSources() >> []
+        }
+
+        ConnectionSourcesProvider provider = Mock {
+            getConnectionSources() >> connectionSources
+        }
+
+        when:
+        List<String> names = ConnectionSourceNameResolver.resolveConnectionSourceNames(provider)
+
+        then:
+        names == [ConnectionSource.DEFAULT]
+    }
+
+    def "resolveConnectionSourceNames handles non-collection iterable"() {
+        given:
+        ConnectionSource cs1 = Mock { getName() >> 'db1' }
+        ConnectionSource cs2 = Mock { getName() >> 'db2' }
+        Iterable<ConnectionSource> iterable = [cs1, cs2] as Iterable
+
+        ConnectionSources connectionSources = Mock {
+            getAllConnectionSources() >> iterable
+        }
+
+        ConnectionSourcesProvider provider = Mock {
+            getConnectionSources() >> connectionSources
+        }
+
+        when:
+        List<String> names = ConnectionSourceNameResolver.resolveConnectionSourceNames(provider)
+
+        then:
+        names == ['db1', 'db2']
+    }
+
+    def "resolveDefaultConnectionSourceName returns default when datastore is not a provider"() {
+        given:
+        Object datastore = new Object()
+
+        when:
+        String name = ConnectionSourceNameResolver.resolveDefaultConnectionSourceName(datastore)
+
+        then:
+        name == ConnectionSource.DEFAULT
+    }
+
+    def "resolveDefaultConnectionSourceName returns default when connectionSources is null"() {
+        given:
+        ConnectionSourcesProvider provider = Mock {
+            getConnectionSources() >> null
+        }
+
+        when:
+        String name = ConnectionSourceNameResolver.resolveDefaultConnectionSourceName(provider)
+
+        then:
+        name == ConnectionSource.DEFAULT
+    }
+
+    def "resolveDefaultConnectionSourceName returns default connection source name"() {
+        given:
+        ConnectionSource defaultCs = Mock { getName() >> 'primary' }
+
+        ConnectionSources connectionSources = Mock {
+            getDefaultConnectionSource() >> defaultCs
+        }
+
+        ConnectionSourcesProvider provider = Mock {
+            getConnectionSources() >> connectionSources
+        }
+
+        when:
+        String name = ConnectionSourceNameResolver.resolveDefaultConnectionSourceName(provider)
+
+        then:
+        name == 'primary'
+    }
+
+    def "resolveDefaultConnectionSourceName returns default when default connection source is null"() {
+        given:
+        ConnectionSources connectionSources = Mock {
+            getDefaultConnectionSource() >> null
+        }
+
+        ConnectionSourcesProvider provider = Mock {
+            getConnectionSources() >> connectionSources
+        }
+
+        when:
+        String name = ConnectionSourceNameResolver.resolveDefaultConnectionSourceName(provider)
+
+        then:
+        name == ConnectionSource.DEFAULT
+    }
+}
diff --git a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/DefaultDatastoreSelectorSpec.groovy b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/DefaultDatastoreSelectorSpec.groovy
new file mode 100644
index 0000000000..efb68c8c20
--- /dev/null
+++ b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/DefaultDatastoreSelectorSpec.groovy
@@ -0,0 +1,95 @@
+/*
+ *  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.datastore.gorm
+
+import grails.gorm.MultiTenant
+import grails.gorm.multitenancy.CurrentTenantHolder
+import org.grails.datastore.mapping.core.Datastore
+import org.grails.datastore.mapping.core.connections.ConnectionSource
+import org.grails.datastore.mapping.multitenancy.MultiTenancySettings
+import org.grails.datastore.mapping.multitenancy.MultiTenantCapableDatastore
+import org.grails.datastore.mapping.multitenancy.exceptions.TenantNotFoundException
+import spock.lang.Specification
+
+class DefaultDatastoreSelectorSpec extends Specification {
+
+    private final GormEnhancerRegistry stateRegistry = GormEnhancerRegistry.instance
+
+    void setup() {
+        GormRegistry.reset()
+        stateRegistry.clearPreferredDatastore()
+        stateRegistry.clearResolvingDatastoreDepth()
+    }
+
+    void cleanup() {
+        stateRegistry.clearPreferredDatastore()
+        stateRegistry.clearResolvingDatastoreDepth()
+        GormRegistry.reset()
+    }
+
+    void 'select returns default datastore when the current tenant is default'() {
+        given:
+        def selector = new DefaultDatastoreSelector()
+        GormRegistry registry = GormRegistry.instance
+        MultiTenantCapableDatastore defaultDatastore = Mock(MultiTenantCapableDatastore)
+        registry.registerDatastore(ConnectionSource.DEFAULT, defaultDatastore)
+
+        expect:
+        CurrentTenantHolder.withTenant(defaultDatastore, ConnectionSource.DEFAULT) {
+            selector.select(registry, stateRegistry, TenantEntity, TenantEntity.name, 0, new GormApiResolver(registry))
+        }.is(defaultDatastore)
+    }
+
+    void 'select delegates to resolver for a non-default tenant'() {
+        given:
+        def selector = new DefaultDatastoreSelector()
+        GormRegistry registry = GormRegistry.instance
+        Datastore resolvedDatastore = Mock(Datastore)
+        MultiTenantCapableDatastore defaultDatastore = Mock(MultiTenantCapableDatastore)
+        registry.registerDatastore(ConnectionSource.DEFAULT, defaultDatastore)
+        registry.registerDatastore('tenant-1', resolvedDatastore)
+
+        expect:
+        CurrentTenantHolder.withTenant(defaultDatastore, 'tenant-1') {
+            selector.select(registry, stateRegistry, TenantEntity, TenantEntity.name, 0, new GormApiResolver(registry))
+        }.is(resolvedDatastore)
+    }
+
+    void 'select rethrows tenant not found in database mode'() {
+        given:
+        def selector = new DefaultDatastoreSelector()
+        GormRegistry registry = GormRegistry.instance
+        MultiTenantCapableDatastore defaultDatastore = Mock(MultiTenantCapableDatastore) {
+            getMultiTenancyMode() >> MultiTenancySettings.MultiTenancyMode.DATABASE
+            getTenantResolver() >> Stub(org.grails.datastore.mapping.multitenancy.TenantResolver) {
+                resolveTenantIdentifier() >> { throw new TenantNotFoundException('missing') }
+            }
+        }
+        registry.registerDatastore(ConnectionSource.DEFAULT, defaultDatastore)
+
+        when:
+        selector.select(registry, stateRegistry, TenantEntity, TenantEntity.name, 0, new GormApiResolver(registry))
+
+        then:
+        thrown(TenantNotFoundException)
+    }
+
+    private static class TenantEntity implements MultiTenant<TenantEntity> {
+    }
+}
diff --git a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/DefaultGormApiFactorySpec.groovy b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/DefaultGormApiFactorySpec.groovy
new file mode 100644
index 0000000000..57bb25e218
--- /dev/null
+++ b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/DefaultGormApiFactorySpec.groovy
@@ -0,0 +1,63 @@
+/*
+ *  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.datastore.gorm
+
+import org.grails.datastore.mapping.model.MappingContext
+import spock.lang.Specification
+
+class DefaultGormApiFactorySpec extends Specification {
+
+    void 'createInstanceApi applies failOnError and markDirty configuration'() {
+        given:
+        DefaultGormApiFactory factory = new DefaultGormApiFactory()
+        MappingContext mappingContext = Mock(MappingContext)
+        DatastoreResolver resolver = Stub(DatastoreResolver)
+
+        when:
+        GormInstanceApi<TestFactoryEntity> instanceApi = factory.createInstanceApi(
+            TestFactoryEntity,
+            mappingContext,
+            resolver,
+            GormRegistry.instance,
+            true,
+            false
+        )
+
+        then:
+        instanceApi != null
+        instanceApi.failOnError
+        !instanceApi.markDirty
+    }
+
+    void 'createDynamicFinders returns default finder set'() {
+        given:
+        DefaultGormApiFactory factory = new DefaultGormApiFactory()
+        MappingContext mappingContext = Mock(MappingContext)
+        DatastoreResolver resolver = Stub(DatastoreResolver)
+
+        when:
+        def finders = factory.createDynamicFinders(resolver, mappingContext)
+
+        then:
+        finders.size() == 8
+    }
+
+    static class TestFactoryEntity {
+    }
+}
diff --git a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/GormApiFactorySpec.groovy b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/GormApiFactorySpec.groovy
new file mode 100644
index 0000000000..b7fe9894b1
--- /dev/null
+++ b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/GormApiFactorySpec.groovy
@@ -0,0 +1,132 @@
+/*
+ *  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.datastore.gorm
+
+import org.grails.datastore.gorm.finders.FinderMethod
+import org.grails.datastore.mapping.model.MappingContext
+import spock.lang.Specification
+
+/**
+ * Tests for GormApiFactory interface contract
+ */
+class GormApiFactorySpec extends Specification {
+
+    void 'factory creates GormStaticApi instances'() {
+        given:
+        GormApiFactory factory = new MockGormApiFactory()
+        MappingContext mappingContext = Mock(MappingContext)
+        DatastoreResolver resolver = Stub(DatastoreResolver)
+
+        when:
+        GormStaticApi<TestEntity> staticApi = factory.createStaticApi(
+            TestEntity,
+            mappingContext,
+            resolver,
+            'default',
+            GormRegistry.instance
+        )
+
+        then:
+        staticApi != null
+        staticApi instanceof GormStaticApi
+    }
+
+    void 'factory creates GormInstanceApi instances'() {
+        given:
+        GormApiFactory factory = new MockGormApiFactory()
+        MappingContext mappingContext = Mock(MappingContext)
+        DatastoreResolver resolver = Stub(DatastoreResolver)
+
+        when:
+        GormInstanceApi<TestEntity> instanceApi = factory.createInstanceApi(
+            TestEntity,
+            mappingContext,
+            resolver,
+            GormRegistry.instance,
+            true,
+            false
+        )
+
+        then:
+        instanceApi != null
+        instanceApi instanceof GormInstanceApi
+    }
+
+    void 'factory creates GormValidationApi instances'() {
+        given:
+        GormApiFactory factory = new MockGormApiFactory()
+        MappingContext mappingContext = Mock(MappingContext)
+        DatastoreResolver resolver = Stub(DatastoreResolver)
+
+        when:
+        GormValidationApi<TestEntity> validationApi = factory.createValidationApi(
+            TestEntity,
+            mappingContext,
+            resolver,
+            GormRegistry.instance
+        )
+
+        then:
+        validationApi != null
+        validationApi instanceof GormValidationApi
+    }
+
+    void 'factory creates dynamic finders'() {
+        given:
+        GormApiFactory factory = new MockGormApiFactory()
+        MappingContext mappingContext = Mock(MappingContext)
+        DatastoreResolver resolver = Stub(DatastoreResolver)
+
+        when:
+        List<FinderMethod> finders = factory.createDynamicFinders(resolver, mappingContext)
+
+        then:
+        finders != null
+        finders instanceof List
+    }
+
+    static class TestEntity {
+        String name
+    }
+
+    static class MockGormApiFactory implements GormApiFactory {
+        @Override
+        <D> GormStaticApi<D> createStaticApi(Class<D> persistentClass, MappingContext mappingContext, DatastoreResolver resolver, String qualifier, GormRegistry registry) {
+            new GormStaticApi<D>(persistentClass, mappingContext, [], resolver, qualifier, registry)
+        }
+
+        @Override
+        <D> GormInstanceApi<D> createInstanceApi(Class<D> persistentClass, MappingContext mappingContext, DatastoreResolver resolver, GormRegistry registry, boolean failOnError, boolean markDirty) {
+            GormInstanceApi<D> api = new GormInstanceApi<D>(persistentClass, mappingContext, resolver, registry)
+            api.failOnError = failOnError
+            api.markDirty = markDirty
+            return api
+        }
+
+        @Override
+        <D> GormValidationApi<D> createValidationApi(Class<D> persistentClass, MappingContext mappingContext, DatastoreResolver resolver, GormRegistry registry) {
+            new GormValidationApi<D>(persistentClass, mappingContext, resolver, registry)
+        }
+
+        @Override
+        List<FinderMethod> createDynamicFinders(DatastoreResolver datastoreResolver, MappingContext mappingContext) {
+            []
+        }
+    }
+}
diff --git a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/GormApiRegistrySpec.groovy b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/GormApiRegistrySpec.groovy
new file mode 100644
index 0000000000..494bced038
--- /dev/null
+++ b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/GormApiRegistrySpec.groovy
@@ -0,0 +1,123 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.grails.datastore.gorm
+
+import org.grails.datastore.mapping.core.Datastore
+import org.grails.datastore.mapping.core.connections.ConnectionSource
+import org.grails.datastore.mapping.model.MappingContext
+import spock.lang.Specification
+
+class GormApiRegistrySpec extends Specification {
+
+    void setup() {
+        GormRegistry.reset()
+    }
+
+    void cleanup() {
+        GormRegistry.reset()
+    }
+
+    void 'static api registry stores and resolves APIs by qualifier'() {
+        given:
+        def registry = GormRegistry.instance
+        def apiRegistry = registry.staticApiRegistry
+        def (mappingContext, datastore, datastoreResolver) = createContext()
+        def secondaryDatastore = Stub(Datastore)
+        def api = new GormStaticApi(ApiRegistryEntity, mappingContext, [], datastoreResolver, ConnectionSource.DEFAULT, registry)
+
+        when:
+        apiRegistry.register(ApiRegistryEntity.name, api)
+        registry.registerDatastore(ConnectionSource.DEFAULT, datastore)
+        registry.registerDatastore('secondary', secondaryDatastore)
+        registry.registerEntityDatastore(ApiRegistryEntity.name, ConnectionSource.DEFAULT, datastore)
+        registry.registerEntityDatastore(ApiRegistryEntity.name, 'secondary', secondaryDatastore)
+
+        then:
+        apiRegistry.size() == 1
+        apiRegistry.containsKey(ApiRegistryEntity.name)
+        apiRegistry.get(ApiRegistryEntity.name).is(api)
+        apiRegistry.get(ApiRegistryEntity.name, ConnectionSource.DEFAULT).is(api)
+        apiRegistry.get(ApiRegistryEntity.name, 'secondary') instanceof GormStaticApi
+        !apiRegistry.get(ApiRegistryEntity.name, 'secondary').is(api)
+
+        when:
+        apiRegistry.clear()
+
+        then:
+        apiRegistry.size() == 0
+    }
+
+    void 'instance api registry stores and resolves APIs by qualifier'() {
+        given:
+        def registry = GormRegistry.instance
+        def apiRegistry = registry.instanceApiRegistry
+        def (mappingContext, datastore, datastoreResolver) = createContext()
+        def secondaryDatastore = Stub(Datastore)
+        def api = new GormInstanceApi(ApiRegistryEntity, mappingContext, datastoreResolver, registry)
+
+        when:
+        apiRegistry.register(ApiRegistryEntity.name, api)
+        registry.registerDatastore(ConnectionSource.DEFAULT, datastore)
+        registry.registerDatastore('secondary', secondaryDatastore)
+        registry.registerEntityDatastore(ApiRegistryEntity.name, ConnectionSource.DEFAULT, datastore)
+        registry.registerEntityDatastore(ApiRegistryEntity.name, 'secondary', secondaryDatastore)
+
+        then:
+        apiRegistry.size() == 1
+        apiRegistry.get(ApiRegistryEntity.name).is(api)
+        apiRegistry.get(ApiRegistryEntity.name, ConnectionSource.DEFAULT).is(api)
+        apiRegistry.get(ApiRegistryEntity.name, 'secondary') instanceof GormInstanceApi
+    }
+
+    void 'validation api registry stores and resolves APIs by qualifier'() {
+        given:
+        def registry = GormRegistry.instance
+        def apiRegistry = registry.validationApiRegistry
+        def (mappingContext, datastore, datastoreResolver) = createContext()
+        def secondaryDatastore = Stub(Datastore)
+        def api = new GormValidationApi(ApiRegistryEntity, mappingContext, datastoreResolver, registry)
+
+        when:
+        apiRegistry.register(ApiRegistryEntity.name, api)
+        registry.registerDatastore(ConnectionSource.DEFAULT, datastore)
+        registry.registerDatastore('secondary', secondaryDatastore)
+        registry.registerEntityDatastore(ApiRegistryEntity.name, ConnectionSource.DEFAULT, datastore)
+        registry.registerEntityDatastore(ApiRegistryEntity.name, 'secondary', secondaryDatastore)
+
+        then:
+        apiRegistry.size() == 1
+        apiRegistry.get(ApiRegistryEntity.name).is(api)
+        apiRegistry.get(ApiRegistryEntity.name, ConnectionSource.DEFAULT).is(api)
+        apiRegistry.get(ApiRegistryEntity.name, 'secondary') instanceof GormValidationApi
+    }
+
+    private List createContext() {
+        MappingContext mappingContext = Stub(MappingContext)
+        Datastore datastore = Stub(Datastore) {
+            getMappingContext() >> mappingContext
+        }
+        DatastoreResolver datastoreResolver = Stub(DatastoreResolver) {
+            resolve() >> datastore
+        }
+        [mappingContext, datastore, datastoreResolver]
+    }
+
+    static class ApiRegistryEntity {
+    }
+}
diff --git a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/GormApiResolverSpec.groovy b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/GormApiResolverSpec.groovy
new file mode 100644
index 0000000000..79ccc92469
--- /dev/null
+++ b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/GormApiResolverSpec.groovy
@@ -0,0 +1,202 @@
+/*
+ *  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.datastore.gorm
+
+import grails.gorm.MultiTenant
+import org.grails.datastore.mapping.core.Datastore
+import org.grails.datastore.mapping.multitenancy.exceptions.TenantNotFoundException
+import org.grails.datastore.mapping.multitenancy.MultiTenantCapableDatastore
+import org.grails.datastore.mapping.core.connections.ConnectionSource
+import org.springframework.transaction.support.TransactionSynchronizationManager
+import spock.lang.Specification
+
+class GormApiResolverSpec extends Specification {
+    private final GormEnhancerRegistry stateRegistry = GormEnhancerRegistry.instance
+
+    void setup() {
+        GormRegistry.reset()
+        stateRegistry.clearPreferredDatastore()
+        stateRegistry.clearResolvingDatastoreDepth()
+    }
+
+    void cleanup() {
+        if (TransactionSynchronizationManager.hasResource('secondary')) {
+            TransactionSynchronizationManager.unbindResource('secondary')
+        }
+        stateRegistry.clearPreferredDatastore()
+        stateRegistry.clearResolvingDatastoreDepth()
+        GormRegistry.reset()
+    }
+
+    void 'resolver finds datastore by registered type'() {
+        given:
+        GormRegistry registry = GormRegistry.instance
+        GormApiResolver resolver = registry.apiResolver
+        Datastore datastore = Mock(Datastore)
+        registry.registerDatastoreByType(datastore)
+
+        expect:
+        resolver.findDatastoreByType(datastore.getClass()).is(datastore)
+    }
+
+    void 'resolver finds the default datastore for a single configured datastore'() {
+        given:
+        GormRegistry registry = GormRegistry.instance
+        GormApiResolver resolver = registry.apiResolver
+        Datastore datastore = Mock(Datastore)
+        registry.registerDatastore(ConnectionSource.DEFAULT, datastore)
+
+        expect:
+        resolver.findSingleDatastore().is(datastore)
+    }
+
+    void 'resolver resolves datastores by qualifier'() {
+        given:
+        GormRegistry registry = GormRegistry.instance
+        GormApiResolver resolver = registry.apiResolver
+        Datastore defaultDatastore = Mock(Datastore)
+        Datastore secondaryDatastore = Mock(Datastore)
+        registry.registerDatastore(ConnectionSource.DEFAULT, defaultDatastore)
+        registry.registerDatastore('secondary', secondaryDatastore)
+
+        expect:
+        resolver.findDatastore(null, 'secondary').is(secondaryDatastore)
+    }
+
+    void 'resolver returns transaction-bound datastore for explicit qualifier'() {
+        given:
+        GormRegistry registry = GormRegistry.instance
+        GormApiResolver resolver = registry.apiResolver
+        Datastore boundDatastore = Mock(Datastore)
+        TransactionSynchronizationManager.bindResource('secondary', boundDatastore)
+
+        expect:
+        resolver.findDatastore(null, 'secondary').is(boundDatastore)
+    }
+
+    void 'resolver honors preferred datastore for default qualifier'() {
+        given:
+        GormRegistry registry = GormRegistry.instance
+        GormApiResolver resolver = registry.apiResolver
+        Datastore preferredDatastore = Mock(Datastore) {
+            getMappingContext() >> Mock(org.grails.datastore.mapping.model.MappingContext) {
+                getPersistentEntity(TestEntity.name) >> Mock(org.grails.datastore.mapping.model.PersistentEntity)
+            }
+        }
+        stateRegistry.setPreferredDatastore(preferredDatastore)
+
+        expect:
+        resolver.findDatastore(TestEntity, ConnectionSource.DEFAULT).is(preferredDatastore)
+    }
+
+    void 'resolver falls through preferred path to explicit qualifier resolution'() {
+        given:
+        GormRegistry registry = GormRegistry.instance
+        GormApiResolver resolver = registry.apiResolver
+        Datastore preferredDatastore = Mock(Datastore)
+        Datastore secondaryDatastore = Mock(Datastore)
+        stateRegistry.setPreferredDatastore(preferredDatastore)
+        registry.registerDatastore('secondary', secondaryDatastore)
+
+        expect:
+        resolver.findDatastore(null, 'secondary').is(secondaryDatastore)
+    }
+
+    void 'resolver returns default datastore when recursion depth guard is exceeded'() {
+        given:
+        GormRegistry registry = GormRegistry.instance
+        GormApiResolver resolver = registry.apiResolver
+        Datastore defaultDatastore = Mock(Datastore)
+        registry.registerDatastore(ConnectionSource.DEFAULT, defaultDatastore)
+        stateRegistry.setResolvingDatastoreDepth(6)
+
+        expect:
+        resolver.findDatastore(TestEntity, null).is(defaultDatastore)
+    }
+
+    void 'resolver can resolve an active session datastore without a qualifier registration'() {
+        given:
+        GormRegistry registry = GormRegistry.instance
+        GormApiResolver resolver = registry.apiResolver
+        Datastore activeDatastore = Mock(Datastore) {
+            hasCurrentSession() >> true
+        }
+        registry.registerDatastoreByType(activeDatastore)
+
+        expect:
+        resolver.findDatastore(null, null).is(activeDatastore)
+    }
+
+    void 'resolver fails when datastore type is missing'() {
+        given:
+        GormApiResolver resolver = GormRegistry.instance.apiResolver
+
+        when:
+        resolver.findDatastoreByType(Datastore)
+
+        then:
+        IllegalStateException e = thrown()
+        e.message.contains('No GORM implementation configured for type')
+    }
+
+    void 'resolver skips active session datastore for multi-tenant entity if tenant is not resolved'() {
+        given:
+        GormRegistry registry = GormRegistry.instance
+        GormApiResolver resolver = registry.apiResolver
+        
+        Datastore activeDatastore = Mock(MultiTenantCapableDatastore) {
+            hasCurrentSession() >> true
+            getMultiTenancyMode() >> org.grails.datastore.mapping.multitenancy.MultiTenancySettings.MultiTenancyMode.DATABASE
+            getTenantResolver() >> Mock(org.grails.datastore.mapping.multitenancy.TenantResolver) {
+                resolveTenantIdentifier() >> { throw new TenantNotFoundException("No tenant found") }
+            }
+            getMappingContext() >> Mock(org.grails.datastore.mapping.model.MappingContext) {
+                getPersistentEntity(TestMultiTenantEntity.name) >> Mock(org.grails.datastore.mapping.model.PersistentEntity) {
+                    isMultiTenant() >> true
+                }
+            }
+            getConnectionSources() >> Mock(org.grails.datastore.mapping.core.connections.ConnectionSources) {
+                getDefaultConnectionSource() >> Mock(ConnectionSource) {
+                    getName() >> 'someTenant'
+                }
+            }
+        }
+        registry.registerDatastoreByType(activeDatastore)
+
+        Datastore defaultDatastore = Mock(MultiTenantCapableDatastore) {
+            getMultiTenancyMode() >> org.grails.datastore.mapping.multitenancy.MultiTenancySettings.MultiTenancyMode.DATABASE
+            getTenantResolver() >> Mock(org.grails.datastore.mapping.multitenancy.TenantResolver) {
+                resolveTenantIdentifier() >> { throw new TenantNotFoundException("No tenant found") }
+            }
+        }
+        registry.registerDatastore(ConnectionSource.DEFAULT, defaultDatastore)
+
+        when:
+        resolver.findDatastore(TestMultiTenantEntity, null)
+
+        then:
+        thrown(TenantNotFoundException)
+    }
+
+    private static class TestEntity {
+    }
+
+    private static class TestMultiTenantEntity implements MultiTenant {
+    }
+}
diff --git a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/GormInstanceApiRegistrySpec.groovy b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/GormInstanceApiRegistrySpec.groovy
new file mode 100644
index 0000000000..71cc7fe5e0
--- /dev/null
+++ b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/GormInstanceApiRegistrySpec.groovy
@@ -0,0 +1,67 @@
+/*
+ *  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.datastore.gorm
+
+import org.grails.datastore.mapping.core.Datastore
+import org.grails.datastore.mapping.core.connections.ConnectionSource
+import org.grails.datastore.mapping.model.MappingContext
+import spock.lang.Specification
+
+class GormInstanceApiRegistrySpec extends Specification {
+
+    void setup() {
+        GormRegistry.reset()
+    }
+
+    void cleanup() {
+        GormRegistry.reset()
+    }
+
+    void 'findInstanceApi resolves by qualifier'() {
+        given:
+        def registry = GormRegistry.instance
+        def apiRegistry = registry.instanceApiRegistry
+        def context = Stub(MappingContext) {
+            getMappingFactory() >> null
+        }
+        def defaultDatastore = Stub(Datastore) {
+            getMappingContext() >> context
+        }
+        def secondaryDatastore = Stub(Datastore) {
+            getMappingContext() >> context
+        }
+        def resolver = Stub(DatastoreResolver) {
+            resolve() >> defaultDatastore
+        }
+        def api = new GormInstanceApi(ApiRegistryEntity, context, resolver, registry)
+        apiRegistry.register(ApiRegistryEntity.name, api)
+        registry.registerDatastore(ConnectionSource.DEFAULT, defaultDatastore)
+        registry.registerDatastore('secondary', secondaryDatastore)
+        registry.registerEntityDatastore(ApiRegistryEntity.name, ConnectionSource.DEFAULT, defaultDatastore)
+        registry.registerEntityDatastore(ApiRegistryEntity.name, 'secondary', secondaryDatastore)
+
+        expect:
+        apiRegistry.findInstanceApi(ApiRegistryEntity).is(api)
+        apiRegistry.findInstanceApi(ApiRegistryEntity, 'secondary') instanceof GormInstanceApi
+        !apiRegistry.findInstanceApi(ApiRegistryEntity, 'secondary').is(api)
+    }
+
+    static class ApiRegistryEntity {
+    }
+}
diff --git a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/GormInstanceApiSpec.groovy b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/GormInstanceApiSpec.groovy
new file mode 100644
index 0000000000..0c509263bd
--- /dev/null
+++ b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/GormInstanceApiSpec.groovy
@@ -0,0 +1,142 @@
+/*
+ *  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.datastore.gorm
+
+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.model.MappingContext
+import spock.lang.Specification
+
+class GormInstanceApiSpec extends Specification {
+
+    void "save validate false skips validation during persist and restores flag"() {
+        given:
+        Datastore datastore = mockDatastore()
+        Session session = Mock(Session)
+        List<TestValidateableEntity> cleared = []
+        def testValidationApi = new GormValidationApi<TestValidateableEntity>(TestValidateableEntity.class, datastore)
+        testValidationApi.metaClass.clearErrors = { Object entityArg ->
+            Object obj = entityArg
+            if (obj instanceof List) {
+                obj = ((List) obj).get(0)
+            }
+            cleared << (TestValidateableEntity) obj
+        }
+        
+        def registry = new GormRegistry() {
+            @Override
+            <D> GormValidationApi<D> resolveValidationApi(Class<D> entity, String qualifier = null) {
+                return testValidationApi
+            }
+        }
+        
+        def api = new TestGormInstanceApi(datastore, session, registry)
+        def entity = new TestValidateableEntity()
+
+        when:
+        def result = api.save(entity, [validate: false, flush: true])
+
+        then:
+        1 * session.persist(_ as TestValidateableEntity) >> { Object[] args ->
+            Object persisted = args[0]
+            if (persisted instanceof List) {
+                persisted = ((List) persisted).get(0)
+            }
+            assert ((TestValidateableEntity) persisted).shouldSkipValidation()
+        }
+        1 * session.flush()
+        result.is(entity)
+        cleared == [entity]
+        !entity.shouldSkipValidation()
+    }
+
+    void "save validate false preserves preexisting skipValidation state"() {
+        given:
+        Datastore datastore = mockDatastore()
+        Session session = Mock(Session)
+        List<TestValidateableEntity> cleared = []
+        def testValidationApi = new GormValidationApi<TestValidateableEntity>(TestValidateableEntity.class, datastore)
+        testValidationApi.metaClass.clearErrors = { Object entityArg ->
+            Object obj = entityArg
+            if (obj instanceof List) {
+                obj = ((List) obj).get(0)
+            }
+            cleared << (TestValidateableEntity) obj
+        }
+        
+        def registry = new GormRegistry() {
+            @Override
+            <D> GormValidationApi<D> resolveValidationApi(Class<D> entity, String qualifier = null) {
+                return testValidationApi
+            }
+        }
+        
+        def api = new TestGormInstanceApi(datastore, session, registry)
+        def entity = new TestValidateableEntity()
+        entity.skipValidation(true)
+
+        when:
+        def result = api.save(entity, [validate: false])
+
+        then:
+        1 * session.persist(_ as TestValidateableEntity) >> { Object[] args ->
+            Object persisted = args[0]
+            if (persisted instanceof List) {
+                persisted = ((List) persisted).get(0)
+            }
+            assert ((TestValidateableEntity) persisted).shouldSkipValidation()
+        }
+        0 * session.flush()
+        result.is(entity)
+        cleared == [entity]
+        entity.shouldSkipValidation()
+    }
+
+    private Datastore mockDatastore() {
+        Mock(Datastore) {
+            getMappingContext() >> Mock(MappingContext) {
+                getMappingFactory() >> null
+            }
+        }
+    }
+
+    private static class TestGormInstanceApi extends GormInstanceApi<TestValidateableEntity> {
+        private final Session session
+
+        TestGormInstanceApi(Datastore datastore, Session session) {
+            super(TestValidateableEntity.class, datastore)
+            this.session = session
+        }
+
+        TestGormInstanceApi(Datastore datastore, Session session, GormRegistry registry) {
+            super(TestValidateableEntity.class, datastore, registry)
+            this.session = session
+        }
+
+        @Override
+        protected <T> T execute(SessionCallback<T> callback) {
+            return callback.call(session)
+        }
+    }
+
+    private static class TestValidateableEntity implements GormValidateable {
+    }
+}
diff --git a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/GormRegistryConcurrencySpec.groovy b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/GormRegistryConcurrencySpec.groovy
new file mode 100644
index 0000000000..42a4fd49d0
--- /dev/null
+++ b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/GormRegistryConcurrencySpec.groovy
@@ -0,0 +1,132 @@
+/*
+ *  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.datastore.gorm
+
+import org.grails.datastore.mapping.core.Datastore
+import org.grails.datastore.mapping.core.connections.ConnectionSource
+import org.grails.datastore.mapping.model.MappingContext
+import spock.lang.Specification
+import spock.lang.Timeout
+
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.ExecutorService
+import java.util.concurrent.Executors
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicInteger
+
+class GormRegistryConcurrencySpec extends Specification {
+
+    void setup() {
+        GormRegistry.reset()
+    }
+
+    void cleanup() {
+        GormRegistry.reset()
+    }
+
+    @Timeout(10)
+    void "registry hot-paths perform without lock contention under high concurrency"() {
+        given:
+        def registry = GormRegistry.instance
+        int numThreads = 10
+        int iterationsPerThread = 100000
+        ExecutorService executor = Executors.newFixedThreadPool(numThreads)
+        CountDownLatch startLatch = new CountDownLatch(1)
+        CountDownLatch endLatch = new CountDownLatch(numThreads)
+        AtomicInteger errorCount = new AtomicInteger(0)
+
+        // Setup some dummy state to query
+        def context = Stub(MappingContext) {
+            getMappingFactory() >> null
+        }
+        def defaultDatastore = Stub(Datastore) {
+            getMappingContext() >> context
+        }
+        def secondaryDatastore = Stub(Datastore) {
+            getMappingContext() >> context
+        }
+        def resolver = Stub(DatastoreResolver) {
+            resolve() >> defaultDatastore
+        }
+        
+        registry.registerDatastore(ConnectionSource.DEFAULT, defaultDatastore)
+        registry.registerDatastore("secondary", secondaryDatastore)
+        registry.registerEntityDatastore(ConcurrentEntity.name, ConnectionSource.DEFAULT, defaultDatastore)
+        registry.registerEntityDatastore(ConcurrentEntity.name, "secondary", secondaryDatastore)
+        
+        def staticApi = new GormStaticApi(ConcurrentEntity, context, [], resolver, ConnectionSource.DEFAULT, registry)
+        def instanceApi = new GormInstanceApi(ConcurrentEntity, context, resolver, registry)
+        def validationApi = new GormValidationApi(ConcurrentEntity, context, resolver, registry)
+        
+        registry.registerApi(ConcurrentEntity.name, staticApi, instanceApi, validationApi)
+
+        when: "multiple threads access registry hot paths simultaneously"
+        long startTime = System.currentTimeMillis()
+        for (int i = 0; i < numThreads; i++) {
+            executor.submit {
+                try {
+                    startLatch.await()
+                    for (int j = 0; j < iterationsPerThread; j++) {
+                        // High contention normalization paths
+                        def normClass = registry.normalizeEntityKeyFromClass(ConcurrentEntity)
+                        def normName = registry.normalizeEntityKey(" ${ConcurrentEntity.name} ")
+                        def normQual = registry.normalizeQualifier(" secondary ")
+                        
+                        assert normClass == ConcurrentEntity.name
+                        assert normName == ConcurrentEntity.name
+                        assert normQual == "secondary"
+
+                        // Datastore lookup paths
+                        def ds1 = registry.getDatastore(ConcurrentEntity.name, ConnectionSource.DEFAULT)
+                        def ds2 = registry.getDatastore(ConcurrentEntity.name, "secondary")
+                        
+                        assert ds1 == defaultDatastore
+                        assert ds2 == secondaryDatastore
+
+                        // API lookup paths
+                        def sapi = registry.getStaticApi(ConcurrentEntity.name)
+                        def iapi = registry.getInstanceApi(ConcurrentEntity.name)
+                        def vapi = registry.getValidationApi(ConcurrentEntity.name)
+                        
+                        assert sapi != null
+                        assert iapi != null
+                        assert vapi != null
+                    }
+                } catch (Throwable e) {
+                    e.printStackTrace()
+                    errorCount.incrementAndGet()
+                } finally {
+                    endLatch.countDown()
+                }
+            }
+        }
+        
+        startLatch.countDown() // Release all threads
+        endLatch.await(5, TimeUnit.SECONDS)
+        long endTime = System.currentTimeMillis()
+        executor.shutdown()
+        
+        println "Concurrency test completed in ${endTime - startTime}ms for ${numThreads * iterationsPerThread} operations"
+
+        then: "no errors occurred and operations completed successfully"
+        errorCount.get() == 0
+    }
+
+    static class ConcurrentEntity {}
+}
diff --git a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/GormRegistryEntityRegistrationSpec.groovy b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/GormRegistryEntityRegistrationSpec.groovy
new file mode 100644
index 0000000000..c960e4731f
--- /dev/null
+++ b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/GormRegistryEntityRegistrationSpec.groovy
@@ -0,0 +1,185 @@
+/*
+ *  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.datastore.gorm
+
+import spock.lang.Specification
+
+import org.grails.datastore.mapping.core.Datastore
+import org.grails.datastore.mapping.core.connections.ConnectionSource
+import org.grails.datastore.mapping.model.MappingContext
+import org.grails.datastore.gorm.DatastoreResolver
+
+/**
+ * Tests for {@link GormRegistry#registerEntityApis(String, GormStaticApi, GormInstanceApi, GormValidationApi)}
+ * and {@link GormRegistry#registerEntityDatastores(String, Object, List, Object)}.
+ *
+ * @author Graeme Rocher
+ */
+class GormRegistryEntityRegistrationSpec extends Specification {
+
+    void setup() {
+        GormRegistry.reset()
+    }
+
+    void cleanup() {
+        GormRegistry.reset()
+    }
+
+    void 'registerApi stores static, instance, and validation APIs in dedicated registries'() {
+        given:
+        GormRegistry registry = GormRegistry.instance
+        String className = RegistryBook.name
+        MappingContext mappingContext = Stub(MappingContext) {
+            getMappingFactory() >> null
+        }
+        Datastore datastore = Stub(Datastore) {
+            getMappingContext() >> mappingContext
+        }
+        DatastoreResolver datastoreResolver = Stub(DatastoreResolver) {
+            resolve() >> datastore
+        }
+        def staticApi = new GormStaticApi(RegistryBook, mappingContext, [], datastoreResolver, ConnectionSource.DEFAULT, registry)
+        def instanceApi = new GormInstanceApi(RegistryBook, mappingContext, datastoreResolver, registry)
+        def validationApi = new GormValidationApi(RegistryBook, mappingContext, datastoreResolver, registry)
+
+        when:
+        registry.registerApi(className, staticApi, instanceApi, validationApi)
+        
+        then:
+        registry.getStaticApiRegistry().containsKey(className)
+        registry.getInstanceApiRegistry().containsKey(className)
+        registry.getValidationApiRegistry().containsKey(className)
+        registry.getStaticApi(className).is(staticApi)
+        registry.getInstanceApi(className).is(instanceApi)
+        registry.getValidationApi(className).is(validationApi)
+    }
+
+    void 'registerApi overwrites existing registrations'() {
+        given:
+        GormRegistry registry = GormRegistry.instance
+        String className = RegistryBook.name
+        MappingContext mappingContext = Stub(MappingContext) {
+            getMappingFactory() >> null
+        }
+        Datastore datastore = Stub(Datastore) {
+            getMappingContext() >> mappingContext
+        }
+        DatastoreResolver datastoreResolver = Stub(DatastoreResolver) {
+            resolve() >> datastore
+        }
+        def firstStaticApi = new GormStaticApi(RegistryBook, mappingContext, [], datastoreResolver, ConnectionSource.DEFAULT, registry)
+        def firstInstanceApi = new GormInstanceApi(RegistryBook, mappingContext, datastoreResolver, registry)
+        def firstValidationApi = new GormValidationApi(RegistryBook, mappingContext, datastoreResolver, registry)
+        def secondStaticApi = new GormStaticApi(RegistryBook, mappingContext, [], datastoreResolver, ConnectionSource.DEFAULT, registry)
+        def secondInstanceApi = new GormInstanceApi(RegistryBook, mappingContext, datastoreResolver, registry)
+        def secondValidationApi = new GormValidationApi(RegistryBook, mappingContext, datastoreResolver, registry)
+
+        when:
+        registry.registerApi(className, firstStaticApi, firstInstanceApi, firstValidationApi)
+        registry.registerApi(className, secondStaticApi, secondInstanceApi, secondValidationApi)
+        
+        then:
+        registry.getStaticApi(className).is(secondStaticApi)
+        registry.getInstanceApi(className).is(secondInstanceApi)
+        registry.getValidationApi(className).is(secondValidationApi)
+    }
+
+    class RegistryBook {
+
+    }
+
+    void 'registerEntityDatastores registers datastore for single connection source'() {
+        given:
+        GormRegistry registry = GormRegistry.instance
+        String className = 'com.example.Book'
+        Datastore datastore = Mock(Datastore)
+
+        when:
+        // Note: registerEntityDatastores expects a non-null entity, so we call it without entity param
+        // which will skip the entity-specific qualifier logic
+        for (String qualifier in [ConnectionSource.DEFAULT]) {
+            registry.registerDatastore(qualifier, datastore)
+            registry.registerEntityDatastore(className, qualifier, datastore)
+        }
+
+        then:
+        registry.getDatastore(className, ConnectionSource.DEFAULT) == datastore
+    }
+
+    void 'registerEntityDatastores handles null datastore gracefully'() {
+        given:
+        GormRegistry registry = GormRegistry.instance
+        String className = 'com.example.Book'
+        List<String> connectionSources = [ConnectionSource.DEFAULT]
+
+        when:
+        registry.registerEntityDatastores(className, null, connectionSources, null)
+
+        then:
+        noExceptionThrown()
+        registry.getDatastore(className, ConnectionSource.DEFAULT) == null
+    }
+
+    void 'registerEntityDatastores registers datastores for multiple connection sources'() {
+        given:
+        GormRegistry registry = GormRegistry.instance
+        String className = 'com.example.Book'
+        Datastore datastore = Mock(Datastore)
+        List<String> connectionSources = [ConnectionSource.DEFAULT, 'secondary', 'reporting']
+
+        when:
+        // Register directly for multiple sources
+        for (String qualifier in connectionSources) {
+            registry.registerDatastore(qualifier, datastore)
+            registry.registerEntityDatastore(className, qualifier, datastore)
+        }
+
+        then:
+        registry.getDatastore(className, ConnectionSource.DEFAULT) == datastore
+        registry.getDatastore(className, 'secondary') == datastore
+        registry.getDatastore(className, 'reporting') == datastore
+    }
+
+    void 'registry normalizes default qualifier aliases when registering datastores'() {
+        given:
+        GormRegistry registry = GormRegistry.instance
+        Datastore datastore = Mock(Datastore)
+
+        when:
+        registry.registerDatastore(ConnectionSource.OLD_DEFAULT, datastore)
+
+        then:
+        registry.getDatastore((String) null, ConnectionSource.DEFAULT) == datastore
+        registry.getDatastore((String) null, ConnectionSource.OLD_DEFAULT) == datastore
+        registry.getDatastore((String) null, '   ') == datastore
+    }
+
+    void 'registry normalizes entity keys for entity-specific datastore lookups'() {
+        given:
+        GormRegistry registry = GormRegistry.instance
+        Datastore datastore = Mock(Datastore)
+
+        when:
+        registry.registerEntityDatastore(" ${RegistryBook.name} ", ConnectionSource.OLD_DEFAULT, datastore)
+
+        then:
+        registry.getDatastore(RegistryBook.name, ConnectionSource.DEFAULT) == datastore
+        registry.getDatastore(" ${RegistryBook.name} ", ConnectionSource.OLD_DEFAULT) == datastore
+    }
+}
diff --git a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/GormRegistryFactorySpec.groovy b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/GormRegistryFactorySpec.groovy
new file mode 100644
index 0000000000..4f4152a104
--- /dev/null
+++ b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/GormRegistryFactorySpec.groovy
@@ -0,0 +1,73 @@
+/*
+ *  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.datastore.gorm
+
+import org.grails.datastore.mapping.core.Datastore
+import spock.lang.Specification
+
+class GormRegistryFactorySpec extends Specification {
+
+    void setup() {
+        GormRegistry.reset()
+    }
+
+    void cleanup() {
+        GormRegistry.reset()
+    }
+
+    void 'registry returns default factory when no override is registered'() {
+        given:
+        GormRegistry registry = GormRegistry.instance
+        Datastore datastore = Mock(Datastore)
+
+        expect:
+        registry.getApiFactory(datastore).is(registry.defaultApiFactory)
+    }
+
+    void 'registry returns custom factory for datastore type override'() {
+        given:
+        GormRegistry registry = GormRegistry.instance
+        Datastore datastore = Mock(Datastore)
+        GormApiFactory customFactory = Mock(GormApiFactory)
+        registry.registerApiFactory(datastore.getClass(), customFactory)
+
+        expect:
+        registry.getApiFactory(datastore).is(customFactory)
+    }
+
+    void 'registry resolves factory for datastore interface or superclass override'() {
+        given:
+        GormRegistry registry = GormRegistry.instance
+        Datastore datastore = Mock(Datastore)
+        GormApiFactory customFactory = Mock(GormApiFactory)
+        registry.registerApiFactory(Datastore, customFactory)
+
+        expect:
+        registry.getApiFactory(datastore).is(customFactory)
+    }
+
+    void 'registry exposes singleton resolver instance'() {
+        given:
+        GormRegistry registry = GormRegistry.instance
+
+        expect:
+        registry.apiResolver != null
+        registry.apiResolver.is(registry.apiResolver)
+    }
+}
diff --git a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/GormStaticApiRegistrySpec.groovy b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/GormStaticApiRegistrySpec.groovy
new file mode 100644
index 0000000000..be96afeb4b
--- /dev/null
+++ b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/GormStaticApiRegistrySpec.groovy
@@ -0,0 +1,67 @@
+/*
+ *  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.datastore.gorm
+
+import org.grails.datastore.mapping.core.Datastore
+import org.grails.datastore.mapping.core.connections.ConnectionSource
+import org.grails.datastore.mapping.model.MappingContext
+import spock.lang.Specification
+
+class GormStaticApiRegistrySpec extends Specification {
+
+    void setup() {
+        GormRegistry.reset()
+    }
+
+    void cleanup() {
+        GormRegistry.reset()
+    }
+
+    void 'findStaticApi resolves by qualifier'() {
+        given:
+        def registry = GormRegistry.instance
+        def apiRegistry = registry.staticApiRegistry
+        def context = Stub(MappingContext) {
+            getMappingFactory() >> null
+        }
+        def defaultDatastore = Stub(Datastore) {
+            getMappingContext() >> context
+        }
+        def secondaryDatastore = Stub(Datastore) {
+            getMappingContext() >> context
+        }
+        def resolver = Stub(DatastoreResolver) {
+            resolve() >> defaultDatastore
+        }
+        def api = new GormStaticApi(ApiRegistryEntity, context, [], resolver, ConnectionSource.DEFAULT, registry)
+        apiRegistry.register(ApiRegistryEntity.name, api)
+        registry.registerDatastore(ConnectionSource.DEFAULT, defaultDatastore)
+        registry.registerDatastore('secondary', secondaryDatastore)
+        registry.registerEntityDatastore(ApiRegistryEntity.name, ConnectionSource.DEFAULT, defaultDatastore)
+        registry.registerEntityDatastore(ApiRegistryEntity.name, 'secondary', secondaryDatastore)
+
+        expect:
+        apiRegistry.findStaticApi(ApiRegistryEntity).is(api)
+        apiRegistry.findStaticApi(ApiRegistryEntity, 'secondary') instanceof GormStaticApi
+        !apiRegistry.findStaticApi(ApiRegistryEntity, 'secondary').is(api)
+    }
+
+    static class ApiRegistryEntity {
+    }
+}
diff --git a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/GormValidationApiRegistrySpec.groovy b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/GormValidationApiRegistrySpec.groovy
new file mode 100644
index 0000000000..220b0eb919
--- /dev/null
+++ b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/GormValidationApiRegistrySpec.groovy
@@ -0,0 +1,67 @@
+/*
+ *  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.datastore.gorm
+
+import org.grails.datastore.mapping.core.Datastore
+import org.grails.datastore.mapping.core.connections.ConnectionSource
+import org.grails.datastore.mapping.model.MappingContext
+import spock.lang.Specification
+
+class GormValidationApiRegistrySpec extends Specification {
+
+    void setup() {
+        GormRegistry.reset()
+    }
+
+    void cleanup() {
+        GormRegistry.reset()
+    }
+
+    void 'findValidationApi resolves by qualifier'() {
+        given:
+        def registry = GormRegistry.instance
+        def apiRegistry = registry.validationApiRegistry
+        def context = Stub(MappingContext) {
+            getMappingFactory() >> null
+        }
+        def defaultDatastore = Stub(Datastore) {
+            getMappingContext() >> context
+        }
+        def secondaryDatastore = Stub(Datastore) {
+            getMappingContext() >> context
+        }
+        def resolver = Stub(DatastoreResolver) {
+            resolve() >> defaultDatastore
+        }
+        def api = new GormValidationApi(ApiRegistryEntity, context, resolver, registry)
+        apiRegistry.register(ApiRegistryEntity.name, api)
+        registry.registerDatastore(ConnectionSource.DEFAULT, defaultDatastore)
+        registry.registerDatastore('secondary', secondaryDatastore)
+        registry.registerEntityDatastore(ApiRegistryEntity.name, ConnectionSource.DEFAULT, defaultDatastore)
+        registry.registerEntityDatastore(ApiRegistryEntity.name, 'secondary', secondaryDatastore)
+
+        expect:
+        apiRegistry.findValidationApi(ApiRegistryEntity).is(api)
+        apiRegistry.findValidationApi(ApiRegistryEntity, 'secondary') instanceof GormValidationApi
+        !apiRegistry.findValidationApi(ApiRegistryEntity, 'secondary').is(api)
+    }
+
+    static class ApiRegistryEntity {
+    }
+}
diff --git a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/PreferredDatastoreSelectorSpec.groovy b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/PreferredDatastoreSelectorSpec.groovy
new file mode 100644
index 0000000000..2fd73db652
--- /dev/null
+++ b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/PreferredDatastoreSelectorSpec.groovy
@@ -0,0 +1,75 @@
+/*
+ *  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.datastore.gorm
+
+import org.grails.datastore.mapping.core.Datastore
+import org.grails.datastore.mapping.core.connections.ConnectionSource
+import org.grails.datastore.mapping.core.connections.MultipleConnectionSourceCapableDatastore
+import spock.lang.Specification
+
+class PreferredDatastoreSelectorSpec extends Specification {
+
+    private final GormEnhancerRegistry stateRegistry = GormEnhancerRegistry.instance
+
+    void setup() {
+        GormRegistry.reset()
+        stateRegistry.clearPreferredDatastore()
+        stateRegistry.clearResolvingDatastoreDepth()
+    }
+
+    void cleanup() {
+        stateRegistry.clearPreferredDatastore()
+        stateRegistry.clearResolvingDatastoreDepth()
+        GormRegistry.reset()
+    }
+
+    void 'select returns preferred datastore for default qualifier'() {
+        given:
+        def selector = new PreferredDatastoreSelector()
+        GormRegistry registry = GormRegistry.instance
+        Datastore preferredDatastore = Mock(Datastore)
+        stateRegistry.setPreferredDatastore(preferredDatastore)
+
+        expect:
+        selector.select(registry, stateRegistry, null, ConnectionSource.DEFAULT, null, 0, null).is(preferredDatastore)
+    }
+
+    void 'select returns qualifier datastore from preferred multi-connection datastore'() {
+        given:
+        def selector = new PreferredDatastoreSelector()
+        GormRegistry registry = GormRegistry.instance
+        Datastore qualifierDatastore = Mock(Datastore)
+        MultipleConnectionSourceCapableDatastore preferredDatastore = Mock(MultipleConnectionSourceCapableDatastore) {
+            getDatastoreForConnection('secondary') >> qualifierDatastore
+        }
+        stateRegistry.setPreferredDatastore(preferredDatastore)
+
+        expect:
+        selector.select(registry, stateRegistry, null, 'secondary', null, 0, null).is(qualifierDatastore)
+    }
+
+    void 'select returns null when no preferred datastore is configured'() {
+        given:
+        def selector = new PreferredDatastoreSelector()
+        GormRegistry registry = GormRegistry.instance
+
+        expect:
+        selector.select(registry, stateRegistry, null, null, null, 0, null) == null
+    }
+}
diff --git a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/QualifiedDatastoreSelectorSpec.groovy b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/QualifiedDatastoreSelectorSpec.groovy
new file mode 100644
index 0000000000..43e289a439
--- /dev/null
+++ b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/QualifiedDatastoreSelectorSpec.groovy
@@ -0,0 +1,96 @@
+/*
+ *  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.datastore.gorm
+
+import org.grails.datastore.mapping.core.Datastore
+import org.grails.datastore.mapping.core.connections.ConnectionSource
+import org.grails.datastore.mapping.core.connections.MultipleConnectionSourceCapableDatastore
+import org.grails.datastore.mapping.multitenancy.MultiTenantCapableDatastore
+import org.springframework.transaction.support.TransactionSynchronizationManager
+import spock.lang.Specification
+
+class QualifiedDatastoreSelectorSpec extends Specification {
+
+    private final GormEnhancerRegistry stateRegistry = GormEnhancerRegistry.instance
+
+    void setup() {
+        GormRegistry.reset()
+        stateRegistry.clearPreferredDatastore()
+        stateRegistry.clearResolvingDatastoreDepth()
+    }
+
+    void cleanup() {
+        if (TransactionSynchronizationManager.hasResource('secondary')) {
+            TransactionSynchronizationManager.unbindResource('secondary')
+        }
+        stateRegistry.clearPreferredDatastore()
+        stateRegistry.clearResolvingDatastoreDepth()
+        GormRegistry.reset()
+    }
+
+    void 'select returns transaction-bound datastore for qualifier'() {
+        given:
+        def selector = new QualifiedDatastoreSelector()
+        GormRegistry registry = GormRegistry.instance
+        Datastore boundDatastore = Mock(Datastore)
+        TransactionSynchronizationManager.bindResource('secondary', boundDatastore)
+
+        expect:
+        selector.select(registry, stateRegistry, null, 'secondary', 0).is(boundDatastore)
+    }
+
+    void 'select returns registry datastore for qualifier'() {
+        given:
+        def selector = new QualifiedDatastoreSelector()
+        GormRegistry registry = GormRegistry.instance
+        Datastore secondaryDatastore = Mock(Datastore)
+        registry.registerDatastore('secondary', secondaryDatastore)
+
+        expect:
+        selector.select(registry, stateRegistry, null, 'secondary', 0).is(secondaryDatastore)
+    }
+
+    void 'select returns datastore from default multiple-connection datastore'() {
+        given:
+        def selector = new QualifiedDatastoreSelector()
+        GormRegistry registry = GormRegistry.instance
+        Datastore secondaryDatastore = Mock(Datastore)
+        MultipleConnectionSourceCapableDatastore defaultDatastore = Mock(MultipleConnectionSourceCapableDatastore) {
+            getDatastoreForConnection('secondary') >> secondaryDatastore
+        }
+        registry.registerDatastore(ConnectionSource.DEFAULT, defaultDatastore)
+
+        expect:
+        selector.select(registry, stateRegistry, null, 'secondary', 0).is(secondaryDatastore)
+    }
+
+    void 'select returns datastore from default multi-tenant datastore'() {
+        given:
+        def selector = new QualifiedDatastoreSelector()
+        GormRegistry registry = GormRegistry.instance
+        Datastore secondaryDatastore = Mock(Datastore)
+        MultiTenantCapableDatastore defaultDatastore = Mock(MultiTenantCapableDatastore) {
+            getDatastoreForTenantId('secondary') >> secondaryDatastore
+        }
+        registry.registerDatastore(ConnectionSource.DEFAULT, defaultDatastore)
+
+        expect:
+        selector.select(registry, stateRegistry, null, 'secondary', 0).is(secondaryDatastore)
+    }
+}
diff --git a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/multitenancy/MultiTenantEventListenerSpec.groovy b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/multitenancy/MultiTenantEventListenerSpec.groovy
new file mode 100644
index 0000000000..994070c757
--- /dev/null
+++ b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/multitenancy/MultiTenantEventListenerSpec.groovy
@@ -0,0 +1,111 @@
+/*
+ * Copyright 2026 the original author or authors.
+ *
+ * Licensed 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.datastore.gorm.multitenancy
+
+import grails.gorm.multitenancy.Tenants
+import org.grails.datastore.mapping.core.Datastore
+import org.grails.datastore.mapping.engine.EntityAccess
+import org.grails.datastore.mapping.engine.event.PreInsertEvent
+import org.grails.datastore.mapping.model.PersistentEntity
+import org.grails.datastore.mapping.model.types.TenantId
+import org.grails.datastore.mapping.multitenancy.MultiTenantCapableDatastore
+import spock.lang.Specification
+
+class MultiTenantEventListenerSpec extends Specification {
+
+    static class DummyTenantId extends TenantId {
+        DummyTenantId() { super(null, null, "tenantId", Long) }
+        org.grails.datastore.mapping.model.PropertyMapping getMapping() { null }
+    }
+
+    void "test tenantId is not overridden if it already exists"() {
+        given: "A mock datastore and entity"
+        MultiTenantCapableDatastore datastore = Mock(MultiTenantCapableDatastore)
+        PersistentEntity entity = Mock(PersistentEntity)
+        TenantId tenantId = new DummyTenantId()
+        EntityAccess entityAccess = Mock(EntityAccess)
+
+        and: "Setup entity multi-tenant mocks"
+        entity.isMultiTenant() >> true
+        entity.getTenantId() >> tenantId
+        entity.getJavaClass() >> MultiTenantEventListenerSpec
+
+        and: "A listener"
+        def listener = new MultiTenantEventListener(datastore)
+
+        when: "A PreInsertEvent is triggered with an existing tenantId"
+        def preInsertEvent = new PreInsertEvent(datastore, entity, entityAccess)
+        // Stub the Tenants.currentId call
+        Tenants.datastoreLocator = new Tenants.DatastoreLocator() {
+            Datastore getDatastore() { return datastore }
+        }
+        datastore.getMultiTenancyMode() >> org.grails.datastore.mapping.multitenancy.MultiTenancySettings.MultiTenancyMode.DATABASE
+        datastore.withNewSession(_ as Serializable, _ as Closure) >> { Serializable tId, Closure callable -> callable.call(null) }
+
+        Tenants.withId(datastore, "SystemTenant") {
+            listener.onApplicationEvent(preInsertEvent)
+        }
+
+        then: "The listener checks for existing tenantId"
+        1 * entityAccess.getProperty("tenantId") >> "ManualTenant"
+        
+        and: "It sets it to the existing tenantId instead of the current context tenant"
+        1 * entityAccess.setProperty("tenantId", "ManualTenant")
+        0 * entityAccess.setProperty("tenantId", "SystemTenant")
+
+        cleanup:
+        Tenants.datastoreLocator = new Tenants.DatastoreLocator()
+    }
+
+    void "test tenantId is set to current tenant if it does not exist"() {
+        given: "A mock datastore and entity"
+        MultiTenantCapableDatastore datastore = Mock(MultiTenantCapableDatastore)
+        PersistentEntity entity = Mock(PersistentEntity)
+        TenantId tenantId = new DummyTenantId()
+        EntityAccess entityAccess = Mock(EntityAccess)
+
+        and: "Setup entity multi-tenant mocks"
+        entity.isMultiTenant() >> true
+        entity.getTenantId() >> tenantId
+        entity.getJavaClass() >> MultiTenantEventListenerSpec
+
+        and: "A listener"
+        def listener = new MultiTenantEventListener(datastore)
+
+        when: "A PreInsertEvent is triggered with no existing tenantId"
+        def preInsertEvent = new PreInsertEvent(datastore, entity, entityAccess)
+        // Stub the Tenants.currentId call
+        Tenants.datastoreLocator = new Tenants.DatastoreLocator() {
+            Datastore getDatastore() { return datastore }
+        }
+        datastore.getMultiTenancyMode() >> org.grails.datastore.mapping.multitenancy.MultiTenancySettings.MultiTenancyMode.DATABASE
+        datastore.withNewSession(_ as Serializable, _ as Closure) >> { Serializable tId, Closure callable -> callable.call(null) }
+
+        Tenants.withId(datastore, "SystemTenant") {
+            listener.onApplicationEvent(preInsertEvent)
+        }
+
+        then: "The listener checks for existing tenantId and finds null"
+        1 * entityAccess.getProperty("tenantId") >> null
+        
+        and: "It sets it to the system tenantId"
+        1 * entityAccess.setProperty("tenantId", "SystemTenant")
+
+        cleanup:
+        Tenants.datastoreLocator = new Tenants.DatastoreLocator()
+    }
+}
diff --git a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/transactions/DefaultTransactionTemplateFactorySpec.groovy b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/transactions/DefaultTransactionTemplateFactorySpec.groovy
new file mode 100644
index 0000000000..eaf1774d87
--- /dev/null
+++ b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/transactions/DefaultTransactionTemplateFactorySpec.groovy
@@ -0,0 +1,90 @@
+/*
+ *  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.datastore.gorm.transactions
+
+import grails.gorm.transactions.GrailsTransactionTemplate
+import org.springframework.transaction.PlatformTransactionManager
+import org.springframework.transaction.TransactionDefinition
+import org.springframework.transaction.interceptor.TransactionAttribute
+import org.springframework.transaction.interceptor.DefaultTransactionAttribute
+import org.springframework.transaction.support.SimpleTransactionStatus
+import spock.lang.Specification
+
+/**
+ * Tests for DefaultTransactionTemplateFactory
+ */
+class DefaultTransactionTemplateFactorySpec extends Specification {
+
+    DefaultTransactionTemplateFactory factory
+    PlatformTransactionManager mockTransactionManager
+
+    void setup() {
+        factory = new DefaultTransactionTemplateFactory()
+        mockTransactionManager = Mock(PlatformTransactionManager)
+    }
+
+    void "createTransactionTemplate creates GrailsTransactionTemplate with transaction manager"() {
+        when:
+        def template = factory.createTransactionTemplate(mockTransactionManager)
+
+        then:
+        template != null
+        template instanceof GrailsTransactionTemplate
+    }
+
+    void "createTransactionTemplate with TransactionDefinition creates template with definition"() {
+        given:
+        def definition = Mock(TransactionDefinition) {
+            getIsolationLevel() >> TransactionDefinition.ISOLATION_DEFAULT
+            getPropagationBehavior() >> TransactionDefinition.PROPAGATION_REQUIRED
+            getTimeout() >> -1
+            isReadOnly() >> false
+        }
+
+        when:
+        def template = factory.createTransactionTemplate(mockTransactionManager, definition)
+
+        then:
+        template != null
+        template instanceof GrailsTransactionTemplate
+    }
+
+    void "createTransactionTemplate with TransactionAttribute creates template with attribute"() {
+        given:
+        def attribute = new DefaultTransactionAttribute()
+
+        when:
+        def template = factory.createTransactionTemplate(mockTransactionManager, attribute)
+
+        then:
+        template != null
+        template instanceof GrailsTransactionTemplate
+    }
+
+    void "factory is consistent across multiple calls"() {
+        when:
+        def template1 = factory.createTransactionTemplate(mockTransactionManager)
+        def template2 = factory.createTransactionTemplate(mockTransactionManager)
+
+        then:
+        template1 != null
+        template2 != null
+        template1.class == template2.class
+    }
+}