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

jamesfredley pushed a commit to branch feat/data-service-datasource-inheritance
in repository https://gitbox.apache.org/repos/asf/grails-core.git

commit 19e84793677b55a82a4a1a553eda8f93c2734e69
Author: James Fredley <[email protected]>
AuthorDate: Sat Feb 21 18:15:28 2026 -0500

    feat: auto-inherit datasource from domain class in @Service data services
    
    @Service(DomainClass) now automatically inherits the datasource from
    the domain class's mapping block, so developers no longer need to
    specify @Transactional(connection = '...') when the domain already
    declares its datasource. Explicit @Transactional(connection) on the
    service still takes precedence.
    
    Two-layer implementation:
    - AST (ServiceTransformation): parses the domain's static mapping
      closure for datasource/connection calls and propagates to the
      generated service impl via @Transactional(connection)
    - Runtime (DatastoreServiceMethodInvokingFactoryBean): resolves the
      domain's datasource from the mapping context and sets the correct
      datastore on the service instance
    
    Assisted-by: Claude Code <[email protected]>
---
 .../DataServiceDatasourceInheritanceSpec.groovy    | 215 ++++++++++++++++++++
 .../transform/ServiceTransformation.groovy         | 101 ++++++++++
 .../ConnectionRoutingServiceTransformSpec.groovy   | 216 +++++++++++++++++++++
 ...atastoreServiceMethodInvokingFactoryBean.groovy |  47 ++++-
 .../guide/conf/dataSource/multipleDatasources.adoc |  44 +++++
 .../example/InheritedProductService.groovy         |  38 ++++
 .../DataServiceDatasourceInheritanceSpec.groovy    | 121 ++++++++++++
 7 files changed, 781 insertions(+), 1 deletion(-)

diff --git 
a/grails-data-hibernate5/core/src/test/groovy/org/grails/orm/hibernate/connections/DataServiceDatasourceInheritanceSpec.groovy
 
b/grails-data-hibernate5/core/src/test/groovy/org/grails/orm/hibernate/connections/DataServiceDatasourceInheritanceSpec.groovy
new file mode 100644
index 0000000000..6a9eb267dc
--- /dev/null
+++ 
b/grails-data-hibernate5/core/src/test/groovy/org/grails/orm/hibernate/connections/DataServiceDatasourceInheritanceSpec.groovy
@@ -0,0 +1,215 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.grails.orm.hibernate.connections
+
+import org.hibernate.dialect.H2Dialect
+import spock.lang.AutoCleanup
+import spock.lang.Shared
+import spock.lang.Specification
+
+import grails.gorm.annotation.Entity
+import grails.gorm.services.Service
+import grails.gorm.transactions.Transactional
+import org.grails.datastore.gorm.GormEnhancer
+import org.grails.datastore.mapping.core.DatastoreUtils
+import org.grails.orm.hibernate.HibernateDatastore
+
+class DataServiceDatasourceInheritanceSpec extends Specification {
+
+    @Shared Map config = [
+            'dataSource.url':"jdbc:h2:mem:grailsDB;LOCK_TIMEOUT=10000",
+            'dataSource.dbCreate': 'create-drop',
+            'dataSource.dialect': H2Dialect.name,
+            'dataSource.formatSql': 'true',
+            'hibernate.flush.mode': 'COMMIT',
+            'hibernate.cache.queries': 'true',
+            'hibernate.hbm2ddl.auto': 'create-drop',
+            
'dataSources.warehouse':[url:"jdbc:h2:mem:warehouseDB;LOCK_TIMEOUT=10000"],
+    ]
+
+    @Shared @AutoCleanup HibernateDatastore datastore = new HibernateDatastore(
+            DatastoreUtils.createPropertyResolver(config), Inventory
+    )
+
+    @Shared InventoryService inventoryService
+    @Shared InventoryDataService inventoryDataService
+    @Shared ExplicitInventoryService explicitInventoryService
+
+    void setupSpec() {
+        inventoryService = datastore
+                .getDatastoreForConnection('warehouse')
+                .getService(InventoryService)
+        inventoryDataService = datastore
+                .getDatastoreForConnection('warehouse')
+                .getService(InventoryDataService)
+        explicitInventoryService = datastore
+                .getDatastoreForConnection('warehouse')
+                .getService(ExplicitInventoryService)
+    }
+
+    void setup() {
+        def api = GormEnhancer.findStaticApi(Inventory, 'warehouse')
+        api.withNewTransaction {
+            api.executeUpdate('delete from Inventory')
+        }
+    }
+
+    void "abstract service without @Transactional(connection) inherits from 
domain"() {
+        when: "saving through a service that has no @Transactional(connection)"
+        def saved = inventoryService.save(new Inventory(sku: 'ABC-001', 
quantity: 50))
+
+        then: "the entity is persisted on the warehouse datasource"
+        saved != null
+        saved.id != null
+        saved.sku == 'ABC-001'
+
+        and: "it exists on the warehouse datasource"
+        GormEnhancer.findStaticApi(Inventory, 'warehouse').withNewTransaction {
+            GormEnhancer.findStaticApi(Inventory, 'warehouse').count()
+        } == 1
+    }
+
+    void "get by ID routes to inherited datasource"() {
+        given: "an inventory item saved on warehouse"
+        def saved = inventoryService.save(new Inventory(sku: 'GET-001', 
quantity: 10))
+
+        when: "retrieving by ID"
+        def found = inventoryService.get(saved.id)
+
+        then: "the correct entity is returned"
+        found != null
+        found.id == saved.id
+        found.sku == 'GET-001'
+    }
+
+    void "delete routes to inherited datasource"() {
+        given: "an inventory item saved on warehouse"
+        def saved = inventoryService.save(new Inventory(sku: 'DEL-001', 
quantity: 5))
+
+        when: "deleting by ID"
+        def deleted = inventoryService.delete(saved.id)
+
+        then: "the entity is deleted"
+        deleted != null
+        deleted.sku == 'DEL-001'
+        inventoryService.get(saved.id) == null
+    }
+
+    void "count routes to inherited datasource"() {
+        given: "items saved on warehouse"
+        inventoryService.save(new Inventory(sku: 'CNT-001', quantity: 1))
+        inventoryService.save(new Inventory(sku: 'CNT-002', quantity: 2))
+
+        expect: "count returns 2"
+        inventoryService.count() == 2
+    }
+
+    void "findBySku routes to inherited datasource"() {
+        given: "items saved on warehouse"
+        inventoryService.save(new Inventory(sku: 'FIND-001', quantity: 100))
+
+        when: "finding by sku"
+        def found = inventoryService.findBySku('FIND-001')
+
+        then: "the correct entity is returned"
+        found != null
+        found.sku == 'FIND-001'
+        found.quantity == 100
+    }
+
+    void "interface service inherits datasource from domain"() {
+        when: "saving through an interface service with no 
@Transactional(connection)"
+        def saved = inventoryDataService.save(new Inventory(sku: 'IFACE-001', 
quantity: 25))
+
+        then: "the entity is persisted on warehouse"
+        saved != null
+        saved.id != null
+
+        and: "retrievable through the same service"
+        inventoryDataService.get(saved.id) != null
+    }
+
+    void "explicit @Transactional(connection) wins over domain datasource"() {
+        when: "saving through a service with explicit 
@Transactional(connection='warehouse')"
+        def saved = explicitInventoryService.save(new Inventory(sku: 
'EXPL-001', quantity: 75))
+
+        then: "the entity is persisted correctly"
+        saved != null
+        saved.id != null
+        saved.sku == 'EXPL-001'
+    }
+
+    void "abstract and interface services share the same inherited 
datasource"() {
+        given: "an item saved through the abstract service"
+        def saved = inventoryService.save(new Inventory(sku: 'CROSS-001', 
quantity: 42))
+
+        expect: "the interface service can find it"
+        inventoryDataService.findBySku('CROSS-001') != null
+        inventoryDataService.findBySku('CROSS-001').id == saved.id
+    }
+
+}
+
+@Entity
+class Inventory {
+    Long id
+    Long version
+    String sku
+    Integer quantity
+
+    static mapping = {
+        datasource 'warehouse'
+    }
+    static constraints = {
+        sku blank: false
+    }
+}
+
+@Service(Inventory)
+abstract class InventoryService {
+
+    abstract Inventory get(Serializable id)
+
+    abstract Inventory save(Inventory item)
+
+    abstract Inventory delete(Serializable id)
+
+    abstract Number count()
+
+    abstract Inventory findBySku(String sku)
+}
+
+@Service(Inventory)
+interface InventoryDataService {
+
+    Inventory get(Serializable id)
+
+    Inventory save(Inventory item)
+
+    Inventory delete(Serializable id)
+
+    Inventory findBySku(String sku)
+}
+
+@Service(Inventory)
+@Transactional(connection = 'warehouse')
+abstract class ExplicitInventoryService {
+
+    abstract Inventory save(Inventory item)
+}
diff --git 
a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/services/transform/ServiceTransformation.groovy
 
b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/services/transform/ServiceTransformation.groovy
index 30811c5e30..d5d7c37812 100644
--- 
a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/services/transform/ServiceTransformation.groovy
+++ 
b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/services/transform/ServiceTransformation.groovy
@@ -31,12 +31,17 @@ import org.codehaus.groovy.ast.FieldNode
 import org.codehaus.groovy.ast.MethodNode
 import org.codehaus.groovy.ast.Parameter
 import org.codehaus.groovy.ast.PropertyNode
+import org.codehaus.groovy.ast.expr.ArgumentListExpression
 import org.codehaus.groovy.ast.expr.ClassExpression
+import org.codehaus.groovy.ast.expr.ClosureExpression
 import org.codehaus.groovy.ast.expr.ConstantExpression
 import org.codehaus.groovy.ast.expr.Expression
 import org.codehaus.groovy.ast.expr.ListExpression
+import org.codehaus.groovy.ast.expr.MethodCallExpression
 import org.codehaus.groovy.ast.expr.VariableExpression
 import org.codehaus.groovy.ast.stmt.BlockStatement
+import org.codehaus.groovy.ast.stmt.ExpressionStatement
+import org.codehaus.groovy.ast.stmt.Statement
 import org.codehaus.groovy.ast.tools.GenericsUtils
 import org.codehaus.groovy.control.CompilePhase
 import org.codehaus.groovy.control.SourceUnit
@@ -51,6 +56,7 @@ import groovyjarjarasm.asm.Opcodes
 
 import grails.gorm.services.Service
 import grails.gorm.transactions.NotTransactional
+import grails.gorm.transactions.Transactional
 import org.apache.grails.common.compiler.GroovyTransformOrder
 import org.grails.datastore.gorm.services.Implemented
 import org.grails.datastore.gorm.services.ServiceEnhancer
@@ -86,6 +92,7 @@ import 
org.grails.datastore.gorm.transactions.transform.TransactionalTransform
 import 
org.grails.datastore.gorm.transform.AbstractTraitApplyingGormASTTransformation
 import 
org.grails.datastore.gorm.validation.jakarta.services.implementers.MethodValidationImplementer
 import org.grails.datastore.mapping.core.Datastore
+import org.grails.datastore.mapping.core.connections.ConnectionSource
 import org.grails.datastore.mapping.core.order.OrderedComparator
 
 import static org.apache.groovy.ast.tools.AnnotatedNodeUtils.markAsGenerated
@@ -263,6 +270,19 @@ class ServiceTransformation extends 
AbstractTraitApplyingGormASTTransformation i
             // weave with generic argument
             weaveTraitWithGenerics(impl, getTraitClass(), targetDomainClass)
 
+            // Auto-inherit datasource from domain class's mapping if the 
service
+            // does not already have an explicit @Transactional(connection=...)
+            if (targetDomainClass != ClassHelper.OBJECT_TYPE) {
+                String domainConnection = 
resolveDomainDatasource(targetDomainClass)
+                if (domainConnection != null
+                        && !ConnectionSource.DEFAULT.equals(domainConnection)
+                        && !ConnectionSource.ALL.equals(domainConnection)) {
+                    if (!hasExplicitConnectionAnnotation(classNode)) {
+                        applyDomainConnectionToService(classNode, impl, 
domainConnection)
+                    }
+                }
+            }
+
             List<MethodNode> abstractMethods = 
findAllUnimplementedAbstractMethods(classNode)
             abstractMethods.sort(true) { it.name } // ensure a consistent 
order of processing methods
 
@@ -452,6 +472,87 @@ class ServiceTransformation extends 
AbstractTraitApplyingGormASTTransformation i
         }
     }
 
+    private static String resolveDomainDatasource(ClassNode domainClass) {
+        FieldNode mappingField = domainClass.getDeclaredField('mapping')
+        if (mappingField == null) {
+            PropertyNode mappingProp = domainClass.getProperty('mapping')
+            if (mappingProp != null) {
+                mappingField = mappingProp.getField()
+            }
+        }
+        if (mappingField != null) {
+            return 
extractDatasourceFromExpression(mappingField.getInitialValueExpression())
+        }
+        return null
+    }
+
+    private static String extractDatasourceFromExpression(Expression expr) {
+        if (!(expr instanceof ClosureExpression)) {
+            return null
+        }
+        Statement code = ((ClosureExpression) expr).getCode()
+        if (!(code instanceof BlockStatement)) {
+            return null
+        }
+        for (Statement stmt : ((BlockStatement) code).getStatements()) {
+            if (!(stmt instanceof ExpressionStatement)) {
+                continue
+            }
+            Expression stmtExpr = ((ExpressionStatement) stmt).getExpression()
+            if (!(stmtExpr instanceof MethodCallExpression)) {
+                continue
+            }
+            MethodCallExpression call = (MethodCallExpression) stmtExpr
+            String methodName = call.getMethodAsString()
+            if ('datasource' == methodName || 'connection' == methodName || 
'connections' == methodName) {
+                Expression args = call.getArguments()
+                if (args instanceof ArgumentListExpression) {
+                    List<Expression> argExprs = ((ArgumentListExpression) 
args).getExpressions()
+                    if (!argExprs.isEmpty() && argExprs[0] instanceof 
ConstantExpression) {
+                        return ((ConstantExpression) 
argExprs[0]).getValue()?.toString()
+                    }
+                }
+            }
+        }
+        return null
+    }
+
+    private static boolean hasExplicitConnectionAnnotation(ClassNode 
classNode) {
+        AnnotationNode ann = findAnnotation(classNode, Transactional)
+        if (ann != null) {
+            Expression connection = ann.getMember('connection')
+            if (connection instanceof ConstantExpression) {
+                String value = ((ConstantExpression) 
connection).getValue()?.toString()
+                return value != null && !value.isEmpty()
+            }
+        }
+        return false
+    }
+
+    private static void applyDomainConnectionToService(ClassNode classNode, 
ClassNode implClass, String connectionName) {
+        ConstantExpression connectionExpr = new 
ConstantExpression(connectionName)
+
+        AnnotationNode classAnn = findAnnotation(classNode, Transactional)
+        if (classAnn != null) {
+            classAnn.setMember('connection', connectionExpr)
+        }
+        else {
+            AnnotationNode newAnn = new 
AnnotationNode(ClassHelper.make(Transactional))
+            newAnn.setMember('connection', connectionExpr)
+            classNode.addAnnotation(newAnn)
+        }
+
+        AnnotationNode implAnn = findAnnotation(implClass, Transactional)
+        if (implAnn != null) {
+            implAnn.setMember('connection', connectionExpr)
+        }
+        else {
+            AnnotationNode newImplAnn = new 
AnnotationNode(ClassHelper.make(Transactional))
+            newImplAnn.setMember('connection', connectionExpr)
+            implClass.addAnnotation(newImplAnn)
+        }
+    }
+
     @Override
     int priority() {
         GroovyTransformOrder.DATA_SERVICE_ORDER
diff --git 
a/grails-datamapping-core/src/test/groovy/grails/gorm/services/ConnectionRoutingServiceTransformSpec.groovy
 
b/grails-datamapping-core/src/test/groovy/grails/gorm/services/ConnectionRoutingServiceTransformSpec.groovy
index ff223997e7..1c1aa441ac 100644
--- 
a/grails-datamapping-core/src/test/groovy/grails/gorm/services/ConnectionRoutingServiceTransformSpec.groovy
+++ 
b/grails-datamapping-core/src/test/groovy/grails/gorm/services/ConnectionRoutingServiceTransformSpec.groovy
@@ -377,4 +377,220 @@ class Gadget {
         thrown(IllegalStateException)
     }
 
+    void "test service auto-inherits datasource from domain mapping"() {
+        when: "a service targets a domain with datasource 'secondary' but has 
no @Transactional(connection)"
+        def service = new GroovyClassLoader().parseClass('''
+import grails.gorm.services.Service
+import grails.gorm.annotation.Entity
+import grails.gorm.transactions.Transactional
+
+@Service(AutoWidget)
+abstract class AutoWidgetService {
+
+    abstract AutoWidget save(AutoWidget widget)
+
+    abstract AutoWidget find(Serializable id)
+
+    abstract AutoWidget delete(Serializable id)
+}
+
+@Entity
+class AutoWidget {
+    String name
+
+    static mapping = {
+        datasource 'secondary'
+    }
+}
+''')
+
+        then: "the class compiles without errors"
+        !service.isInterface()
+
+        when: "the implementation is loaded"
+        def impl = 
service.classLoader.loadClass('$AutoWidgetServiceImplementation')
+
+        then: "the implementation inherits @Transactional(connection) from the 
domain datasource"
+        impl != null
+        impl.getAnnotation(Transactional) != null
+        impl.getAnnotation(Transactional).connection() == 'secondary'
+    }
+
+    void "test interface service auto-inherits datasource from domain 
mapping"() {
+        when: "an interface service targets a domain with datasource 
'secondary' but has no @Transactional(connection)"
+        def service = new GroovyClassLoader().parseClass('''
+import grails.gorm.services.Service
+import grails.gorm.annotation.Entity
+import grails.gorm.transactions.Transactional
+
+@Service(AutoGadget)
+interface AutoGadgetService {
+
+    AutoGadget save(AutoGadget gadget)
+
+    AutoGadget find(Serializable id)
+
+    AutoGadget delete(Serializable id)
+}
+
+@Entity
+class AutoGadget {
+    String label
+
+    static mapping = {
+        datasource 'secondary'
+    }
+}
+''')
+
+        then: "the interface compiles without errors"
+        service.isInterface()
+
+        when: "the implementation is loaded"
+        def impl = 
service.classLoader.loadClass('$AutoGadgetServiceImplementation')
+
+        then: "the implementation inherits @Transactional(connection) from the 
domain datasource"
+        impl != null
+        impl.getAnnotation(Transactional) != null
+        impl.getAnnotation(Transactional).connection() == 'secondary'
+    }
+
+    void "test explicit @Transactional(connection) wins over domain 
datasource"() {
+        when: "a service has explicit @Transactional(connection='primary') but 
domain has datasource 'secondary'"
+        def service = new GroovyClassLoader().parseClass('''
+import grails.gorm.services.Service
+import grails.gorm.annotation.Entity
+import grails.gorm.transactions.Transactional
+
+@Service(ExplicitItem)
+@Transactional(connection = 'primary')
+abstract class ExplicitItemService {
+
+    abstract ExplicitItem save(ExplicitItem item)
+}
+
+@Entity
+class ExplicitItem {
+    String name
+
+    static mapping = {
+        datasource 'secondary'
+    }
+}
+''')
+
+        then: "the class compiles without errors"
+        !service.isInterface()
+
+        when: "the implementation is loaded"
+        def impl = 
service.classLoader.loadClass('$ExplicitItemServiceImplementation')
+
+        then: "the explicit connection annotation wins"
+        impl != null
+        impl.getAnnotation(Transactional) != null
+        impl.getAnnotation(Transactional).connection() == 'primary'
+    }
+
+    void "test service with domain on default datasource gets no connection 
annotation"() {
+        when: "a service targets a domain with no datasource mapping (default)"
+        def service = new GroovyClassLoader().parseClass('''
+import grails.gorm.services.Service
+import grails.gorm.annotation.Entity
+import grails.gorm.transactions.Transactional
+
+@Service(DefaultItem)
+abstract class DefaultItemService {
+
+    abstract DefaultItem save(DefaultItem item)
+}
+
+@Entity
+class DefaultItem {
+    String name
+}
+''')
+
+        then: "the class compiles without errors"
+        !service.isInterface()
+
+        when: "the implementation is loaded"
+        def impl = 
service.classLoader.loadClass('$DefaultItemServiceImplementation')
+
+        then: "no @Transactional(connection) is added"
+        impl != null
+        def txAnn = impl.getAnnotation(Transactional)
+        txAnn == null || txAnn.connection() == ''
+    }
+
+    void "test service auto-inherits connection from domain using connection() 
method"() {
+        when: "a domain uses connection() instead of datasource() in its 
mapping"
+        def service = new GroovyClassLoader().parseClass('''
+import grails.gorm.services.Service
+import grails.gorm.annotation.Entity
+import grails.gorm.transactions.Transactional
+
+@Service(ConnItem)
+abstract class ConnItemService {
+
+    abstract ConnItem save(ConnItem item)
+}
+
+@Entity
+class ConnItem {
+    String name
+
+    static mapping = {
+        connection 'warehouse'
+    }
+}
+''')
+
+        then: "the class compiles without errors"
+        !service.isInterface()
+
+        when: "the implementation is loaded"
+        def impl = 
service.classLoader.loadClass('$ConnItemServiceImplementation')
+
+        then: "the implementation inherits @Transactional(connection) from the 
domain's connection"
+        impl != null
+        impl.getAnnotation(Transactional) != null
+        impl.getAnnotation(Transactional).connection() == 'warehouse'
+    }
+
+    void "test @Transactional without connection gets connection added from 
domain"() {
+        when: "a service has @Transactional (no connection) and domain has 
datasource 'secondary'"
+        def service = new GroovyClassLoader().parseClass('''
+import grails.gorm.services.Service
+import grails.gorm.annotation.Entity
+import grails.gorm.transactions.Transactional
+
+@Service(TxItem)
+@Transactional
+abstract class TxItemService {
+
+    abstract TxItem save(TxItem item)
+}
+
+@Entity
+class TxItem {
+    String name
+
+    static mapping = {
+        datasource 'secondary'
+    }
+}
+''')
+
+        then: "the class compiles without errors"
+        !service.isInterface()
+
+        when: "the implementation is loaded"
+        def impl = 
service.classLoader.loadClass('$TxItemServiceImplementation')
+
+        then: "the existing @Transactional gets the connection member from the 
domain"
+        impl != null
+        impl.getAnnotation(Transactional) != null
+        impl.getAnnotation(Transactional).connection() == 'secondary'
+    }
+
 }
diff --git 
a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/config/DatastoreServiceMethodInvokingFactoryBean.groovy
 
b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/config/DatastoreServiceMethodInvokingFactoryBean.groovy
index e59393c918..6026ee4d5f 100644
--- 
a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/config/DatastoreServiceMethodInvokingFactoryBean.groovy
+++ 
b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/config/DatastoreServiceMethodInvokingFactoryBean.groovy
@@ -19,6 +19,8 @@
 
 package org.grails.datastore.mapping.config
 
+import java.lang.annotation.Annotation
+
 import groovy.transform.CompileStatic
 import groovy.transform.Internal
 
@@ -29,6 +31,10 @@ import 
org.springframework.beans.factory.config.MethodInvokingFactoryBean
 import org.springframework.lang.Nullable
 
 import org.grails.datastore.mapping.core.Datastore
+import org.grails.datastore.mapping.core.connections.ConnectionSource
+import org.grails.datastore.mapping.core.connections.ConnectionSourcesSupport
+import 
org.grails.datastore.mapping.core.connections.MultipleConnectionSourceCapableDatastore
+import org.grails.datastore.mapping.model.PersistentEntity
 import org.grails.datastore.mapping.services.Service
 
 /**
@@ -56,7 +62,8 @@ class DatastoreServiceMethodInvokingFactoryBean extends 
MethodInvokingFactoryBea
     protected Object invokeWithTargetException() throws Exception {
         Object object = super.invokeWithTargetException()
         if (object) {
-            ((Service) object).setDatastore((Datastore) targetObject)
+            Datastore effectiveDatastore = 
resolveEffectiveDatastore((Datastore) targetObject)
+            ((Service) object).setDatastore(effectiveDatastore)
             if (beanFactory instanceof AutowireCapableBeanFactory) {
                 ((AutowireCapableBeanFactory) 
beanFactory).autowireBeanProperties(object, 
AutowireCapableBeanFactory.AUTOWIRE_BY_TYPE, false)
             }
@@ -64,6 +71,44 @@ class DatastoreServiceMethodInvokingFactoryBean extends 
MethodInvokingFactoryBea
         object
     }
 
+    private Datastore resolveEffectiveDatastore(Datastore defaultDatastore) {
+        if (!(defaultDatastore instanceof 
MultipleConnectionSourceCapableDatastore)) {
+            return defaultDatastore
+        }
+
+        Class<?> domainClass = getServiceDomainClass()
+        if (domainClass == null || domainClass == Object) {
+            return defaultDatastore
+        }
+
+        PersistentEntity entity = 
defaultDatastore.getMappingContext()?.getPersistentEntity(domainClass.getName())
+        if (entity == null) {
+            return defaultDatastore
+        }
+
+        String domainConnection = 
ConnectionSourcesSupport.getDefaultConnectionSourceName(entity)
+        if (domainConnection != null
+                && !ConnectionSource.DEFAULT.equals(domainConnection)
+                && !ConnectionSource.ALL.equals(domainConnection)) {
+            return ((MultipleConnectionSourceCapableDatastore) 
defaultDatastore).getDatastoreForConnection(domainConnection)
+        }
+
+        return defaultDatastore
+    }
+
+    private Class<?> getServiceDomainClass() {
+        try {
+            for (Annotation ann : serviceClass.getAnnotations()) {
+                if ('grails.gorm.services.Service' == 
ann.annotationType().getName()) {
+                    return (Class<?>) 
ann.annotationType().getMethod('value').invoke(ann)
+                }
+            }
+        }
+        catch (Exception ignored) {
+        }
+        return null
+    }
+
     @Override
     void setBeanFactory(BeanFactory beanFactory) {
         super.setBeanFactory(beanFactory)
diff --git a/grails-doc/src/en/guide/conf/dataSource/multipleDatasources.adoc 
b/grails-doc/src/en/guide/conf/dataSource/multipleDatasources.adoc
index bd9e875a78..643e2b8ab5 100644
--- a/grails-doc/src/en/guide/conf/dataSource/multipleDatasources.adoc
+++ b/grails-doc/src/en/guide/conf/dataSource/multipleDatasources.adoc
@@ -260,6 +260,50 @@ Note that the datasource specified in a service has no 
bearing on which datasour
 
 If you have a `Foo` domain class in `dataSource1` and a `Bar` domain class in 
`dataSource2`, if `WahooService` uses `dataSource1`, a service method that 
saves a new `Foo` and a new `Bar` will only be transactional for `Foo` since 
they share the same datasource. The transaction won't affect the `Bar` 
instance. If you want both to be transactional you'd need to use two services 
and XA datasources for two-phase commit, e.g. with the Atomikos plugin.
 
+===== GORM Data Service Datasource Inheritance
+
+When using GORM Data Services (the `@Service` annotation), the service 
automatically inherits the datasource from its domain class's `mapping` block. 
This means you do not need to explicitly specify `@Transactional(connection = 
'...')` if the domain class already declares its datasource.
+
+For example, given a domain class mapped to the `'lookup'` datasource:
+
+[source,groovy]
+----
+class ZipCode {
+
+   String code
+
+   static mapping = {
+      datasource 'lookup'
+   }
+}
+----
+
+A GORM Data Service targeting this domain class will automatically route all 
auto-implemented operations (save, get, delete, find, count) to the `'lookup'` 
datasource:
+
+[source,groovy]
+----
+@Service(ZipCode)
+abstract class ZipCodeService {
+
+   abstract ZipCode get(Serializable id)
+   abstract ZipCode save(ZipCode zipCode)
+   abstract ZipCode delete(Serializable id)
+   abstract List<ZipCode> findByCode(String code)
+}
+----
+
+If you need to override the inherited datasource, you can still use an 
explicit `@Transactional(connection = '...')` annotation on the service, which 
takes precedence:
+
+[source,groovy]
+----
+@Service(ZipCode)
+@Transactional(connection = 'auditing')
+abstract class ZipCodeAuditService {
+
+   abstract ZipCode save(ZipCode zipCode)
+}
+----
+
 ==== Transactions across multiple data sources
 
 Grails does not by default try to handle transactions that span multiple data 
sources.
diff --git 
a/grails-test-examples/hibernate5/grails-data-service-multi-datasource/grails-app/services/example/InheritedProductService.groovy
 
b/grails-test-examples/hibernate5/grails-data-service-multi-datasource/grails-app/services/example/InheritedProductService.groovy
new file mode 100644
index 0000000000..af5494f7ef
--- /dev/null
+++ 
b/grails-test-examples/hibernate5/grails-data-service-multi-datasource/grails-app/services/example/InheritedProductService.groovy
@@ -0,0 +1,38 @@
+/*
+ *  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 example
+
+import grails.gorm.services.Service
+
+@Service(Product)
+abstract class InheritedProductService {
+
+    abstract Product get(Serializable id)
+
+    abstract Product save(Product product)
+
+    abstract Product delete(Serializable id)
+
+    abstract Number count()
+
+    abstract Product findByName(String name)
+
+    abstract List<Product> findAllByName(String name)
+}
diff --git 
a/grails-test-examples/hibernate5/grails-data-service-multi-datasource/src/integration-test/groovy/functionaltests/DataServiceDatasourceInheritanceSpec.groovy
 
b/grails-test-examples/hibernate5/grails-data-service-multi-datasource/src/integration-test/groovy/functionaltests/DataServiceDatasourceInheritanceSpec.groovy
new file mode 100644
index 0000000000..8432b789be
--- /dev/null
+++ 
b/grails-test-examples/hibernate5/grails-data-service-multi-datasource/src/integration-test/groovy/functionaltests/DataServiceDatasourceInheritanceSpec.groovy
@@ -0,0 +1,121 @@
+/*
+ *  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 functionaltests
+
+import example.Product
+import example.InheritedProductService
+
+import org.springframework.beans.factory.annotation.Autowired
+
+import grails.testing.mixin.integration.Integration
+import org.grails.orm.hibernate.HibernateDatastore
+import spock.lang.Specification
+
+@Integration
+class DataServiceDatasourceInheritanceSpec extends Specification {
+
+    @Autowired
+    HibernateDatastore hibernateDatastore
+
+    InheritedProductService inheritedProductService
+
+    void setup() {
+        inheritedProductService = hibernateDatastore
+                .getDatastoreForConnection('secondary')
+                .getService(InheritedProductService)
+    }
+
+    void cleanup() {
+        Product.secondary.withTransaction {
+            Product.secondary.executeUpdate('delete from Product')
+        }
+    }
+
+    void "save routes to secondary datasource via inherited connection"() {
+        when:
+        def saved = inheritedProductService.save(new Product(name: 
'InheritedWidget', amount: 42))
+
+        then:
+        saved != null
+        saved.id != null
+        saved.name == 'InheritedWidget'
+        saved.amount == 42
+    }
+
+    void "get by ID routes to secondary datasource via inherited connection"() 
{
+        given:
+        def saved = inheritedProductService.save(new Product(name: 
'InheritedGadget', amount: 99))
+
+        when:
+        def found = inheritedProductService.get(saved.id)
+
+        then:
+        found != null
+        found.id == saved.id
+        found.name == 'InheritedGadget'
+        found.amount == 99
+    }
+
+    void "count routes to secondary datasource via inherited connection"() {
+        given:
+        inheritedProductService.save(new Product(name: 'Alpha', amount: 10))
+        inheritedProductService.save(new Product(name: 'Beta', amount: 20))
+
+        expect:
+        inheritedProductService.count() == 2
+    }
+
+    void "delete routes to secondary datasource via inherited connection"() {
+        given:
+        def saved = inheritedProductService.save(new Product(name: 
'Ephemeral', amount: 1))
+
+        when:
+        inheritedProductService.delete(saved.id)
+
+        then:
+        inheritedProductService.get(saved.id) == null
+    }
+
+    void "findByName routes to secondary datasource via inherited 
connection"() {
+        given:
+        inheritedProductService.save(new Product(name: 'Unique', amount: 77))
+
+        when:
+        def found = inheritedProductService.findByName('Unique')
+
+        then:
+        found != null
+        found.name == 'Unique'
+        found.amount == 77
+    }
+
+    void "findAllByName routes to secondary datasource via inherited 
connection"() {
+        given:
+        inheritedProductService.save(new Product(name: 'Duplicate', amount: 
10))
+        inheritedProductService.save(new Product(name: 'Duplicate', amount: 
20))
+        inheritedProductService.save(new Product(name: 'Other', amount: 30))
+
+        when:
+        def found = inheritedProductService.findAllByName('Duplicate')
+
+        then:
+        found.size() == 2
+        found.every { it.name == 'Duplicate' }
+    }
+}


Reply via email to