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

(grails-core) 01/01: Replace finders inheritance hierarchy with composition

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

jdaugherty pushed a commit to branch refactor/finder-composition-8.0.x
in repository https://gitbox.apache.org/repos/asf/grails-core.git

commit 7bb6de9e568d15ea780f9b14d84d036dc4ae726e
Author: Walter Duque de Estrada <wb...@mac.com>
AuthorDate: Fri Oct 2 09:31:22 2026 -0400

    Replace finders inheritance hierarchy with composition
    
    Backport of #16154 to 8.0.x.
    
    The dynamic finders in org.grails.datastore.gorm.finders no longer form an
    AbstractFinder -> DynamicFinder -> AbstractFindByFinder -> concrete finder
    chain. SingleResultFinder, ListResultFinder and CountFinder are flat
    classes, created through static factories, that compose a DynamicFinder.
    DynamicFinder is now only the method-name grammar and criteria builder.
    
    - findOrCreateBy* and findOrSaveBy* share one implementation of building
      an instance from the Equal expressions. The old FindOrSaveByFinder copy
      of that branch could never run
    - countBy*Or* resolves association arguments to their identifiers, as
      countBy*And* does, which fixes the MongoDB under-count. The
      @PendingFeature on the Mongo spec is removed
    - AbstractDetachedCriteria#dynamicFinders is typed List<FinderMethod>
    - The removed public classes and the factories that replace them are
      listed in the upgrade guide
    
    The RxGORM half of #16154 is not included, because grails-datamapping-rx
    is not built on 8.0.x. ListOrderByFinder keeps, and the new FinderSupport
    gets, the ASF license header.
    
    Fixes #15789
    
    Co-authored-by: James Daugherty <jd...@jdresources.net>
---
 .../gorm/mongo/DisjunctionQuerySpec.groovy         |   2 -
 .../datastore/gorm/DefaultGormApiFactory.groovy    |  24 +-
 .../org/grails/datastore/gorm/GormEnhancer.groovy  |  24 +-
 .../gorm/finders/AbstractFindByFinder.java         |  69 --
 .../datastore/gorm/finders/AbstractFinder.java     |  86 --
 .../datastore/gorm/finders/CountByFinder.java      |  91 ---
 .../grails/datastore/gorm/finders/CountFinder.java | 157 ++++
 .../datastore/gorm/finders/DynamicFinder.java      | 359 ++++-----
 .../gorm/finders/DynamicFinderInvocation.java      |  12 +-
 .../gorm/finders/FindAllByBooleanFinder.java       |  56 --
 .../datastore/gorm/finders/FindAllByFinder.java    |  71 --
 .../gorm/finders/FindByBooleanFinder.java          |  51 --
 .../datastore/gorm/finders/FindByFinder.java       |  45 --
 .../gorm/finders/FindOrCreateByFinder.java         | 117 ---
 .../datastore/gorm/finders/FindOrSaveByFinder.java |  53 --
 .../datastore/gorm/finders/FinderGrammar.java      |  65 ++
 .../datastore/gorm/finders/FinderSupport.java      |  82 ++
 .../datastore/gorm/finders/ListOrderByFinder.java  | 107 +--
 .../datastore/gorm/finders/ListResultFinder.java   | 134 ++++
 .../datastore/gorm/finders/MethodExpression.java   |  64 +-
 .../datastore/gorm/finders/SingleResultFinder.java | 245 ++++++
 .../query/criteria/AbstractDetachedCriteria.groovy |   3 +-
 .../datastore/gorm/finders/CountFinderSpec.groovy  | 261 +++++++
 ...c.groovy => DatastoreResolverFinderSpec.groovy} |  66 +-
 .../gorm/finders/DynamicFinderCoverageSpec.groovy  |  10 +-
 .../finders/DynamicFinderInvocationSpec.groovy     |  67 ++
 .../gorm/finders/DynamicFinderSpec.groovy          | 869 ++++++++++++++++++++-
 .../finders/FindOrSaveByDynamicFinderSpec.groovy   |  63 ++
 .../gorm/finders/FindOrSaveByFinderSpec.groovy     |  88 ---
 .../gorm/finders/FinderSupportSpec.groovy          | 123 +++
 .../datastore/gorm/finders/FinderTestEntity.groovy |  49 ++
 .../gorm/finders/ListOrderByFinderSpec.groovy      | 210 +++++
 .../gorm/finders/ListResultFinderSpec.groovy       | 222 ++++++
 .../gorm/finders/MethodExpressionSpec.groovy       | 574 ++++++++++++++
 .../gorm/finders/SingleResultFinderSpec.groovy     | 412 ++++++++++
 .../src/en/guide/upgrading/upgrading80x.adoc       |  35 +
 36 files changed, 3898 insertions(+), 1068 deletions(-)

diff --git a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/DisjunctionQuerySpec.groovy b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/DisjunctionQuerySpec.groovy
index 38569b5efc..68ecda3680 100644
--- a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/DisjunctionQuerySpec.groovy
+++ b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/DisjunctionQuerySpec.groovy
@@ -24,7 +24,6 @@ import org.apache.grails.data.testing.tck.domains.PetType
 import org.apache.grails.data.mongo.core.GrailsDataMongoTckManager
 import org.apache.grails.data.testing.tck.base.GrailsDataTckSpec
 import spock.lang.Issue
-import spock.lang.PendingFeature
 
 class DisjunctionQuerySpec extends GrailsDataTckSpec<GrailsDataMongoTckManager> {
     def dogType
@@ -65,7 +64,6 @@ class DisjunctionQuerySpec extends GrailsDataTckSpec<GrailsDataMongoTckManager>
         pet.name == "Big Bird"
     }
 
-    @PendingFeature(reason = "Pre-existing: count over an OR disjunction (countByXOrY) under-counts on MongoDB — the count aggregation does not correctly count distinct matches of the disjunction. Verified pre-existing (MongoQuery count path is unchanged from before the GormRegistry rewrite; find-over-OR works). Tracked for an independent fix in https://github.com/apache/grails-core/issues/15789.")
     @Issue('GPMONGODB-380')
     void "Count all dogs or pets with the name Jack"() {
         given: "Some data"
diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/DefaultGormApiFactory.groovy b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/DefaultGormApiFactory.groovy
index 331f887e09..d1e8761f03 100644
--- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/DefaultGormApiFactory.groovy
+++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/DefaultGormApiFactory.groovy
@@ -19,15 +19,11 @@
 package org.grails.datastore.gorm
 
 import groovy.transform.CompileStatic
-import org.grails.datastore.gorm.finders.CountByFinder
-import org.grails.datastore.gorm.finders.FindAllByBooleanFinder
-import org.grails.datastore.gorm.finders.FindAllByFinder
-import org.grails.datastore.gorm.finders.FindByBooleanFinder
-import org.grails.datastore.gorm.finders.FindByFinder
+import org.grails.datastore.gorm.finders.CountFinder
 import org.grails.datastore.gorm.finders.FinderMethod
-import org.grails.datastore.gorm.finders.FindOrCreateByFinder
-import org.grails.datastore.gorm.finders.FindOrSaveByFinder
 import org.grails.datastore.gorm.finders.ListOrderByFinder
+import org.grails.datastore.gorm.finders.ListResultFinder
+import org.grails.datastore.gorm.finders.SingleResultFinder
 import org.grails.datastore.mapping.model.MappingContext
 
 /**
@@ -71,13 +67,13 @@ class DefaultGormApiFactory implements GormApiFactory {
 
     @Override
     List<FinderMethod> createDynamicFinders(DatastoreResolver datastoreResolver, MappingContext mappingContext) {
-        [new FindOrCreateByFinder(datastoreResolver, mappingContext),
-         new FindOrSaveByFinder(datastoreResolver, mappingContext),
-         new FindByFinder(datastoreResolver, mappingContext),
-         new FindAllByFinder(datastoreResolver, mappingContext),
-         new FindAllByBooleanFinder(datastoreResolver, mappingContext),
-         new FindByBooleanFinder(datastoreResolver, mappingContext),
-         new CountByFinder(datastoreResolver, mappingContext),
+        [SingleResultFinder.findOrCreateBy(datastoreResolver, mappingContext),
+         SingleResultFinder.findOrSaveBy(datastoreResolver, mappingContext),
+         SingleResultFinder.findBy(datastoreResolver, mappingContext),
+         ListResultFinder.findAllBy(datastoreResolver, mappingContext),
+         ListResultFinder.findAllByBoolean(datastoreResolver, mappingContext),
+         SingleResultFinder.findByBoolean(datastoreResolver, mappingContext),
+         CountFinder.countBy(datastoreResolver, mappingContext),
          new ListOrderByFinder(datastoreResolver, mappingContext)] as List<FinderMethod>
     }
 }
diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormEnhancer.groovy b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormEnhancer.groovy
index b30438a4a4..68bff58c54 100644
--- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormEnhancer.groovy
+++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormEnhancer.groovy
@@ -28,15 +28,11 @@ import org.springframework.transaction.PlatformTransactionManager
 import org.springframework.util.ClassUtils
 
 import grails.gorm.MultiTenant
-import org.grails.datastore.gorm.finders.CountByFinder
-import org.grails.datastore.gorm.finders.FindAllByBooleanFinder
-import org.grails.datastore.gorm.finders.FindAllByFinder
-import org.grails.datastore.gorm.finders.FindByBooleanFinder
-import org.grails.datastore.gorm.finders.FindByFinder
+import org.grails.datastore.gorm.finders.CountFinder
 import org.grails.datastore.gorm.finders.FinderMethod
-import org.grails.datastore.gorm.finders.FindOrCreateByFinder
-import org.grails.datastore.gorm.finders.FindOrSaveByFinder
 import org.grails.datastore.gorm.finders.ListOrderByFinder
+import org.grails.datastore.gorm.finders.ListResultFinder
+import org.grails.datastore.gorm.finders.SingleResultFinder
 import org.grails.datastore.mapping.core.Datastore
 import org.grails.datastore.mapping.core.connections.ConnectionSource
 import org.grails.datastore.mapping.core.connections.ConnectionSourceSettings
@@ -423,13 +419,13 @@ class GormEnhancer implements Closeable {
     @Deprecated
     @CompileStatic
     protected List<FinderMethod> createDynamicFinders(Datastore targetDatastore) {
-        [new FindOrCreateByFinder(targetDatastore),
-         new FindOrSaveByFinder(targetDatastore),
-         new FindByFinder(targetDatastore),
-         new FindAllByFinder(targetDatastore),
-         new FindAllByBooleanFinder(targetDatastore),
-         new FindByBooleanFinder(targetDatastore),
-         new CountByFinder(targetDatastore),
+        [SingleResultFinder.findOrCreateBy(targetDatastore),
+         SingleResultFinder.findOrSaveBy(targetDatastore),
+         SingleResultFinder.findBy(targetDatastore),
+         ListResultFinder.findAllBy(targetDatastore),
+         ListResultFinder.findAllByBoolean(targetDatastore),
+         SingleResultFinder.findByBoolean(targetDatastore),
+         CountFinder.countBy(targetDatastore),
          new ListOrderByFinder(targetDatastore)] as List<FinderMethod>
     }
 
diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/AbstractFindByFinder.java b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/AbstractFindByFinder.java
deleted file mode 100644
index 8f9e42a4ff..0000000000
--- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/AbstractFindByFinder.java
+++ /dev/null
@@ -1,69 +0,0 @@
-/*
- *  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.finders;
-
-import java.util.regex.Pattern;
-
-import org.grails.datastore.gorm.DatastoreResolver;
-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.model.MappingContext;
-import org.grails.datastore.mapping.query.Query;
-
-public abstract class AbstractFindByFinder extends DynamicFinder {
-    public static final String OPERATOR_OR = "Or";
-    public static final String OPERATOR_AND = "And";
-    public static final String[] OPERATORS = { OPERATOR_AND, OPERATOR_OR };
-
-    protected AbstractFindByFinder(Pattern pattern, Datastore datastore) {
-        super(pattern, OPERATORS, datastore);
-    }
-
-    protected AbstractFindByFinder(Pattern pattern, String[] operators, DatastoreResolver datastoreResolver, MappingContext mappingContext) {
-        super(pattern, operators, datastoreResolver, mappingContext);
-    }
-
-    protected AbstractFindByFinder(Pattern pattern, MappingContext mappingContext) {
-        super(pattern, OPERATORS, mappingContext);
-    }
-
-    @Override
-    protected Object doInvokeInternal(final DynamicFinderInvocation invocation) {
-        return execute(new SessionCallback<Object>() {
-            public Object doInSession(final Session session) {
-                Query query = buildQuery(invocation, session);
-                adjustQuery(query);
-                return invokeQuery(query);
-            }
-        });
-    }
-
-    protected Object invokeQuery(Query q) {
-        return q.singleResult();
-    }
-
-    public boolean firstExpressionIsRequiredBoolean() {
-        return super.firstExpressionIsRequiredBoolean();
-    }
-
-    protected void adjustQuery(Query query) {
-    }
-
-}
diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/AbstractFinder.java b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/AbstractFinder.java
deleted file mode 100644
index 3c44da7286..0000000000
--- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/AbstractFinder.java
+++ /dev/null
@@ -1,86 +0,0 @@
-/*
- *  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.finders;
-
-import groovy.lang.Closure;
-
-import grails.gorm.CriteriaBuilder;
-import org.grails.datastore.gorm.DatastoreResolver;
-import org.grails.datastore.mapping.core.Datastore;
-import org.grails.datastore.mapping.core.DatastoreUtils;
-import org.grails.datastore.mapping.core.SessionCallback;
-import org.grails.datastore.mapping.core.VoidSessionCallback;
-import org.grails.datastore.mapping.query.Query;
-
-/**
- * Abstract base class for finders.
- *
- * @author Burt Beckwith
- */
-@SuppressWarnings("rawtypes")
-public abstract class AbstractFinder implements FinderMethod {
-
-    protected DatastoreResolver datastoreResolver;
-    protected Datastore datastore;
-
-    public AbstractFinder(final Datastore datastore) {
-        this.datastore = datastore;
-    }
-
-    public AbstractFinder(final DatastoreResolver datastoreResolver) {
-        this.datastoreResolver = datastoreResolver;
-    }
-
-    protected Datastore getDatastore() {
-        if (datastoreResolver != null) {
-            return datastoreResolver.resolve();
-        }
-        return datastore;
-    }
-
-    protected <T> T execute(final SessionCallback<T> callback) {
-        Datastore ds = getDatastore();
-        if (ds != null) {
-            return DatastoreUtils.execute(ds, callback);
-        }
-        else {
-            throw new IllegalStateException("Cannot execute session query with null datastore");
-        }
-    }
-
-    protected void execute(final VoidSessionCallback callback) {
-        Datastore ds = getDatastore();
-        if (ds != null) {
-            DatastoreUtils.execute(ds, callback);
-        }
-        else {
-            throw new IllegalStateException("Cannot execute session query with null datastore");
-        }
-
-    }
-
-    protected void applyAdditionalCriteria(Query query, Closure additionalCriteria) {
-        if (additionalCriteria == null) {
-            return;
-        }
-
-        CriteriaBuilder builder = new CriteriaBuilder(query.getEntity().getJavaClass(), query.getSession(), query);
-        builder.build(additionalCriteria);
-    }
-}
diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/CountByFinder.java b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/CountByFinder.java
deleted file mode 100644
index 3b835b1042..0000000000
--- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/CountByFinder.java
+++ /dev/null
@@ -1,91 +0,0 @@
-/*
- *  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.finders;
-
-import java.util.regex.Pattern;
-
-import grails.gorm.DetachedCriteria;
-import org.grails.datastore.gorm.DatastoreResolver;
-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.model.MappingContext;
-import org.grails.datastore.mapping.query.Query;
-
-/**
- * Supports countBy* queries.
- *
- * @author Graeme Rocher
- */
-public class CountByFinder extends DynamicFinder implements QueryBuildingFinder {
-
-    private static final String METHOD_PATTERN = "(countBy)([A-Z]\\w*)";
-    protected static final String[] OPERATORS = { "And", "Or" };
-
-    public CountByFinder(final Datastore datastore) {
-        super(Pattern.compile(METHOD_PATTERN), OPERATORS, datastore);
-    }
-
-    public CountByFinder(DatastoreResolver datastoreResolver, MappingContext mappingContext) {
-        super(Pattern.compile(METHOD_PATTERN), OPERATORS, datastoreResolver, mappingContext);
-    }
-
-    public CountByFinder(MappingContext mappingContext) {
-        super(Pattern.compile(METHOD_PATTERN), OPERATORS, mappingContext);
-    }
-
-    @Override
-    protected Object doInvokeInternal(final DynamicFinderInvocation invocation) {
-        return execute(new SessionCallback<Object>() {
-            public Object doInSession(final Session session) {
-                Query q = buildQuery(invocation, session);
-                q.projections().count();
-                return q.singleResult();
-            }
-        });
-    }
-
-    @Override
-    public Query buildQuery(DynamicFinderInvocation invocation, Session session) {
-        final Class clazz = invocation.getJavaClass();
-        Query q = session.createQuery(clazz);
-        applyDetachedCriteria(q, invocation.getDetachedCriteria());
-
-        final String operator = invocation.getOperator();
-        if (operator != null && operator.equals("Or")) {
-            Query.Junction disjunction = q.disjunction();
-            for (MethodExpression expression : invocation.getExpressions()) {
-                disjunction.add(expression.createCriterion());
-            }
-        }
-        else {
-            for (MethodExpression expression : invocation.getExpressions()) {
-                q.add(expression.createCriterion());
-            }
-        }
-        return q;
-    }
-
-    protected void applyDetachedCriteria(Query q, DetachedCriteria detachedCriteria) {
-        if (detachedCriteria != null) {
-            DynamicFinder.applyDetachedCriteria(q, detachedCriteria);
-        }
-    }
-
-}
diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/CountFinder.java b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/CountFinder.java
new file mode 100644
index 0000000000..f376286717
--- /dev/null
+++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/CountFinder.java
@@ -0,0 +1,157 @@
+/*
+ *  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.finders;
+
+import java.util.regex.Pattern;
+
+import groovy.lang.Closure;
+
+import grails.gorm.DetachedCriteria;
+import org.grails.datastore.gorm.DatastoreResolver;
+import org.grails.datastore.mapping.core.Datastore;
+import org.grails.datastore.mapping.core.Session;
+import org.grails.datastore.mapping.model.MappingContext;
+import org.grails.datastore.mapping.query.Query;
+
+/**
+ * Implements {@code countBy*}. Kept as its own dedicated class rather than a variant of {@link
+ * SingleResultFinder}: its {@link #buildQuery} does not reuse {@link DynamicFinder#getJunction} at
+ * all - for the {@code Or} operator it extends the query's own top-level junction directly via
+ * {@code Query.disjunction()}/{@code Query.add(Junction, Criterion)}, rather than building a
+ * standalone {@code Disjunction} added as one nested criterion the way every other finder's
+ * shared {@code buildQuery} does. This divergence is preserved exactly as it existed before this
+ * class existed, not unified with the shared grammar's junction-building.
+ */
+public class CountFinder implements FinderMethod, QueryBuildingFinder {
+
+    private static final Pattern METHOD_PATTERN = Pattern.compile("(countBy)(\\w+)");
+    private static final String[] OPERATORS = {"And", "Or"};
+    private static final String OPERATOR_OR = "Or";
+
+    private final DatastoreResolver datastoreResolver;
+    private final DynamicFinder grammar;
+
+    private CountFinder(DatastoreResolver datastoreResolver, DynamicFinder grammar) {
+        this.datastoreResolver = datastoreResolver;
+        this.grammar = grammar;
+    }
+
+    public static CountFinder countBy(Datastore datastore) {
+        return countBy(FinderSupport.resolverFor(datastore), datastore.getMappingContext());
+    }
+
+    public static CountFinder countBy(DatastoreResolver datastoreResolver, MappingContext mappingContext) {
+        return new CountFinder(datastoreResolver, new DynamicFinder(METHOD_PATTERN, OPERATORS, mappingContext, false));
+    }
+
+    public static CountFinder countBy(MappingContext mappingContext) {
+        return countBy(null, mappingContext);
+    }
+
+    @Override
+    public void setPattern(String pattern) {
+        grammar.setPattern(pattern);
+    }
+
+    @Override
+    public boolean isMethodMatch(String methodName) {
+        return grammar.isMethodMatch(methodName);
+    }
+
+    @Override
+    @SuppressWarnings("rawtypes")
+    public Object invoke(Class clazz, String methodName, Object[] arguments) {
+        return invoke(clazz, methodName, (Closure) null, arguments);
+    }
+
+    @Override
+    @SuppressWarnings("rawtypes")
+    public Object invoke(Class clazz, String methodName, Closure additionalCriteria, Object[] arguments) {
+        DynamicFinderInvocation invocation = grammar.createFinderInvocation(clazz, methodName, additionalCriteria, arguments);
+        return doInvoke(invocation);
+    }
+
+    /**
+     * Not part of {@link FinderMethod} - called reflectively (dynamic Groovy dispatch) by {@link
+     * org.grails.datastore.gorm.query.criteria.AbstractDetachedCriteria#methodMissing}. See {@link
+     * SingleResultFinder#invoke(Class, String, DetachedCriteria, Object[])} for the full rationale.
+     *
+     * @param clazz The persistent class
+     * @param methodName The method name
+     * @param detachedCriteria The detached criteria to merge into the built query
+     * @param arguments The method call arguments
+     * @return The result of the method call
+     */
+    @SuppressWarnings("rawtypes")
+    public Object invoke(Class clazz, String methodName, DetachedCriteria detachedCriteria, Object[] arguments) {
+        DynamicFinderInvocation invocation = grammar.createFinderInvocation(clazz, methodName, null, arguments);
+        if (detachedCriteria != null) {
+            invocation.setDetachedCriteria(detachedCriteria);
+        }
+        return doInvoke(invocation);
+    }
+
+    private Object doInvoke(final DynamicFinderInvocation invocation) {
+        return FinderSupport.execute(datastoreResolver, session -> {
+            Query query = buildQuery(invocation, session);
+            return query.singleResult();
+        });
+    }
+
+    @Override
+    public Query buildQuery(DynamicFinderInvocation invocation, Session session) {
+        final Class<?> clazz = invocation.getJavaClass();
+        Query query = session.createQuery(clazz);
+        return applyCriteriaAndCount(invocation, clazz, query);
+    }
+
+    /**
+     * Applies this finder's independent (non-{@link DynamicFinder#getJunction}) And/Or criteria
+     * handling and the {@code count()} projection to an already-created query. Exposed as a public
+     * static helper so other query-building strategies (e.g. {@code grails-datamapping-rx}'s
+     * count finder, which builds its query outside a {@link Session}) can reuse this logic rather
+     * than duplicating it.
+     *
+     * @param invocation The invocation
+     * @param clazz The persistent class
+     * @param query The already-created query to apply criteria to
+     * @return The same query, for chaining
+     */
+    public static Query applyCriteriaAndCount(DynamicFinderInvocation invocation, Class<?> clazz, Query query) {
+        DynamicFinder.applyAdditionalCriteria(query, invocation.getCriteria());
+        DynamicFinder.applyDetachedCriteria(query, invocation.getDetachedCriteria());
+        DynamicFinder.configureQueryWithArguments(clazz, query, invocation.getArguments());
+
+        String operatorInUse = invocation.getOperator();
+        if (OPERATOR_OR.equals(operatorInUse)) {
+            Query.Junction disjunction = query.disjunction();
+            for (MethodExpression expression : invocation.getExpressions()) {
+                query.add(disjunction, expression.createCriterion());
+            }
+        }
+        else {
+            for (MethodExpression expression : invocation.getExpressions()) {
+                query.add(expression.createCriterion());
+            }
+        }
+
+        query.projections().count();
+        return query;
+    }
+}
diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/DynamicFinder.java b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/DynamicFinder.java
index 1375fb37be..2b48a0cdbf 100644
--- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/DynamicFinder.java
+++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/DynamicFinder.java
@@ -20,6 +20,8 @@ import java.util.Collections;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.function.BiConsumer;
+import java.util.function.Consumer;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 import java.util.stream.Collectors;
@@ -35,8 +37,7 @@ import org.springframework.core.convert.ConversionException;
 import org.springframework.core.convert.ConversionService;
 import org.springframework.util.StringUtils;
 
-import grails.gorm.DetachedCriteria;
-import org.grails.datastore.gorm.DatastoreResolver;
+import grails.gorm.CriteriaBuilder;
 import org.grails.datastore.gorm.finders.MethodExpression.Between;
 import org.grails.datastore.gorm.finders.MethodExpression.Equal;
 import org.grails.datastore.gorm.finders.MethodExpression.GreaterThan;
@@ -56,7 +57,6 @@ import org.grails.datastore.gorm.finders.MethodExpression.NotInList;
 import org.grails.datastore.gorm.finders.MethodExpression.Rlike;
 import org.grails.datastore.gorm.query.criteria.AbstractCriteriaBuilder;
 import org.grails.datastore.gorm.query.criteria.AbstractDetachedCriteria;
-import org.grails.datastore.mapping.core.Datastore;
 import org.grails.datastore.mapping.core.Session;
 import org.grails.datastore.mapping.model.MappingContext;
 import org.grails.datastore.mapping.model.PersistentEntity;
@@ -70,13 +70,16 @@ import org.grails.datastore.mapping.reflect.ClassUtils;
 import org.grails.datastore.mapping.reflect.NameUtils;
 
 /**
- * Abstract base class for dynamic finders.
+ * Parses a dynamic finder method name into a {@link DynamicFinderInvocation}, builds the AND/OR
+ * junction of criteria for it, and exposes the shared argument-map/fetch/sort/detached-criteria
+ * handling used by every finder implementation. Composed (not extended) by the concrete finder
+ * classes in this package and in {@code grails-datamapping-rx} - see {@link FinderGrammar}.
  *
  * @author Graeme Rocher
  * @since 1.0
  */
-@SuppressWarnings({"rawtypes", "unchecked"})
-public abstract class DynamicFinder extends AbstractFinder implements QueryBuildingFinder {
+@SuppressWarnings({"rawtypes", "unchecked", "ResultOfMethodCallIgnored"})
+public class DynamicFinder implements FinderGrammar {
 
     public static final String ARGUMENT_FETCH_SIZE = "fetchSize";
     public static final String ARGUMENT_TIMEOUT = "timeout";
@@ -97,8 +100,8 @@ public abstract class DynamicFinder extends AbstractFinder implements QueryBuild
     private static final String OPERATOR_OR = "Or";
     private static final String OPERATOR_AND = "And";
     private static final String[] DEFAULT_OPERATORS = {OPERATOR_AND, OPERATOR_OR};
-    private Pattern[] operatorPatterns;
-    private String[] operators;
+    private final Pattern[] operatorPatterns;
+    private final String[] operators;
 
     private static Pattern methodExpressinPattern;
 
@@ -108,8 +111,9 @@ public abstract class DynamicFinder extends AbstractFinder implements QueryBuild
     private static final String NOT = "Not";
     private static final String INVALID_SORT_PROPERTY = "Invalid sort property";
     private static final String INVALID_SORT_DIRECTION = "Invalid sort direction";
-    private static final Map<String, Constructor> methodExpressions = new LinkedHashMap<String, Constructor>();
+    private static final Map<String, Constructor> methodExpressions = new LinkedHashMap<>();
     protected final MappingContext mappingContext;
+    private final boolean firstExpressionIsRequiredBoolean;
 
     static {
         defaultOperationPatterns = new Pattern[2];
@@ -129,39 +133,27 @@ public abstract class DynamicFinder extends AbstractFinder implements QueryBuild
             for (Class c : classes) {
                 methodExpressions.put(c.getSimpleName(), c.getConstructor(constructorParamTypes));
             }
-        } catch (SecurityException e) {
-            // ignore
-        } catch (NoSuchMethodException e) {
+        } catch (SecurityException | NoSuchMethodException e) {
             // ignore
         }
 
         resetMethodExpressionPattern();
     }
 
-    protected DynamicFinder(final Pattern pattern, final String[] operators, final DatastoreResolver datastoreResolver, MappingContext mappingContext) {
-        super(datastoreResolver);
-        this.mappingContext = mappingContext;
-        this.pattern = pattern;
-        this.operators = operators;
-        this.operatorPatterns = new Pattern[operators.length];
-        populateOperators(operators);
-    }
-
-    protected DynamicFinder(final Pattern pattern, final String[] operators, final Datastore datastore) {
-        super(datastore);
-        this.mappingContext = datastore.getMappingContext();
-        this.pattern = pattern;
-        this.operators = operators;
-        this.operatorPatterns = new Pattern[operators.length];
-        populateOperators(operators);
-    }
-
-    protected DynamicFinder(final Pattern pattern, final String[] operators, final MappingContext mappingContext) {
-        super((Datastore) null);
+    /**
+     * @param pattern The method-name pattern this grammar matches
+     * @param operators The junction operators this grammar splits on (e.g. {@code {"And", "Or"}})
+     * @param mappingContext The mapping context used to resolve persistent entities/properties
+     * @param firstExpressionIsRequiredBoolean Whether the first parsed expression is a required
+     * boolean clause (the "find&lt;booleanProperty&gt;By*"/"findAll&lt;booleanProperty&gt;By*" forms)
+     */
+    public DynamicFinder(final Pattern pattern, final String[] operators, final MappingContext mappingContext,
+            final boolean firstExpressionIsRequiredBoolean) {
         this.mappingContext = mappingContext;
         this.pattern = pattern;
         this.operators = operators;
         this.operatorPatterns = new Pattern[operators.length];
+        this.firstExpressionIsRequiredBoolean = firstExpressionIsRequiredBoolean;
         populateOperators(operators);
     }
 
@@ -176,11 +168,7 @@ public abstract class DynamicFinder extends AbstractFinder implements QueryBuild
             methodExpressions.put(methodExpression.getSimpleName(), methodExpression.getConstructor(
                     Class.class, String.class));
             resetMethodExpressionPattern();
-        } catch (SecurityException e) {
-            throw new IllegalArgumentException("Class [" + methodExpression +
-                    "] does not provide a constructor that takes parameters of type Class and String: " +
-                    e.getMessage(), e);
-        } catch (NoSuchMethodException e) {
+        } catch (SecurityException | NoSuchMethodException e) {
             throw new IllegalArgumentException("Class [" + methodExpression +
                     "] does not provide a constructor that takes parameters of type Class and String: " +
                     e.getMessage(), e);
@@ -198,54 +186,52 @@ public abstract class DynamicFinder extends AbstractFinder implements QueryBuild
     public static MatchSpec buildMatchSpec(String prefix, String methodName, int parameterCount) {
         String methodPattern = "(" + prefix + ")([A-Z]\\w*)";
         Matcher matcher = Pattern.compile(methodPattern).matcher(methodName);
-        if (matcher.find()) {
+        if (matcher.find() && matcher.groupCount() == 2) {
             int totalRequiredArguments = 0;
             List<MethodExpression> expressions = new ArrayList<>();
-            if (matcher.groupCount() == 2) {
-                String querySequence = matcher.group(2);
-                String operatorInUse;
-                boolean containsOperator = false;
-                String[] queryParameters;
-                for (int i = 0; i < DEFAULT_OPERATORS.length; i++) {
-                    Matcher currentMatcher = defaultOperationPatterns[i].matcher(querySequence);
-                    if (currentMatcher.find()) {
-                        containsOperator = true;
-                        operatorInUse = DEFAULT_OPERATORS[i];
-
-                        queryParameters = querySequence.split(operatorInUse);
-                        // loop through query parameters and create expressions
-                        // calculating the number of arguments required for the expression
-                        for (String queryParameter : queryParameters) {
-                            MethodExpression currentExpression = findMethodExpression(queryParameter);
-                            // add to list of expressions
-                            totalRequiredArguments += currentExpression.argumentsRequired;
-                            expressions.add(currentExpression);
-                        }
-                        break;
-                    }
-                }
-
-                // otherwise there is only one expression
-                if (!containsOperator && querySequence != null) {
-                    MethodExpression solo = findMethodExpression(querySequence);
+            String querySequence = matcher.group(2);
+            String operatorInUse;
+            boolean containsOperator = false;
+            String[] queryParameters;
+            for (int i = 0; i < DEFAULT_OPERATORS.length; i++) {
+                Matcher currentMatcher = defaultOperationPatterns[i].matcher(querySequence);
+                if (currentMatcher.find()) {
+                    containsOperator = true;
+                    operatorInUse = DEFAULT_OPERATORS[i];
 
-                    final int requiredArguments = solo.getArgumentsRequired();
-                    if (requiredArguments > parameterCount) {
-                        return null;
+                    queryParameters = querySequence.split(operatorInUse);
+                    // loop through query parameters and create expressions
+                    // calculating the number of arguments required for the expression
+                    for (String queryParameter : queryParameters) {
+                        MethodExpression currentExpression = findMethodExpression(queryParameter);
+                        // add to list of expressions
+                        totalRequiredArguments += currentExpression.argumentsRequired;
+                        expressions.add(currentExpression);
                     }
-
-                    totalRequiredArguments += requiredArguments;
-                    expressions.add(solo);
+                    break;
                 }
+            }
+
+            // otherwise there is only one expression
+            if (!containsOperator) {
+                MethodExpression solo = findMethodExpression(querySequence);
 
-                // if the total of all the arguments necessary does not equal the number of arguments
-                // return null
-                if (totalRequiredArguments > parameterCount) {
+                final int requiredArguments = solo.getArgumentsRequired();
+                if (requiredArguments > parameterCount) {
                     return null;
                 }
-                else {
-                    return new MatchSpec(methodName, prefix, querySequence, totalRequiredArguments, expressions);
-                }
+
+                totalRequiredArguments += requiredArguments;
+                expressions.add(solo);
+            }
+
+            // if the total of all the arguments necessary does not equal the number of arguments
+            // return null
+            if (totalRequiredArguments > parameterCount) {
+                return null;
+            }
+            else {
+                return new MatchSpec(methodName, prefix, querySequence, totalRequiredArguments, expressions);
             }
         }
         return null;
@@ -265,23 +251,12 @@ public abstract class DynamicFinder extends AbstractFinder implements QueryBuild
      * @param methodName The method name
      * @return True if it is
      */
+    @Override
     public boolean isMethodMatch(String methodName) {
         return pattern.matcher(methodName.subSequence(0, methodName.length())).find();
     }
 
-    public Object invoke(final Class clazz, String methodName, Closure additionalCriteria, Object[] arguments) {
-        DynamicFinderInvocation invocation = createFinderInvocation(clazz, methodName, additionalCriteria, arguments);
-        return doInvokeInternal(invocation);
-    }
-
-    public Object invoke(final Class clazz, String methodName, DetachedCriteria detachedCriteria, Object[] arguments) {
-        DynamicFinderInvocation invocation = createFinderInvocation(clazz, methodName, null, arguments);
-        if (detachedCriteria != null) {
-            invocation.setDetachedCriteria(detachedCriteria);
-        }
-        return doInvokeInternal(invocation);
-    }
-
+    @Override
     public DynamicFinderInvocation createFinderInvocation(Class clazz, String methodName,
             Closure additionalCriteria, Object[] arguments) {
 
@@ -349,7 +324,7 @@ public abstract class DynamicFinder extends AbstractFinder implements QueryBuild
                         for (int k = 0; k < requiredArgs; k++, argumentCursor++) {
                             currentArguments[k] = arguments[argumentCursor];
                         }
-                        currentExpression = getInitializedExpression(currentExpression, currentArguments);
+                        getInitializedExpression(currentExpression, currentArguments);
                         PersistentEntity persistentEntity = mappingContext.getPersistentEntity(clazz.getName());
 
                         try {
@@ -376,9 +351,7 @@ public abstract class DynamicFinder extends AbstractFinder implements QueryBuild
             }
 
             totalRequiredArguments += requiredArguments;
-            Object[] soloArgs = new Object[requiredArguments];
-            System.arraycopy(arguments, 0, soloArgs, 0, requiredArguments);
-            solo = getInitializedExpression(solo, arguments);
+            getInitializedExpression(solo, arguments);
             PersistentEntity persistentEntity = mappingContext.getPersistentEntity(clazz.getName());
             try {
                 solo.convertArguments(persistentEntity);
@@ -408,10 +381,6 @@ public abstract class DynamicFinder extends AbstractFinder implements QueryBuild
                 expressions, additionalCriteria, operatorInUse);
     }
 
-    public Object invoke(final Class clazz, String methodName, Object[] arguments) {
-        return invoke(clazz, methodName, (Closure) null, arguments);
-    }
-
     /**
      * Populates arguments for the given query form the given map
      * @param query The query
@@ -423,29 +392,10 @@ public abstract class DynamicFinder extends AbstractFinder implements QueryBuild
         }
         String orderParam = (String) argMap.get(ARGUMENT_ORDER);
 
-        Object fetchObj = argMap.get(ARGUMENT_FETCH);
-        if (fetchObj instanceof Map) {
-            Map fetch = (Map) fetchObj;
-            for (Object o : fetch.keySet()) {
-                String associationName = (String) o;
-                Object fetchValue = fetch.get(associationName);
-                if (fetchValue instanceof FetchType) {
-                    FetchType fetchType = (FetchType) fetchValue;
-                    handleFetchType(query, associationName, fetchType);
-                }
-                else if (fetchValue instanceof JoinType) {
-                    JoinType joinType = (JoinType) fetchValue;
-                    query.join(associationName, joinType);
-                } else {
-                    FetchType fetchType = getFetchMode(fetchValue);
-                    handleFetchType(query, associationName, fetchType);
-                }
-            }
-        }
-
-        if (argMap.containsKey(ARGUMENT_CACHE)) {
-            query.cache(ClassUtils.getBooleanFromMap(ARGUMENT_CACHE, argMap));
-        }
+        applyFetchAndCacheArguments(argMap,
+                (associationName, fetchType) -> handleFetchType(query, associationName, fetchType),
+                query::join,
+                query::cache);
 
         Object sortObject = argMap.get(ARGUMENT_SORT);
         PersistentEntity entity = resolvePersistentEntity(query);
@@ -466,22 +416,19 @@ public abstract class DynamicFinder extends AbstractFinder implements QueryBuild
         }
         boolean ignoreCase = !argMap.containsKey(ARGUMENT_IGNORE_CASE) || ClassUtils.getBooleanFromMap(ARGUMENT_IGNORE_CASE, argMap);
 
-        if (sortObject != null) {
-            if (sortObject instanceof CharSequence) {
-                final String sort = sortObject.toString();
+        if (sortObject instanceof CharSequence) {
+            final String sort = sortObject.toString();
+            validateSortProperty(entity, sort);
+            query.order(buildOrder(sort, orderParam, ignoreCase));
+        }
+        else if (sortObject instanceof Map sortMap) {
+            // each entry carries its own direction, as in applySortForMap for the Query overload
+            for (Object key : sortMap.keySet()) {
+                Object value = sortMap.get(key);
+                String sort = key.toString();
+                String direction = value != null ? value.toString() : ORDER_ASC;
                 validateSortProperty(entity, sort);
-                query.order(buildOrder(sort, orderParam, ignoreCase));
-            }
-            else if (sortObject instanceof Map) {
-                // each entry carries its own direction, as in applySortForMap for the Query overload
-                Map sortMap = (Map) sortObject;
-                for (Object key : sortMap.keySet()) {
-                    Object value = sortMap.get(key);
-                    String sort = key.toString();
-                    String direction = value != null ? value.toString() : ORDER_ASC;
-                    validateSortProperty(entity, sort);
-                    query.order(buildOrder(sort, direction, ignoreCase));
-                }
+                query.order(buildOrder(sort, direction, ignoreCase));
             }
         }
 
@@ -492,11 +439,12 @@ public abstract class DynamicFinder extends AbstractFinder implements QueryBuild
 
     /**
      * Populates arguments for the given query form the given map
+     * @param targetClass Unused - kept for call-site/API compatibility with existing callers in
+     * grails-data-hibernate5/7 and grails-data-mongodb
      * @param query The query
      * @param argMap The query arguments
      */
-    //TODO: Change {code}Class<? extends Object>{code} to {class<?>} once GROOVY-9460 is fixed.
-    public static void populateArgumentsForCriteria(Class<? extends Object> targetClass, Query query, Map argMap) {
+    public static void populateArgumentsForCriteria(@SuppressWarnings("unused") Class<?> targetClass, Query query, Map argMap) {
         if (argMap == null) {
             return;
         }
@@ -512,29 +460,10 @@ public abstract class DynamicFinder extends AbstractFinder implements QueryBuild
         }
         String orderParam = (String) argMap.get(ARGUMENT_ORDER);
 
-        Object fetchObj = argMap.get(ARGUMENT_FETCH);
-        if (fetchObj instanceof Map) {
-            Map fetch = (Map) fetchObj;
-            for (Object o : fetch.keySet()) {
-                String associationName = (String) o;
-                Object fetchValue = fetch.get(associationName);
-                if (fetchValue instanceof FetchType) {
-                    FetchType fetchType = (FetchType) fetchValue;
-                    handleFetchType(query, associationName, fetchType);
-                }
-                else if (fetchValue instanceof JoinType) {
-                    JoinType joinType = (JoinType) fetchValue;
-                    query.join(associationName, joinType);
-                } else {
-                    FetchType fetchType = getFetchMode(fetchValue);
-                    handleFetchType(query, associationName, fetchType);
-                }
-            }
-        }
-
-        if (argMap.containsKey(ARGUMENT_CACHE)) {
-            query.cache(ClassUtils.getBooleanFromMap(ARGUMENT_CACHE, argMap));
-        }
+        applyFetchAndCacheArguments(argMap,
+                (associationName, fetchType) -> handleFetchType(query, associationName, fetchType),
+                query::join,
+                query::cache);
         if (argMap.containsKey(ARGUMENT_LOCK)) {
             query.lock(ClassUtils.getBooleanFromMap(ARGUMENT_LOCK, argMap));
         }
@@ -568,15 +497,12 @@ public abstract class DynamicFinder extends AbstractFinder implements QueryBuild
         }
         boolean ignoreCase = !argMap.containsKey(ARGUMENT_IGNORE_CASE) || ClassUtils.getBooleanFromMap(ARGUMENT_IGNORE_CASE, argMap);
 
-        if (sortObject != null) {
-            if (sortObject instanceof CharSequence) {
-                final String sort = sortObject.toString();
-                addSimpleSort(query, sort, orderParam, ignoreCase);
-            }
-            else if (sortObject instanceof Map) {
-                Map sortMap = (Map) sortObject;
-                applySortForMap(query, sortMap, ignoreCase);
-            }
+        if (sortObject instanceof CharSequence) {
+            final String sort = sortObject.toString();
+            addSimpleSort(query, sort, orderParam, ignoreCase);
+        }
+        else if (sortObject instanceof Map sortMap) {
+            applySortForMap(query, sortMap, ignoreCase);
         }
 
         if (query instanceof QueryArgumentsAware) {
@@ -584,6 +510,41 @@ public abstract class DynamicFinder extends AbstractFinder implements QueryBuild
         }
     }
 
+    /**
+     * Shared by both {@code populateArgumentsForCriteria} overloads: applies the {@code fetch}/
+     * {@code cache} argument-map entries via the caller-supplied handlers, since {@link Query} and
+     * {@link BuildableCriteria} don't share a common supertype exposing {@code join}/{@code select}/
+     * {@code cache}.
+     *
+     * @param argMap The query arguments
+     * @param fetchHandler Applies a resolved {@link FetchType} to an association
+     * @param joinHandler Applies an explicit {@link JoinType} to an association
+     * @param cacheSetter Applies the {@code cache} argument, when present
+     */
+    private static void applyFetchAndCacheArguments(Map argMap, BiConsumer<String, FetchType> fetchHandler,
+            BiConsumer<String, JoinType> joinHandler, Consumer<Boolean> cacheSetter) {
+        Object fetchObj = argMap.get(ARGUMENT_FETCH);
+        if (fetchObj instanceof Map fetch) {
+            for (Object o : fetch.keySet()) {
+                String associationName = (String) o;
+                Object fetchValue = fetch.get(associationName);
+                if (fetchValue instanceof FetchType fetchType) {
+                    fetchHandler.accept(associationName, fetchType);
+                }
+                else if (fetchValue instanceof JoinType joinType) {
+                    joinHandler.accept(associationName, joinType);
+                }
+                else {
+                    fetchHandler.accept(associationName, getFetchMode(fetchValue));
+                }
+            }
+        }
+
+        if (argMap.containsKey(ARGUMENT_CACHE)) {
+            cacheSetter.accept(ClassUtils.getBooleanFromMap(ARGUMENT_CACHE, argMap));
+        }
+    }
+
     /**
      * Applies sorting logic to the given query from the given map
      * @param query The query
@@ -611,9 +572,6 @@ public abstract class DynamicFinder extends AbstractFinder implements QueryBuild
         if (name.equalsIgnoreCase(FetchType.EAGER.toString()) || name.equalsIgnoreCase("join")) {
             return FetchType.EAGER;
         }
-        if (name.equalsIgnoreCase(FetchType.LAZY.toString()) || name.equalsIgnoreCase("select")) {
-            return FetchType.LAZY;
-        }
         return FetchType.LAZY;
     }
 
@@ -657,7 +615,21 @@ public abstract class DynamicFinder extends AbstractFinder implements QueryBuild
         }
     }
 
-    protected abstract Object doInvokeInternal(DynamicFinderInvocation invocation);
+    /**
+     * Applies the given additional-criteria closure to the given query by building it through a
+     * {@link CriteriaBuilder} for the query's entity/session.
+     *
+     * @param query The query
+     * @param additionalCriteria The additional criteria closure, or null for a no-op
+     */
+    public static void applyAdditionalCriteria(Query query, Closure additionalCriteria) {
+        if (additionalCriteria == null) {
+            return;
+        }
+
+        CriteriaBuilder builder = new CriteriaBuilder(query.getEntity().getJavaClass(), query.getSession(), query);
+        builder.build(additionalCriteria);
+    }
 
     private static void handleFetchType(Query q, String associationName, FetchType fetchType) {
         switch (fetchType) {
@@ -752,7 +724,7 @@ public abstract class DynamicFinder extends AbstractFinder implements QueryBuild
     }
 
     private static void resetMethodExpressionPattern() {
-        String expressionPattern = DefaultGroovyMethods.join((Iterable) methodExpressions.keySet(), "|");
+        String expressionPattern = DefaultGroovyMethods.join(methodExpressions.keySet(), "|");
         methodExpressinPattern = Pattern.compile("\\p{Upper}[\\p{Lower}\\d]+(" + expressionPattern + ")");
     }
 
@@ -881,14 +853,13 @@ public abstract class DynamicFinder extends AbstractFinder implements QueryBuild
         }
     }
 
-    protected void configureQueryWithArguments(Class clazz, Query query, Object[] arguments) {
-        if (arguments.length == 0 || !(arguments[0] instanceof Map)) {
-            populateArgumentsForCriteria(clazz, query, Collections.emptyMap());
+    public static void configureQueryWithArguments(Class clazz, Query query, Object[] arguments) {
+        if (arguments.length > 0 && arguments[0] instanceof Map<?, ?> argMap) {
+            populateArgumentsForCriteria(clazz, query, argMap);
             return;
         }
 
-        Map<?, ?> argMap = (Map<?, ?>) arguments[0];
-        populateArgumentsForCriteria(clazz, query, argMap);
+        populateArgumentsForCriteria(clazz, query, Collections.emptyMap());
     }
 
     private static String calcPropertyName(String queryParameter, String clause) {
@@ -905,34 +876,32 @@ public abstract class DynamicFinder extends AbstractFinder implements QueryBuild
     }
 
     /**
-     * Initializes the arguments of the specified expression with the specified arguments.  If the
-     * expression is an Equal expression and the argument is null then a new expression is created
-     * and returned of type IsNull.
+     * Initializes the arguments of the specified expression with the specified arguments.
      *
      * @param expression expression to initialize
      * @param arguments arguments to the expression
-     * @return the initialized expression
      */
-    private MethodExpression getInitializedExpression(MethodExpression expression, Object[] arguments) {
+    private void getInitializedExpression(MethodExpression expression, Object[] arguments) {
         // if (expression instanceof Equal && arguments.length == 1 && arguments[0] == null) { // logic moved directly to Equal.createCriterion
         //     expression = new IsNull(expression.propertyName);
         // } else {
         expression.setArguments(arguments);
         // }
-        return expression;
     }
 
+    @Override
     public boolean firstExpressionIsRequiredBoolean() {
-        return false;
+        return firstExpressionIsRequiredBoolean;
     }
 
-    protected Query.Junction getJunction(DynamicFinderInvocation invocation) {
+    @Override
+    public Query.Junction getJunction(DynamicFinderInvocation invocation) {
         var criteria = invocation.getExpressions().stream().map(MethodExpression::createCriterion).collect(Collectors.toList());
         Query.Junction junction;
-        if (FindAllByFinder.OPERATOR_OR.equals(invocation.getOperator())) {
+        if (OPERATOR_OR.equals(invocation.getOperator())) {
             if (firstExpressionIsRequiredBoolean()) {
                 junction = new Query.Conjunction();
-                junction.add(criteria.remove(0));
+                junction.add(criteria.removeFirst());
                 var disjunction = new Query.Disjunction();
                 criteria.forEach(disjunction::add);
                 junction.add(disjunction);
@@ -949,6 +918,16 @@ public abstract class DynamicFinder extends AbstractFinder implements QueryBuild
         return junction;
     }
 
+    /**
+     * Builds a query for the given invocation using this grammar's {@link #getJunction} and the
+     * shared additional-criteria/detached-criteria/argument-map handling. This is the default,
+     * {@code Session}-based query builder used by the synchronous single-result and list finders;
+     * {@link CountFinder} builds its own query independently of this method.
+     *
+     * @param invocation The invocation
+     * @param session The session
+     * @return The built query
+     */
     public Query buildQuery(DynamicFinderInvocation invocation, Session session) {
         final Class<?> clazz = invocation.getJavaClass();
         var query = session.createQuery(clazz);
diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/DynamicFinderInvocation.java b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/DynamicFinderInvocation.java
index f086faf718..daf4a34055 100644
--- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/DynamicFinderInvocation.java
+++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/DynamicFinderInvocation.java
@@ -33,12 +33,12 @@ import grails.gorm.DetachedCriteria;
 @SuppressWarnings("rawtypes")
 public class DynamicFinderInvocation {
 
-    private Class javaClass;
-    private String methodName;
-    private Object[] arguments;
-    private List<MethodExpression> expressions;
-    private Closure criteria;
-    private String operator;
+    private final Class javaClass;
+    private final String methodName;
+    private final Object[] arguments;
+    private final List<MethodExpression> expressions;
+    private final Closure criteria;
+    private final String operator;
     private DetachedCriteria detachedCriteria;
 
     public DynamicFinderInvocation(Class javaClass, String methodName, Object[] arguments,
diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/FindAllByBooleanFinder.java b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/FindAllByBooleanFinder.java
deleted file mode 100644
index 3bf787abd0..0000000000
--- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/FindAllByBooleanFinder.java
+++ /dev/null
@@ -1,56 +0,0 @@
-/*
- *  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.finders;
-
-import org.grails.datastore.gorm.DatastoreResolver;
-import org.grails.datastore.mapping.core.Datastore;
-import org.grails.datastore.mapping.model.MappingContext;
-
-/**
- * The "findAll<booleanProperty>By*" static persistent method. This method allows querying for
- * instances of initial boolean property and additional criteria on other properties.
- *
- * eg.
- * Book.findAllActiveByTitle("The Stand")
- *
- * @author Graeme Rocher
- */
-public class FindAllByBooleanFinder extends FindAllByFinder {
-    public static final String METHOD_PATTERN = "(findAll)((\\w+)(By)([A-Z]\\w*)|(\\w+))";
-
-    public FindAllByBooleanFinder(Datastore datastore) {
-        super(datastore);
-        setPattern(METHOD_PATTERN);
-    }
-
-    public FindAllByBooleanFinder(DatastoreResolver datastoreResolver, MappingContext mappingContext) {
-        super(datastoreResolver, mappingContext);
-        setPattern(METHOD_PATTERN);
-    }
-
-    public FindAllByBooleanFinder(MappingContext mappingContext) {
-        super(mappingContext);
-        setPattern(METHOD_PATTERN);
-    }
-
-    @Override
-    public boolean firstExpressionIsRequiredBoolean() {
-        return true;
-    }
-}
diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/FindAllByFinder.java b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/FindAllByFinder.java
deleted file mode 100644
index 8027851bc3..0000000000
--- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/FindAllByFinder.java
+++ /dev/null
@@ -1,71 +0,0 @@
-/*
- *  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.finders;
-
-import java.util.regex.Pattern;
-
-import org.grails.datastore.gorm.DatastoreResolver;
-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.model.MappingContext;
-import org.grails.datastore.mapping.query.Query;
-
-/**
- * Finder used to return multiple results. Eg. Book.findAllBy..(..)
- */
-public class FindAllByFinder extends DynamicFinder {
-
-    protected static final String OPERATOR_OR = "Or";
-    protected static final String OPERATOR_AND = "And";
-    private static final String METHOD_PATTERN = "(findAllBy)([A-Z]\\w*)";
-    protected static final String[] OPERATORS = { OPERATOR_AND, OPERATOR_OR };
-
-    public FindAllByFinder(final Datastore datastore) {
-        super(Pattern.compile(METHOD_PATTERN), OPERATORS, datastore);
-    }
-
-    public FindAllByFinder(DatastoreResolver datastoreResolver, MappingContext mappingContext) {
-        super(Pattern.compile(METHOD_PATTERN), OPERATORS, datastoreResolver, mappingContext);
-    }
-
-    public FindAllByFinder(MappingContext mappingContext) {
-        super(Pattern.compile(METHOD_PATTERN), OPERATORS, mappingContext);
-    }
-
-    @Override
-    protected Object doInvokeInternal(final DynamicFinderInvocation invocation) {
-        return execute(new SessionCallback<Object>() {
-            public Object doInSession(final Session session) {
-                Query query = buildQuery(invocation, session);
-                adjustQuery(query);
-                return invokeQuery(query);
-            }
-        });
-    }
-
-    protected Object invokeQuery(Query query) {
-        return query.list();
-    }
-
-    protected void adjustQuery(Query query) {
-        // do nothing
-    }
-
-}
diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/FindByBooleanFinder.java b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/FindByBooleanFinder.java
deleted file mode 100644
index c4e24a7178..0000000000
--- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/FindByBooleanFinder.java
+++ /dev/null
@@ -1,51 +0,0 @@
-/*
- *  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.finders;
-
-import org.grails.datastore.gorm.DatastoreResolver;
-import org.grails.datastore.mapping.core.Datastore;
-import org.grails.datastore.mapping.model.MappingContext;
-
-/**
- * @author Graeme Rocher
- * @since 1.0
- */
-public class FindByBooleanFinder extends FindByFinder {
-    public static final String METHOD_PATTERN = "(find)((\\w+)(By)([A-Z]\\w*)|(\\w+))";
-
-    public FindByBooleanFinder(Datastore datastore) {
-        super(datastore);
-        setPattern(METHOD_PATTERN);
-    }
-
-    public FindByBooleanFinder(DatastoreResolver datastoreResolver, MappingContext mappingContext) {
-        super(datastoreResolver, mappingContext);
-        setPattern(METHOD_PATTERN);
-    }
-
-    public FindByBooleanFinder(MappingContext mappingContext) {
-        super(mappingContext);
-        setPattern(METHOD_PATTERN);
-    }
-
-    @Override
-    public boolean firstExpressionIsRequiredBoolean() {
-        return true;
-    }
-}
diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/FindByFinder.java b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/FindByFinder.java
deleted file mode 100644
index 28f7815b39..0000000000
--- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/FindByFinder.java
+++ /dev/null
@@ -1,45 +0,0 @@
-/*
- *  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.finders;
-
-import java.util.regex.Pattern;
-
-import org.grails.datastore.gorm.DatastoreResolver;
-import org.grails.datastore.mapping.core.Datastore;
-import org.grails.datastore.mapping.model.MappingContext;
-
-/**
- * Finder used to return a single result
- */
-public class FindByFinder extends AbstractFindByFinder {
-
-    private static final String METHOD_PATTERN = "(findBy)([A-Z]\\w*)";
-
-    public FindByFinder(final Datastore datastore) {
-        super(Pattern.compile(METHOD_PATTERN), datastore);
-    }
-
-    public FindByFinder(DatastoreResolver datastoreResolver, MappingContext mappingContext) {
-        super(Pattern.compile(METHOD_PATTERN), OPERATORS, datastoreResolver, mappingContext);
-    }
-
-    public FindByFinder(MappingContext mappingContext) {
-        super(Pattern.compile(METHOD_PATTERN), mappingContext);
-    }
-}
diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/FindOrCreateByFinder.java b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/FindOrCreateByFinder.java
deleted file mode 100644
index 3fda6c9e9d..0000000000
--- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/FindOrCreateByFinder.java
+++ /dev/null
@@ -1,117 +0,0 @@
-/*
- *  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.finders;
-
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.regex.Pattern;
-
-import groovy.lang.GroovySystem;
-import groovy.lang.MetaClass;
-import groovy.lang.MissingMethodException;
-
-import org.springframework.core.convert.ConversionException;
-
-import org.grails.datastore.gorm.DatastoreResolver;
-import org.grails.datastore.mapping.core.Datastore;
-import org.grails.datastore.mapping.core.exceptions.ConfigurationException;
-import org.grails.datastore.mapping.model.MappingContext;
-
-/**
- * Finder used to return a single result
- */
-public class FindOrCreateByFinder extends AbstractFindByFinder {
-
-    public static final String METHOD_PATTERN = "(findOrCreateBy)([A-Z]\\w*)";
-
-    public FindOrCreateByFinder(final String methodPattern, final Datastore datastore) {
-        super(Pattern.compile(methodPattern), datastore);
-    }
-
-    public FindOrCreateByFinder(final String methodPattern, DatastoreResolver datastoreResolver, MappingContext mappingContext) {
-        super(Pattern.compile(methodPattern), OPERATORS, datastoreResolver, mappingContext);
-    }
-
-    public FindOrCreateByFinder(final Datastore datastore) {
-        this(METHOD_PATTERN, datastore);
-    }
-
-    public FindOrCreateByFinder(DatastoreResolver datastoreResolver, MappingContext mappingContext) {
-        this(METHOD_PATTERN, datastoreResolver, mappingContext);
-    }
-
-    public FindOrCreateByFinder(MappingContext mappingContext) {
-        super(Pattern.compile(METHOD_PATTERN), mappingContext);
-    }
-
-    public FindOrCreateByFinder(final String methodPattern, MappingContext mappingContext) {
-        super(Pattern.compile(methodPattern), mappingContext);
-    }
-
-    @Override
-    @SuppressWarnings({"rawtypes", "unchecked"})
-    protected Object doInvokeInternal(final DynamicFinderInvocation invocation) {
-
-        if (OPERATOR_OR.equals(invocation.getOperator())) {
-            throw new MissingMethodException(invocation.getMethodName(), invocation.getJavaClass(), invocation.getArguments());
-        }
-        validateInvocation(invocation);
-
-        Object result;
-        try {
-            result = super.doInvokeInternal(invocation);
-        } catch (ConversionException e) { // TODO this is not the right place to deal with this...
-            throw new MissingMethodException(invocation.getMethodName(), invocation.getJavaClass(), invocation.getArguments());
-        }
-        if (result == null) {
-            Map m = new HashMap();
-            List<MethodExpression> expressions = invocation.getExpressions();
-            for (MethodExpression me : expressions) {
-                if (!(me instanceof MethodExpression.Equal)) {
-                    throw new MissingMethodException(invocation.getMethodName(), invocation.getJavaClass(), invocation.getArguments());
-                }
-                String propertyName = me.propertyName;
-                Object[] arguments = me.getArguments();
-                m.put(propertyName, arguments[0]);
-            }
-            MetaClass metaClass = GroovySystem.getMetaClassRegistry().getMetaClass(invocation.getJavaClass());
-            result = metaClass.invokeConstructor(new Object[]{m});
-            if (shouldSaveOnCreate()) {
-                metaClass.invokeMethod(result, "save", null);
-            }
-        }
-        return result;
-    }
-
-    protected void validateInvocation(DynamicFinderInvocation invocation) {
-        for (MethodExpression methodExpression : invocation.getExpressions()) {
-            if (methodExpression instanceof MethodExpression.GreaterThan ||
-                    methodExpression instanceof MethodExpression.LessThan ||
-                    methodExpression instanceof MethodExpression.GreaterThanEquals ||
-                    methodExpression instanceof MethodExpression.LessThanEquals) {
-                throw new ConfigurationException("Only equality-based expressions are supported for " + invocation.getMethodName());
-            }
-        }
-    }
-
-    protected boolean shouldSaveOnCreate() {
-        return false;
-    }
-}
diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/FindOrSaveByFinder.java b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/FindOrSaveByFinder.java
deleted file mode 100644
index d31ce7140f..0000000000
--- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/FindOrSaveByFinder.java
+++ /dev/null
@@ -1,53 +0,0 @@
-/*
- *  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.finders;
-
-import org.grails.datastore.gorm.DatastoreResolver;
-import org.grails.datastore.mapping.core.Datastore;
-import org.grails.datastore.mapping.model.MappingContext;
-
-public class FindOrSaveByFinder extends FindOrCreateByFinder {
-
-    public static final String METHOD_PATTERN = "(findOrSaveBy)([A-Z]\\w*)";
-
-    public FindOrSaveByFinder(final String methodPattern, final Datastore datastore) {
-        super(methodPattern, datastore);
-    }
-
-    public FindOrSaveByFinder(final String methodPattern, DatastoreResolver datastoreResolver, MappingContext mappingContext) {
-        super(methodPattern, datastoreResolver, mappingContext);
-    }
-
-    public FindOrSaveByFinder(Datastore datastore) {
-        super(METHOD_PATTERN, datastore);
-    }
-
-    public FindOrSaveByFinder(DatastoreResolver datastoreResolver, MappingContext mappingContext) {
-        super(METHOD_PATTERN, datastoreResolver, mappingContext);
-    }
-
-    public FindOrSaveByFinder(MappingContext mappingContext) {
-        super(METHOD_PATTERN, mappingContext);
-    }
-
-    @Override
-    protected boolean shouldSaveOnCreate() {
-        return true;
-    }
-}
diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/FinderGrammar.java b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/FinderGrammar.java
new file mode 100644
index 0000000000..ccec871bdc
--- /dev/null
+++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/FinderGrammar.java
@@ -0,0 +1,65 @@
+/*
+ *  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.finders;
+
+import groovy.lang.Closure;
+
+import org.grails.datastore.mapping.query.Query;
+
+/**
+ * The composition seam finder implementations use to parse a dynamic finder method name into a
+ * {@link DynamicFinderInvocation} and build the resulting {@link Query.Junction}, without
+ * subclassing {@link DynamicFinder}. Implemented by {@link DynamicFinder} itself; finder
+ * implementations (in this package and in {@code grails-datamapping-rx}) hold a {@code DynamicFinder}
+ * instance as a composed collaborator rather than extending it.
+ */
+@SuppressWarnings("rawtypes")
+public interface FinderGrammar {
+
+    /**
+     * @param methodName The method name
+     * @return True if the given method name matches this grammar's pattern
+     */
+    boolean isMethodMatch(String methodName);
+
+    /**
+     * Parses a dynamic finder method invocation into its constituent {@link MethodExpression}s.
+     *
+     * @param clazz The persistent class the finder targets
+     * @param methodName The full method name
+     * @param additionalCriteria An optional additional-criteria closure
+     * @param arguments The method call arguments
+     * @return The parsed invocation
+     */
+    DynamicFinderInvocation createFinderInvocation(Class clazz, String methodName, Closure additionalCriteria, Object[] arguments);
+
+    /**
+     * Builds the AND/OR junction of criteria for the given invocation.
+     *
+     * @param invocation The invocation
+     * @return The junction
+     */
+    Query.Junction getJunction(DynamicFinderInvocation invocation);
+
+    /**
+     * @return Whether the first parsed expression is a required boolean clause (the
+     * "find&lt;booleanProperty&gt;By*"/"findAll&lt;booleanProperty&gt;By*" finder forms)
+     */
+    boolean firstExpressionIsRequiredBoolean();
+}
diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/FinderSupport.java b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/FinderSupport.java
new file mode 100644
index 0000000000..dbbaa1989e
--- /dev/null
+++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/FinderSupport.java
@@ -0,0 +1,82 @@
+/*
+ *  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.finders;
+
+import org.grails.datastore.gorm.DatastoreResolver;
+import org.grails.datastore.mapping.core.Datastore;
+import org.grails.datastore.mapping.core.DatastoreUtils;
+import org.grails.datastore.mapping.core.SessionCallback;
+import org.grails.datastore.mapping.core.VoidSessionCallback;
+
+/**
+ * Shared session-execution helper for the synchronous finder implementations in this package.
+ * Not a base class - each finder holds a {@link DatastoreResolver} field and calls these
+ * statically, rather than inheriting an {@code execute} method. The datastore is resolved on
+ * every call, so a finder built from a lazy resolver follows whatever datastore the resolver
+ * returns at invocation time.
+ */
+public final class FinderSupport {
+
+    private static final String NULL_DATASTORE_MESSAGE = "Cannot execute session query with null datastore";
+
+    private FinderSupport() {
+    }
+
+    /**
+     * Wraps a fixed datastore as a resolver.
+     *
+     * @param datastore The datastore, or null for stateless mode
+     * @return A resolver that always returns the given datastore, or null when the datastore is null
+     */
+    public static DatastoreResolver resolverFor(final Datastore datastore) {
+        return datastore == null ? null : () -> datastore;
+    }
+
+    /**
+     * Executes the given callback within a session bound to the datastore the resolver returns.
+     *
+     * @param datastoreResolver The datastore resolver, or null for stateless mode
+     * @param callback The callback
+     * @param <T> The callback's result type
+     * @return The callback's result
+     * @throws IllegalStateException if no datastore can be resolved (stateless mode)
+     */
+    public static <T> T execute(final DatastoreResolver datastoreResolver, final SessionCallback<T> callback) {
+        return DatastoreUtils.execute(resolve(datastoreResolver), callback);
+    }
+
+    /**
+     * Executes the given void callback within a session bound to the datastore the resolver returns.
+     *
+     * @param datastoreResolver The datastore resolver, or null for stateless mode
+     * @param callback The callback
+     * @throws IllegalStateException if no datastore can be resolved (stateless mode)
+     */
+    public static void execute(final DatastoreResolver datastoreResolver, final VoidSessionCallback callback) {
+        DatastoreUtils.execute(resolve(datastoreResolver), callback);
+    }
+
+    private static Datastore resolve(final DatastoreResolver datastoreResolver) {
+        Datastore datastore = datastoreResolver == null ? null : datastoreResolver.resolve();
+        if (datastore == null) {
+            throw new IllegalStateException(NULL_DATASTORE_MESSAGE);
+        }
+        return datastore;
+    }
+}
diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/ListOrderByFinder.java b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/ListOrderByFinder.java
index 47d937953b..53fb0301f0 100644
--- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/ListOrderByFinder.java
+++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/ListOrderByFinder.java
@@ -27,7 +27,6 @@ import groovy.lang.Closure;
 
 import org.grails.datastore.gorm.DatastoreResolver;
 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.model.MappingContext;
 import org.grails.datastore.mapping.query.Query;
@@ -41,74 +40,86 @@ import org.grails.datastore.mapping.reflect.NameUtils;
  * Book.listOrderByTitle(max:10)
  * Book.listOrderByTitleAndAuthor(max:10)
  *
+ * <p>Never shared {@link DynamicFinder}'s grammar (no operator-suffix parsing - just trailing
+ * property names joined by {@code And}), so it stays its own standalone implementation, composing
+ * nothing beyond {@link FinderSupport} for session execution.
+ *
  * @author Graeme Rocher
  */
-public class ListOrderByFinder extends AbstractFinder {
+public class ListOrderByFinder implements FinderMethod {
+
     private static final Pattern METHOD_PATTERN = Pattern.compile("(listOrderBy)(\\w+)");
+    private static final String PROPERTY_SEPARATOR = "And";
+    private final DatastoreResolver datastoreResolver;
     private Pattern pattern = METHOD_PATTERN;
 
-    public ListOrderByFinder(final Datastore datastore) {
-        super(datastore);
+    public ListOrderByFinder(Datastore datastore) {
+        this(FinderSupport.resolverFor(datastore), null);
     }
 
-    public ListOrderByFinder(DatastoreResolver datastoreResolver, MappingContext mappingContext) {
-        super(datastoreResolver);
+    /**
+     * @param datastoreResolver Resolves the datastore at invocation time
+     * @param mappingContext Unused - kept so this finder is registered the same way as the
+     * grammar-based finders, which need the mapping context to convert arguments
+     */
+    public ListOrderByFinder(DatastoreResolver datastoreResolver, @SuppressWarnings("unused") MappingContext mappingContext) {
+        this.datastoreResolver = datastoreResolver;
     }
 
+    @Override
     public void setPattern(String pattern) {
         this.pattern = Pattern.compile(pattern);
     }
 
-    public boolean isMethodMatch(String methodName) {
-        return pattern.matcher(methodName).find();
-    }
-
     @Override
+    @SuppressWarnings("rawtypes")
     public Object invoke(final Class clazz, final String methodName, final Object[] arguments) {
         return invoke(clazz, methodName, null, arguments);
     }
 
     @Override
+    @SuppressWarnings({"rawtypes", "unchecked", "ResultOfMethodCallIgnored"})
     public Object invoke(final Class clazz, final String methodName, final Closure additionalCriteria, final Object[] arguments) {
-        return execute(new SessionCallback<Object>() {
-            @Override
-            public Object doInSession(final Session session) {
-                final Matcher matcher = pattern.matcher(methodName);
-                matcher.find();
-                String parts = matcher.group(2);
-
-                final Query q = session.createQuery(clazz);
-                String[] propertyNames = parts.split("And");
-
-                // Resolve the sort direction BEFORE applying any order. Applying asc first and then
-                // trying to clear/replace it leaves the eagerly-applied asc order in the underlying
-                // criteria, so an explicit order:'desc' argument was silently ignored. The direction
-                // goes through the same normalization as every other entry point, so a value other
-                // than asc or desc is rejected here too instead of quietly sorting ascending.
-                boolean ascending = true;
-                if (arguments.length > 0 && (arguments[0] instanceof Map)) {
-                    Map args = new LinkedHashMap((Map) arguments[0]);
-                    final Object order = args.remove(DynamicFinder.ARGUMENT_ORDER);
-                    final String direction = DynamicFinder.normalizeDirection(order != null ? order.toString() : null);
-                    ascending = !DynamicFinder.ORDER_DESC.equals(direction);
-                    DynamicFinder.populateArgumentsForCriteria(clazz, q, args);
-                }
-
-                for (String propertyName : propertyNames) {
-                    // Lower-case only the first character: a GORM property's name is the method-name
-                    // segment with its first letter de-capitalised. JavaBeans-style decapitalize()
-                    // leaves a name whose first two letters are upper-case unchanged (e.g. "ISize"),
-                    // which would not match a Hungarian-notation property such as "iSize".
-                    String property = NameUtils.decapitalizeFirstChar(propertyName);
-                    q.order(ascending ? Query.Order.asc(property) : Query.Order.desc(property));
-                }
-
-                if (additionalCriteria != null) {
-                    applyAdditionalCriteria(q, additionalCriteria);
-                }
-
-                return q.list();
+
+        Matcher match = pattern.matcher(methodName);
+        match.find();
+
+        final String[] propertyNames = match.group(2).split(PROPERTY_SEPARATOR);
+
+        return FinderSupport.execute(datastoreResolver, (SessionCallback<Object>) session -> {
+            Query q = session.createQuery(clazz);
+
+            // Resolve the sort direction BEFORE applying any order. Applying asc first and then
+            // trying to clear/replace it leaves the eagerly-applied asc order in the underlying
+            // criteria, so an explicit order:'desc' argument was silently ignored. The direction
+            // goes through the same normalization as every other entry point, so a value other
+            // than asc or desc is rejected here too instead of quietly sorting ascending.
+            boolean ascending = true;
+            if (arguments.length > 0 && (arguments[0] instanceof Map)) {
+                final Map args = new LinkedHashMap((Map) arguments[0]);
+                final Object order = args.remove(DynamicFinder.ARGUMENT_ORDER);
+                final String direction = DynamicFinder.normalizeDirection(order != null ? order.toString() : null);
+                ascending = !DynamicFinder.ORDER_DESC.equals(direction);
+                DynamicFinder.populateArgumentsForCriteria(clazz, q, args);
+            }
+
+            for (String propertyName : propertyNames) {
+                // Lower-case only the first character: a GORM property's name is the method-name
+                // segment with its first letter de-capitalised. JavaBeans-style decapitalize()
+                // leaves a name whose first two letters are upper-case unchanged (e.g. "ISize"),
+                // which would not match a Hungarian-notation property such as "iSize".
+                String property = NameUtils.decapitalizeFirstChar(propertyName);
+                q.order(ascending ? Query.Order.asc(property) : Query.Order.desc(property));
             }
+
+            DynamicFinder.applyAdditionalCriteria(q, additionalCriteria);
+
+            return q.list();
         });
     }
+
+    @Override
+    public boolean isMethodMatch(String methodName) {
+        return pattern.matcher(methodName).find();
+    }
 }
diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/ListResultFinder.java b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/ListResultFinder.java
new file mode 100644
index 0000000000..3b045f3491
--- /dev/null
+++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/ListResultFinder.java
@@ -0,0 +1,134 @@
+/*
+ *  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.finders;
+
+import java.util.regex.Pattern;
+
+import groovy.lang.Closure;
+
+import grails.gorm.DetachedCriteria;
+import org.grails.datastore.gorm.DatastoreResolver;
+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.model.MappingContext;
+import org.grails.datastore.mapping.query.Query;
+
+/**
+ * Implements every dynamic finder that returns a list of results: {@code findAllBy*} and the
+ * {@code findAll<booleanProperty>By*} boolean-clause form - configured via static factory methods
+ * rather than subclassed, composing a {@link DynamicFinder} grammar instead of extending it.
+ */
+public class ListResultFinder implements FinderMethod, QueryBuildingFinder {
+
+    private static final String FIND_ALL_BY_PATTERN = "(findAllBy)([A-Z]\\w*)";
+    private static final String FIND_ALL_BY_BOOLEAN_PATTERN = "(findAll)((\\w+)(By)([A-Z]\\w*)|(\\w+))";
+    private static final String[] OPERATORS = {"And", "Or"};
+
+    private final DatastoreResolver datastoreResolver;
+    private final DynamicFinder grammar;
+
+    private ListResultFinder(DatastoreResolver datastoreResolver, DynamicFinder grammar) {
+        this.datastoreResolver = datastoreResolver;
+        this.grammar = grammar;
+    }
+
+    public static ListResultFinder findAllBy(Datastore datastore) {
+        return findAllBy(FinderSupport.resolverFor(datastore), datastore.getMappingContext());
+    }
+
+    public static ListResultFinder findAllBy(DatastoreResolver datastoreResolver, MappingContext mappingContext) {
+        return new ListResultFinder(datastoreResolver, grammar(FIND_ALL_BY_PATTERN, mappingContext, false));
+    }
+
+    public static ListResultFinder findAllBy(MappingContext mappingContext) {
+        return findAllBy(null, mappingContext);
+    }
+
+    public static ListResultFinder findAllByBoolean(Datastore datastore) {
+        return findAllByBoolean(FinderSupport.resolverFor(datastore), datastore.getMappingContext());
+    }
+
+    public static ListResultFinder findAllByBoolean(DatastoreResolver datastoreResolver, MappingContext mappingContext) {
+        return new ListResultFinder(datastoreResolver, grammar(FIND_ALL_BY_BOOLEAN_PATTERN, mappingContext, true));
+    }
+
+    public static ListResultFinder findAllByBoolean(MappingContext mappingContext) {
+        return findAllByBoolean(null, mappingContext);
+    }
+
+    private static DynamicFinder grammar(String pattern, MappingContext mappingContext, boolean booleanClause) {
+        return new DynamicFinder(Pattern.compile(pattern), OPERATORS, mappingContext, booleanClause);
+    }
+
+    @Override
+    public void setPattern(String pattern) {
+        grammar.setPattern(pattern);
+    }
+
+    @Override
+    public boolean isMethodMatch(String methodName) {
+        return grammar.isMethodMatch(methodName);
+    }
+
+    @Override
+    @SuppressWarnings("rawtypes")
+    public Object invoke(Class clazz, String methodName, Object[] arguments) {
+        return invoke(clazz, methodName, (Closure) null, arguments);
+    }
+
+    @Override
+    @SuppressWarnings("rawtypes")
+    public Object invoke(Class clazz, String methodName, Closure additionalCriteria, Object[] arguments) {
+        DynamicFinderInvocation invocation = grammar.createFinderInvocation(clazz, methodName, additionalCriteria, arguments);
+        return doInvoke(invocation);
+    }
+
+    /**
+     * Not part of {@link FinderMethod} - called reflectively (dynamic Groovy dispatch) by {@link
+     * org.grails.datastore.gorm.query.criteria.AbstractDetachedCriteria#methodMissing}. See {@link
+     * SingleResultFinder#invoke(Class, String, DetachedCriteria, Object[])} for the full rationale.
+     *
+     * @param clazz The persistent class
+     * @param methodName The method name
+     * @param detachedCriteria The detached criteria to merge into the built query
+     * @param arguments The method call arguments
+     * @return The result of the method call
+     */
+    @SuppressWarnings("rawtypes")
+    public Object invoke(Class clazz, String methodName, DetachedCriteria detachedCriteria, Object[] arguments) {
+        DynamicFinderInvocation invocation = grammar.createFinderInvocation(clazz, methodName, null, arguments);
+        if (detachedCriteria != null) {
+            invocation.setDetachedCriteria(detachedCriteria);
+        }
+        return doInvoke(invocation);
+    }
+
+    private Object doInvoke(final DynamicFinderInvocation invocation) {
+        return FinderSupport.execute(datastoreResolver, (SessionCallback<Object>) session -> {
+            Query query = buildQuery(invocation, session);
+            return query.list();
+        });
+    }
+
+    @Override
+    public Query buildQuery(DynamicFinderInvocation invocation, Session session) {
+        return grammar.buildQuery(invocation, session);
+    }
+}
diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/MethodExpression.java b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/MethodExpression.java
index ea1c597f28..c580720aea 100644
--- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/MethodExpression.java
+++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/MethodExpression.java
@@ -38,6 +38,13 @@ import org.grails.datastore.mapping.query.Restrictions;
 
 /**
  *  Method expression used to evaluate a dynamic finder.
+ *
+ * <p>Every concrete subclass below provides both a {@code (Class, String)} and a {@code (String)}
+ * constructor. Neither shape is ever called as a literal {@code new Xxx(...)} expression for every
+ * subclass - {@link DynamicFinder}'s registry invokes the {@code (Class, String)} constructor
+ * reflectively via {@code Constructor.newInstance}, and {@code MethodExpressionSpec} exercises both
+ * shapes reflectively via {@code Class.getConstructor(...).newInstance(...)} - so static usage
+ * analysis can't see either call site even though both are genuinely exercised.
  */
 public abstract class MethodExpression {
 
@@ -52,7 +59,7 @@ public abstract class MethodExpression {
 
     public abstract Query.Criterion createCriterion();
 
-    protected MethodExpression(Class<?> targetClass, String propertyName) {
+    protected MethodExpression(@SuppressWarnings("unused") Class<?> targetClass, String propertyName) {
         this.propertyName = propertyName;
     }
 
@@ -67,13 +74,7 @@ public abstract class MethodExpression {
     public void convertArguments(PersistentEntity persistentEntity) {
         ConversionService conversionService = persistentEntity
                 .getMappingContext().getConversionService();
-        PersistentProperty<?> prop = persistentEntity
-                .getPropertyByName(propertyName);
-        if (prop == null) {
-            if (propertyName.equals(persistentEntity.getIdentity().getName())) {
-                prop = persistentEntity.getIdentity();
-            }
-        }
+        PersistentProperty<?> prop = resolveProperty(persistentEntity, propertyName);
         if (prop != null && arguments != null && argumentsRequired > 0) {
             Class<?> type = prop.getType();
             for (int i = 0; i < argumentsRequired; i++) {
@@ -188,10 +189,12 @@ public abstract class MethodExpression {
     }
 
     public static class Ilike extends MethodExpression {
+        @SuppressWarnings("unused")
         public Ilike(Class<?> targetClass, String propertyName) {
             super(targetClass, propertyName);
         }
 
+        @SuppressWarnings("unused")
         public Ilike(String propertyName) {
             super(propertyName);
         }
@@ -203,10 +206,12 @@ public abstract class MethodExpression {
     }
 
     public static class Rlike extends MethodExpression {
+        @SuppressWarnings("unused")
         public Rlike(Class<?> targetClass, String propertyName) {
             super(targetClass, propertyName);
         }
 
+        @SuppressWarnings("unused")
         public Rlike(String propertyName) {
             super(propertyName);
         }
@@ -218,6 +223,7 @@ public abstract class MethodExpression {
     }
 
     public static class NotInList extends MethodExpression {
+        @SuppressWarnings("unused")
         public NotInList(Class<?> targetClass, String propertyName) {
             super(targetClass, propertyName);
         }
@@ -248,16 +254,14 @@ public abstract class MethodExpression {
         public void convertArguments(PersistentEntity persistentEntity) {
             ConversionService conversionService = persistentEntity
                     .getMappingContext().getConversionService();
-            String propertyName = this.propertyName;
-            PersistentProperty<?> prop = persistentEntity
-                    .getPropertyByName(propertyName);
-            Object[] arguments = this.arguments;
-            convertArgumentsForProp(persistentEntity, prop, propertyName, arguments, conversionService);
+            PersistentProperty<?> prop = resolveProperty(persistentEntity, propertyName);
+            convertArgumentsForProp(prop, arguments, conversionService);
         }
     }
 
     public static class InList extends MethodExpression {
 
+        @SuppressWarnings("unused")
         public InList(Class<?> targetClass, String propertyName) {
             super(targetClass, propertyName);
         }
@@ -286,9 +290,8 @@ public abstract class MethodExpression {
         public void convertArguments(PersistentEntity persistentEntity) {
             ConversionService conversionService = persistentEntity
                     .getMappingContext().getConversionService();
-            PersistentProperty<?> prop = persistentEntity
-                    .getPropertyByName(propertyName);
-            convertArgumentsForProp(persistentEntity, prop, propertyName, arguments, conversionService);
+            PersistentProperty<?> prop = resolveProperty(persistentEntity, propertyName);
+            convertArgumentsForProp(prop, arguments, conversionService);
         }
 
     }
@@ -323,6 +326,7 @@ public abstract class MethodExpression {
 
     public static class InRange extends MethodExpression {
 
+        @SuppressWarnings("unused")
         public InRange(Class<?> targetClass, String propertyName) {
             super(targetClass, propertyName);
             argumentsRequired = 1;
@@ -357,11 +361,13 @@ public abstract class MethodExpression {
 
     public static class IsNull extends MethodExpression {
 
+        @SuppressWarnings("unused")
         public IsNull(Class<?> targetClass, String propertyName) {
             super(targetClass, propertyName);
             argumentsRequired = 0;
         }
 
+        @SuppressWarnings("unused")
         public IsNull(String propertyName) {
             super(propertyName);
             argumentsRequired = 0;
@@ -376,11 +382,13 @@ public abstract class MethodExpression {
 
     public static class IsNotNull extends MethodExpression {
 
+        @SuppressWarnings("unused")
         public IsNotNull(Class<?> targetClass, String propertyName) {
             super(targetClass, propertyName);
             argumentsRequired = 0;
         }
 
+        @SuppressWarnings("unused")
         public IsNotNull(String propertyName) {
             super(propertyName);
             argumentsRequired = 0;
@@ -395,11 +403,13 @@ public abstract class MethodExpression {
 
     public static class IsEmpty extends MethodExpression {
 
+        @SuppressWarnings("unused")
         public IsEmpty(Class<?> targetClass, String propertyName) {
             super(targetClass, propertyName);
             argumentsRequired = 0;
         }
 
+        @SuppressWarnings("unused")
         public IsEmpty(String propertyName) {
             super(propertyName);
             argumentsRequired = 0;
@@ -414,11 +424,13 @@ public abstract class MethodExpression {
 
     public static class IsNotEmpty extends MethodExpression {
 
+        @SuppressWarnings("unused")
         public IsNotEmpty(Class<?> targetClass, String propertyName) {
             super(targetClass, propertyName);
             argumentsRequired = 0;
         }
 
+        @SuppressWarnings("unused")
         public IsNotEmpty(String propertyName) {
             super(propertyName);
             argumentsRequired = 0;
@@ -455,6 +467,7 @@ public abstract class MethodExpression {
 
     public static class NotEqual extends MethodExpression {
 
+        @SuppressWarnings("unused")
         public NotEqual(Class<?> targetClass, String propertyName) {
             super(targetClass, propertyName);
         }
@@ -475,12 +488,21 @@ public abstract class MethodExpression {
 
     }
 
-    private static void convertArgumentsForProp(PersistentEntity persistentEntity, PersistentProperty<?> prop, String propertyName, Object[] arguments, ConversionService conversionService) {
-        if (prop == null) {
-            if (propertyName.equals(persistentEntity.getIdentity().getName())) {
-                prop = persistentEntity.getIdentity();
-            }
+    /**
+     * Resolves the given property name against the entity, falling back to the identity property
+     * when there is no regular property by that name (e.g. {@code findByIdInList}). Shared by the
+     * base {@link #convertArguments} and {@link #convertArgumentsForProp} so the fallback exists in
+     * exactly one place.
+     */
+    private static PersistentProperty<?> resolveProperty(PersistentEntity persistentEntity, String propertyName) {
+        PersistentProperty<?> prop = persistentEntity.getPropertyByName(propertyName);
+        if (prop == null && propertyName.equals(persistentEntity.getIdentity().getName())) {
+            return persistentEntity.getIdentity();
         }
+        return prop;
+    }
+
+    private static void convertArgumentsForProp(PersistentProperty<?> prop, Object[] arguments, ConversionService conversionService) {
         if (prop != null) {
             Class<?> type = prop.getType();
             Collection<?> collection = (Collection<?>) arguments[0];
diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/SingleResultFinder.java b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/SingleResultFinder.java
new file mode 100644
index 0000000000..11f414f31f
--- /dev/null
+++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/SingleResultFinder.java
@@ -0,0 +1,245 @@
+/*
+ *  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.finders;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Consumer;
+import java.util.function.Function;
+import java.util.regex.Pattern;
+
+import groovy.lang.Closure;
+import groovy.lang.GroovySystem;
+import groovy.lang.MetaClass;
+import groovy.lang.MissingMethodException;
+
+import org.springframework.core.convert.ConversionException;
+
+import grails.gorm.DetachedCriteria;
+import org.grails.datastore.gorm.DatastoreResolver;
+import org.grails.datastore.mapping.core.Datastore;
+import org.grails.datastore.mapping.core.Session;
+import org.grails.datastore.mapping.core.exceptions.ConfigurationException;
+import org.grails.datastore.mapping.model.MappingContext;
+import org.grails.datastore.mapping.query.Query;
+
+/**
+ * Implements every dynamic finder that returns a single result: {@code findBy*}, the
+ * {@code find<booleanProperty>By*} boolean-clause form, {@code findOrCreateBy*} and
+ * {@code findOrSaveBy*} - configured via static factory methods rather than subclassed, composing
+ * a {@link DynamicFinder} grammar instead of extending it.
+ */
+public class SingleResultFinder implements FinderMethod, QueryBuildingFinder {
+
+    private static final String FIND_BY_PATTERN = "(findBy)([A-Z]\\w*)";
+    private static final String FIND_BY_BOOLEAN_PATTERN = "(find)((\\w+)(By)([A-Z]\\w*)|(\\w++))";
+    private static final String FIND_OR_CREATE_BY_PATTERN = "(findOrCreateBy)([A-Z]\\w*)";
+    private static final String FIND_OR_SAVE_BY_PATTERN = "(findOrSaveBy)([A-Z]\\w*)";
+    private static final String[] OPERATORS = {"And", "Or"};
+    private static final String OPERATOR_OR = "Or";
+
+    private final DatastoreResolver datastoreResolver;
+    private final DynamicFinder grammar;
+    private final Consumer<DynamicFinderInvocation> validate;
+    private final Function<DynamicFinderInvocation, Object> onNullResult;
+
+    private SingleResultFinder(DatastoreResolver datastoreResolver, DynamicFinder grammar,
+            Consumer<DynamicFinderInvocation> validate, Function<DynamicFinderInvocation, Object> onNullResult) {
+        this.datastoreResolver = datastoreResolver;
+        this.grammar = grammar;
+        this.validate = validate;
+        this.onNullResult = onNullResult;
+    }
+
+    public static SingleResultFinder findBy(Datastore datastore) {
+        return findBy(FinderSupport.resolverFor(datastore), datastore.getMappingContext());
+    }
+
+    public static SingleResultFinder findBy(DatastoreResolver datastoreResolver, MappingContext mappingContext) {
+        return new SingleResultFinder(datastoreResolver, grammar(FIND_BY_PATTERN, mappingContext, false), null, null);
+    }
+
+    public static SingleResultFinder findBy(MappingContext mappingContext) {
+        return findBy(null, mappingContext);
+    }
+
+    public static SingleResultFinder findByBoolean(Datastore datastore) {
+        return findByBoolean(FinderSupport.resolverFor(datastore), datastore.getMappingContext());
+    }
+
+    public static SingleResultFinder findByBoolean(DatastoreResolver datastoreResolver, MappingContext mappingContext) {
+        return new SingleResultFinder(datastoreResolver, grammar(FIND_BY_BOOLEAN_PATTERN, mappingContext, true), null, null);
+    }
+
+    public static SingleResultFinder findByBoolean(MappingContext mappingContext) {
+        return findByBoolean(null, mappingContext);
+    }
+
+    public static SingleResultFinder findOrCreateBy(Datastore datastore) {
+        return findOrCreateBy(FinderSupport.resolverFor(datastore), datastore.getMappingContext());
+    }
+
+    public static SingleResultFinder findOrCreateBy(DatastoreResolver datastoreResolver, MappingContext mappingContext) {
+        return findOrCreateOrSave(datastoreResolver, mappingContext, false);
+    }
+
+    public static SingleResultFinder findOrCreateBy(MappingContext mappingContext) {
+        return findOrCreateBy(null, mappingContext);
+    }
+
+    public static SingleResultFinder findOrSaveBy(Datastore datastore) {
+        return findOrSaveBy(FinderSupport.resolverFor(datastore), datastore.getMappingContext());
+    }
+
+    public static SingleResultFinder findOrSaveBy(DatastoreResolver datastoreResolver, MappingContext mappingContext) {
+        return findOrCreateOrSave(datastoreResolver, mappingContext, true);
+    }
+
+    public static SingleResultFinder findOrSaveBy(MappingContext mappingContext) {
+        return findOrSaveBy(null, mappingContext);
+    }
+
+    private static SingleResultFinder findOrCreateOrSave(DatastoreResolver datastoreResolver, MappingContext mappingContext, boolean save) {
+        String pattern = save ? FIND_OR_SAVE_BY_PATTERN : FIND_OR_CREATE_BY_PATTERN;
+        return new SingleResultFinder(datastoreResolver, grammar(pattern, mappingContext, false),
+                SingleResultFinder::rejectOrAndComparisonOperators,
+                invocation -> constructFromEqualExpressions(invocation, save));
+    }
+
+    private static DynamicFinder grammar(String pattern, MappingContext mappingContext, boolean booleanClause) {
+        return new DynamicFinder(Pattern.compile(pattern), OPERATORS, mappingContext, booleanClause);
+    }
+
+    /**
+     * Shared by {@code findOrCreateBy}/{@code findOrSaveBy}: rejects the {@code Or} operator and
+     * any comparison-based expression (only equality-based finds can be created/saved).
+     */
+    private static void rejectOrAndComparisonOperators(DynamicFinderInvocation invocation) {
+        if (OPERATOR_OR.equals(invocation.getOperator())) {
+            throw new MissingMethodException(invocation.getMethodName(), invocation.getJavaClass(), invocation.getArguments());
+        }
+        for (MethodExpression methodExpression : invocation.getExpressions()) {
+            if (methodExpression instanceof MethodExpression.GreaterThan ||
+                    methodExpression instanceof MethodExpression.LessThan ||
+                    methodExpression instanceof MethodExpression.GreaterThanEquals ||
+                    methodExpression instanceof MethodExpression.LessThanEquals) {
+                throw new ConfigurationException("Only equality-based expressions are supported for " + invocation.getMethodName());
+            }
+        }
+    }
+
+    /**
+     * Shared by {@code findOrCreateBy}/{@code findOrSaveBy}: constructs a new instance from the
+     * invocation's {@code Equal}-typed expressions when the underlying query returns null, saving
+     * it too when {@code save} is true. The single implementation this helper provides is what
+     * prevents the two finder kinds from ever diverging into duplicate, driftable copies.
+     */
+    @SuppressWarnings({"rawtypes", "unchecked"})
+    private static Object constructFromEqualExpressions(DynamicFinderInvocation invocation, boolean save) {
+        Map m = new HashMap();
+        List<MethodExpression> expressions = invocation.getExpressions();
+        for (MethodExpression me : expressions) {
+            if (!(me instanceof MethodExpression.Equal)) {
+                throw new MissingMethodException(invocation.getMethodName(), invocation.getJavaClass(), invocation.getArguments());
+            }
+            String propertyName = me.propertyName;
+            Object[] arguments = me.getArguments();
+            m.put(propertyName, arguments[0]);
+        }
+        MetaClass metaClass = GroovySystem.getMetaClassRegistry().getMetaClass(invocation.getJavaClass());
+        Object result = metaClass.invokeConstructor(new Object[]{m});
+        if (save) {
+            metaClass.invokeMethod(result, "save", null);
+        }
+        return result;
+    }
+
+    @Override
+    public void setPattern(String pattern) {
+        grammar.setPattern(pattern);
+    }
+
+    @Override
+    public boolean isMethodMatch(String methodName) {
+        return grammar.isMethodMatch(methodName);
+    }
+
+    @Override
+    @SuppressWarnings("rawtypes")
+    public Object invoke(Class clazz, String methodName, Object[] arguments) {
+        return invoke(clazz, methodName, (Closure) null, arguments);
+    }
+
+    @Override
+    @SuppressWarnings("rawtypes")
+    public Object invoke(Class clazz, String methodName, Closure additionalCriteria, Object[] arguments) {
+        DynamicFinderInvocation invocation = grammar.createFinderInvocation(clazz, methodName, additionalCriteria, arguments);
+        return doInvoke(invocation);
+    }
+
+    /**
+     * Not part of {@link FinderMethod} - called reflectively (dynamic Groovy dispatch) by {@link
+     * org.grails.datastore.gorm.query.criteria.AbstractDetachedCriteria#methodMissing}, which
+     * invokes a matched dynamic finder with the detached criteria instance itself as the "extra"
+     * argument so its accumulated criteria/projections/orders get merged into the built query.
+     *
+     * @param clazz The persistent class
+     * @param methodName The method name
+     * @param detachedCriteria The detached criteria to merge into the built query
+     * @param arguments The method call arguments
+     * @return The result of the method call
+     */
+    @SuppressWarnings("rawtypes")
+    public Object invoke(Class clazz, String methodName, DetachedCriteria detachedCriteria, Object[] arguments) {
+        DynamicFinderInvocation invocation = grammar.createFinderInvocation(clazz, methodName, null, arguments);
+        if (detachedCriteria != null) {
+            invocation.setDetachedCriteria(detachedCriteria);
+        }
+        return doInvoke(invocation);
+    }
+
+    private Object doInvoke(final DynamicFinderInvocation invocation) {
+        if (validate != null) {
+            validate.accept(invocation);
+        }
+        return FinderSupport.execute(datastoreResolver, session -> {
+            Object result;
+            if (onNullResult != null) {
+                try {
+                    result = buildQuery(invocation, session).singleResult();
+                } catch (ConversionException e) {
+                    throw new MissingMethodException(invocation.getMethodName(), invocation.getJavaClass(), invocation.getArguments());
+                }
+                if (result == null) {
+                    result = onNullResult.apply(invocation);
+                }
+            }
+            else {
+                result = buildQuery(invocation, session).singleResult();
+            }
+            return result;
+        });
+    }
+
+    @Override
+    public Query buildQuery(DynamicFinderInvocation invocation, Session session) {
+        return grammar.buildQuery(invocation, session);
+    }
+}
diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/query/criteria/AbstractDetachedCriteria.groovy b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/query/criteria/AbstractDetachedCriteria.groovy
index f0d1115dfa..afafc12ef4 100644
--- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/query/criteria/AbstractDetachedCriteria.groovy
+++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/query/criteria/AbstractDetachedCriteria.groovy
@@ -26,7 +26,6 @@ import groovy.transform.TypeCheckingMode
 import jakarta.persistence.FetchType
 import jakarta.persistence.criteria.JoinType
 
-import org.grails.datastore.gorm.finders.DynamicFinder
 import org.grails.datastore.gorm.finders.FinderMethod
 import org.grails.datastore.mapping.core.connections.ConnectionSource
 import org.grails.datastore.mapping.model.PersistentEntity
@@ -52,7 +51,7 @@ abstract class AbstractDetachedCriteria<T> implements Criteria, Cloneable {
     protected List<Query.Order> orders = []
     protected List<Query.Projection> projections = []
     protected Class targetClass
-    protected List<DynamicFinder> dynamicFinders
+    protected List<FinderMethod> dynamicFinders
     protected Integer defaultOffset
     protected Integer defaultMax
 
diff --git a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/CountFinderSpec.groovy b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/CountFinderSpec.groovy
new file mode 100644
index 0000000000..adcb565cd5
--- /dev/null
+++ b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/CountFinderSpec.groovy
@@ -0,0 +1,261 @@
+/*
+ *  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.finders
+
+import org.grails.datastore.gorm.DatastoreResolver
+import org.grails.datastore.mapping.core.Datastore
+import org.grails.datastore.mapping.core.Session
+import org.grails.datastore.mapping.model.MappingContext
+import org.grails.datastore.mapping.model.PersistentEntity
+import org.grails.datastore.mapping.model.PersistentProperty
+import org.grails.datastore.mapping.query.Query
+import org.springframework.core.convert.support.DefaultConversionService
+import spock.lang.Specification
+import spock.lang.Unroll
+
+/**
+ * Exercises {@link CountFinder}'s own independent {@code buildQuery} - unlike every other finder
+ * in this package, it does NOT reuse {@code DynamicFinder.getJunction()} at all: for the And
+ * operator (or no operator) each expression's criterion is added directly to the query, while for
+ * Or each criterion is added to an explicit {@code disjunction()} via the two-arg
+ * {@code Query.add(Junction, Criterion)} overload - and a {@code count()} projection is always
+ * applied regardless of operator.
+ */
+class CountFinderSpec extends Specification {
+
+    PersistentProperty nameProperty = Stub(PersistentProperty) {
+        getName() >> 'name'
+        getType() >> String
+    }
+    MappingContext mappingContext = Stub(MappingContext)
+    PersistentEntity persistentEntity = Stub(PersistentEntity)
+    Datastore datastore = Stub(Datastore) {
+        getMappingContext() >> mappingContext
+    }
+    CountFinder countFinder = CountFinder.countBy(datastore)
+
+    // See DynamicFinderSpec's setup() for why this circular wiring can't be done via inline field
+    // initializers.
+    void setup() {
+        mappingContext.getConversionService() >> new DefaultConversionService()
+        mappingContext.getPersistentEntity(FinderTestEntity.name) >> persistentEntity
+        persistentEntity.getMappingContext() >> mappingContext
+        persistentEntity.getPropertyByName('name') >> nameProperty
+    }
+
+    private static DynamicFinderInvocation twoEqualsInvocation(String operator) {
+        MethodExpression.Equal nameExpression = new MethodExpression.Equal('name')
+        nameExpression.setArguments(['Bob'] as Object[])
+        MethodExpression.Equal ageExpression = new MethodExpression.Equal('age')
+        ageExpression.setArguments([42] as Object[])
+        new DynamicFinderInvocation(FinderTestEntity, 'countByNameAndAge', [] as Object[],
+                [nameExpression, ageExpression], null, operator)
+    }
+
+    @Unroll
+    void "buildQuery adds each expression's criterion directly to the query, without a disjunction, for operator #operator"() {
+        given:
+        Query.ProjectionList projectionList = Mock(Query.ProjectionList)
+        Query query = Mock(Query) {
+            getEntity() >> persistentEntity
+            projections() >> projectionList
+        }
+
+        when:
+        countFinder.buildQuery(twoEqualsInvocation(operator), Stub(Session) { createQuery(FinderTestEntity) >> query })
+
+        then:
+        2 * query.add(_)
+        0 * query.disjunction()
+        1 * projectionList.count()
+
+        where:
+        operator << [null, 'And']
+    }
+
+    void "buildQuery adds each expression's criterion to an explicit disjunction for the Or operator"() {
+        given:
+        Query.Junction disjunctionMock = Mock(Query.Junction)
+        Query.ProjectionList projectionList = Mock(Query.ProjectionList)
+        Query query = Mock(Query) {
+            getEntity() >> persistentEntity
+            projections() >> projectionList
+        }
+        Session session = Stub(Session) {
+            createQuery(FinderTestEntity) >> query
+        }
+
+        when:
+        countFinder.buildQuery(twoEqualsInvocation('Or'), session)
+
+        then:
+        // Return value is specified here, not via a separate stub, since a bare cardinality
+        // interaction for the same method declared later would shadow an earlier stub's return
+        // value and silently default to returning null.
+        1 * query.disjunction() >> disjunctionMock
+        2 * query.add(disjunctionMock, _)
+        0 * query.add(_)
+        1 * projectionList.count()
+    }
+
+    void "isMethodMatch matches countBy* method names"() {
+        expect:
+        countFinder.isMethodMatch('countByName')
+        countFinder.isMethodMatch('countByNameAndAge')
+        !countFinder.isMethodMatch('somethingElse')
+    }
+
+    void "setPattern delegates to the grammar"() {
+        expect:
+        countFinder.isMethodMatch('countByName')
+        !countFinder.isMethodMatch('customPrefixName')
+
+        when:
+        countFinder.setPattern('(customPrefix)(\\w+)')
+
+        then:
+        countFinder.isMethodMatch('customPrefixName')
+        !countFinder.isMethodMatch('countByName')
+    }
+
+    void "invoke runs the full round trip through the real DatastoreUtils.execute seam and returns the count"() {
+        given:
+        Query.ProjectionList projectionList = Mock(Query.ProjectionList)
+        Query query = Mock(Query) {
+            getEntity() >> persistentEntity
+            projections() >> projectionList
+            singleResult() >> 2L
+        }
+        Session session = Stub(Session) {
+            createQuery(FinderTestEntity) >> query
+        }
+        datastore.hasCurrentSession() >> true
+        datastore.getCurrentSession() >> session
+
+        when:
+        Object result = countFinder.invoke(FinderTestEntity, 'countByName', ['Bob'] as Object[])
+
+        then:
+        result == 2L
+        1 * projectionList.count()
+    }
+
+    void "invoke(Class, methodName, DetachedCriteria, Object[]) merges the detached criteria onto the built query"() {
+        given:
+        // Called reflectively (dynamic Groovy dispatch, not part of FinderMethod) by
+        // AbstractDetachedCriteria#methodMissing.
+        Query.ProjectionList projectionList = Mock(Query.ProjectionList)
+        Query query = Mock(Query) {
+            getEntity() >> persistentEntity
+            projections() >> projectionList
+            singleResult() >> 2L
+        }
+        Session session = Stub(Session) {
+            createQuery(FinderTestEntity) >> query
+        }
+        datastore.hasCurrentSession() >> true
+        datastore.getCurrentSession() >> session
+        grails.gorm.DetachedCriteria detachedCriteria = Stub(grails.gorm.DetachedCriteria) {
+            getFetchStrategies() >> [:]
+            getCriteria() >> [org.grails.datastore.mapping.query.Restrictions.eq('age', 42)]
+            getProjections() >> []
+            getOrders() >> []
+        }
+
+        when:
+        Object result = countFinder.invoke(FinderTestEntity, 'countByName', detachedCriteria, ['Bob'] as Object[])
+
+        then:
+        result == 2L
+        // One from the detached criteria's merged criterion, one from the real "name" expression.
+        2 * query.add({ it instanceof Query.PropertyCriterion })
+        1 * projectionList.count()
+    }
+
+    void "invoke(Class, methodName, DetachedCriteria, Object[]) with a null detachedCriteria never merges anything onto the built query"() {
+        given:
+        Query.ProjectionList projectionList = Mock(Query.ProjectionList)
+        Query query = Mock(Query) {
+            getEntity() >> persistentEntity
+            projections() >> projectionList
+            singleResult() >> 2L
+        }
+        Session session = Stub(Session) {
+            createQuery(FinderTestEntity) >> query
+        }
+        datastore.hasCurrentSession() >> true
+        datastore.getCurrentSession() >> session
+
+        when:
+        Object result = countFinder.invoke(FinderTestEntity, 'countByName', (grails.gorm.DetachedCriteria) null, ['Bob'] as Object[])
+
+        then:
+        result == 2L
+        1 * query.add({ it instanceof Query.PropertyCriterion })
+        1 * projectionList.count()
+    }
+
+    void "invoke throws IllegalStateException when constructed in stateless mode"() {
+        when:
+        CountFinder.countBy(mappingContext).invoke(FinderTestEntity, 'countByName', ['Bob'] as Object[])
+
+        then:
+        thrown(IllegalStateException)
+    }
+    void "countBy resolves its datastore through the DatastoreResolver on every invocation, not at construction"() {
+        given:
+        Query query = Mock(Query) {
+            getEntity() >> persistentEntity
+            projections() >> Mock(Query.ProjectionList)
+        }
+        Session session = Stub(Session) {
+            createQuery(FinderTestEntity) >> query
+        }
+        Datastore lateBoundDatastore = Stub(Datastore) {
+            hasCurrentSession() >> true
+            getCurrentSession() >> session
+        }
+        DatastoreResolver resolver = Mock(DatastoreResolver)
+
+        when:
+        def finder = CountFinder.countBy(resolver, mappingContext)
+
+        then:
+        0 * resolver.resolve()
+
+        when:
+        finder.invoke(FinderTestEntity, 'countByName', ['Bob'] as Object[])
+
+        then:
+        1 * resolver.resolve() >> lateBoundDatastore
+        1 * query.singleResult() >> 0L
+    }
+
+    void "countBy throws IllegalStateException when the DatastoreResolver resolves no datastore"() {
+        given:
+        DatastoreResolver resolver = Mock(DatastoreResolver)
+
+        when:
+        CountFinder.countBy(resolver, mappingContext).invoke(FinderTestEntity, 'countByName', ['Bob'] as Object[])
+
+        then:
+        IllegalStateException e = thrown()
+        e.message == 'Cannot execute session query with null datastore'
+    }
+}
diff --git a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/AbstractFinderSpec.groovy b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/DatastoreResolverFinderSpec.groovy
similarity index 56%
rename from grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/AbstractFinderSpec.groovy
rename to grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/DatastoreResolverFinderSpec.groovy
index c266384f1f..7073d4fd9e 100644
--- a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/AbstractFinderSpec.groovy
+++ b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/DatastoreResolverFinderSpec.groovy
@@ -25,60 +25,59 @@ import spock.lang.Specification
 import spock.lang.Unroll
 
 /**
- * {@code AbstractFinder} is the common base for the "named" dynamic finder classes
- * ({@code FindAllByFinder}, {@code CountByFinder}, {@code ListOrderByFinder}, etc.) that
- * {@code DefaultGormApiFactory#createDynamicFinders} registers using the new
- * {@code DatastoreResolver}-based constructor this PR added. Drives real dynamic finder calls
- * through a {@code SimpleMapDatastore}-backed entity to exercise the lazy resolver path and the
- * additional-criteria closure support.
+ * {@code DefaultGormApiFactory#createDynamicFinders} registers every finder ({@code ListResultFinder},
+ * {@code CountFinder}, {@code ListOrderByFinder}, etc.) through its {@code DatastoreResolver}-based
+ * factory, and {@link FinderSupport} resolves the datastore on every invocation. Drives real
+ * dynamic finder calls through a {@code SimpleMapDatastore}-backed entity to exercise that lazy
+ * resolver path and the additional-criteria closure support.
  */
-class AbstractFinderSpec extends Specification {
+class DatastoreResolverFinderSpec extends Specification {
 
     @AutoCleanup
-    SimpleMapDatastore datastore = new SimpleMapDatastore(AbstractFinderThing)
+    SimpleMapDatastore datastore = new SimpleMapDatastore(ResolverFinderThing)
 
     void "findAllBy resolves its datastore via the lazy DatastoreResolver and executes a real query"() {
         given:
-        AbstractFinderThing.newInstance(title: 'Alpha').save(flush: true)
-        AbstractFinderThing.newInstance(title: 'Beta').save(flush: true)
+        ResolverFinderThing.newInstance(title: 'Alpha').save(flush: true)
+        ResolverFinderThing.newInstance(title: 'Beta').save(flush: true)
 
         expect:
-        AbstractFinderThing.findAllByTitle('Alpha').size() == 1
+        ResolverFinderThing.findAllByTitle('Alpha').size() == 1
     }
 
     void "countBy resolves its datastore via the lazy DatastoreResolver and executes a real query"() {
         given:
-        AbstractFinderThing.newInstance(title: 'Same').save(flush: true)
-        AbstractFinderThing.newInstance(title: 'Same').save(flush: true)
+        ResolverFinderThing.newInstance(title: 'Same').save(flush: true)
+        ResolverFinderThing.newInstance(title: 'Same').save(flush: true)
 
         expect:
-        AbstractFinderThing.countByTitle('Same') == 2
+        ResolverFinderThing.countByTitle('Same') == 2
     }
 
     void "listOrderBy resolves its datastore via the lazy DatastoreResolver and executes a real query"() {
         given:
-        AbstractFinderThing.newInstance(title: 'B').save(flush: true)
-        AbstractFinderThing.newInstance(title: 'A').save(flush: true)
+        ResolverFinderThing.newInstance(title: 'B').save(flush: true)
+        ResolverFinderThing.newInstance(title: 'A').save(flush: true)
 
         expect:
-        AbstractFinderThing.listOrderByTitle()*.title == ['A', 'B']
+        ResolverFinderThing.listOrderByTitle()*.title == ['A', 'B']
     }
 
     void "listOrderBy normalizes the order direction regardless of case and surrounding whitespace"() {
         given:
-        AbstractFinderThing.newInstance(title: 'B').save(flush: true)
-        AbstractFinderThing.newInstance(title: 'A').save(flush: true)
+        ResolverFinderThing.newInstance(title: 'B').save(flush: true)
+        ResolverFinderThing.newInstance(title: 'A').save(flush: true)
 
         expect:
-        AbstractFinderThing.listOrderByTitle(order: ' DESC ')*.title == ['B', 'A']
-        AbstractFinderThing.listOrderByTitle(order: 'Asc')*.title == ['A', 'B']
-        AbstractFinderThing.listOrderByTitle(order: '')*.title == ['A', 'B']
+        ResolverFinderThing.listOrderByTitle(order: ' DESC ')*.title == ['B', 'A']
+        ResolverFinderThing.listOrderByTitle(order: 'Asc')*.title == ['A', 'B']
+        ResolverFinderThing.listOrderByTitle(order: '')*.title == ['A', 'B']
     }
 
     @Unroll
     void "listOrderBy rejects order direction #description without echoing it"() {
         when:
-        AbstractFinderThing.listOrderByTitle(order: order)
+        ResolverFinderThing.listOrderByTitle(order: order)
 
         then:
         def e = thrown(IllegalArgumentException)
@@ -90,29 +89,28 @@ class AbstractFinderSpec extends Specification {
         'desc extra' | 'carrying extra tokens'
     }
 
-    void "listOrderBy with an additional criteria closure applies it via applyAdditionalCriteria"() {
-        given: "ListOrderByFinder.invoke(Class, methodName, Closure, Object[]) is the one caller of\n" +
-                "applyAdditionalCriteria in this class hierarchy - called directly since routing a\n" +
-                "trailing closure through GormStaticApi's own dynamic dispatch into this specific\n" +
-                "4-arg overload isn't part of what this class itself needs proving"
-        AbstractFinderThing.newInstance(title: 'Matched', age: 30).save(flush: true)
-        AbstractFinderThing.newInstance(title: 'Matched', age: 99).save(flush: true)
+    void "listOrderBy with an additional criteria closure applies it via DynamicFinder.applyAdditionalCriteria"() {
+        given: "ListOrderByFinder.invoke(Class, methodName, Closure, Object[]) is called directly since\n" +
+                "routing a trailing closure through GormStaticApi's own dynamic dispatch into this\n" +
+                "specific 4-arg overload isn't part of what this class itself needs proving"
+        ResolverFinderThing.newInstance(title: 'Matched', age: 30).save(flush: true)
+        ResolverFinderThing.newInstance(title: 'Matched', age: 99).save(flush: true)
         def finder = new ListOrderByFinder(datastore)
 
         when:
-        def results = finder.invoke(AbstractFinderThing, 'listOrderByTitle', { lt('age', 50) }, [] as Object[])
+        def results = finder.invoke(ResolverFinderThing, 'listOrderByTitle', { lt('age', 50) }, [] as Object[])
 
         then:
         results.size() == 1
         results[0].age == 30
     }
 
-    void "execute(SessionCallback) throws IllegalStateException when no datastore can be resolved"() {
+    void "invoke throws IllegalStateException when no datastore can be resolved"() {
         given:
         def finder = new ListOrderByFinder((org.grails.datastore.mapping.core.Datastore) null)
 
         when:
-        finder.invoke(AbstractFinderThing, 'listOrderByTitle', [] as Object[])
+        finder.invoke(ResolverFinderThing, 'listOrderByTitle', [] as Object[])
 
         then:
         def e = thrown(IllegalStateException)
@@ -121,7 +119,7 @@ class AbstractFinderSpec extends Specification {
 }
 
 @Entity
-class AbstractFinderThing {
+class ResolverFinderThing {
     String title
     Integer age
 }
diff --git a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/DynamicFinderCoverageSpec.groovy b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/DynamicFinderCoverageSpec.groovy
index d4be58eed5..bc2ad2d99a 100644
--- a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/DynamicFinderCoverageSpec.groovy
+++ b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/DynamicFinderCoverageSpec.groovy
@@ -268,9 +268,9 @@ class DynamicFinderCoverageSpec extends Specification {
         notThrown(Exception)
     }
 
-    void "the DatastoreResolver+MappingContext constructor wires a finder that is usable without a bound datastore"() {
+    void "the DatastoreResolver+MappingContext factory wires a finder that is usable without a bound datastore"() {
         given:
-        def finder = new FindByFinder(new org.grails.datastore.gorm.DatastoreResolver() {
+        def finder = SingleResultFinder.findBy(new org.grails.datastore.gorm.DatastoreResolver() {
             @Override org.grails.datastore.mapping.core.Datastore resolve() { datastore }
         }, datastore.mappingContext)
 
@@ -278,9 +278,9 @@ class DynamicFinderCoverageSpec extends Specification {
         finder.isMethodMatch('findByName')
     }
 
-    void "the MappingContext-only constructor wires a finder without any datastore or resolver"() {
+    void "the MappingContext-only factory wires a finder without any datastore or resolver"() {
         given:
-        def finder = new FindByFinder(datastore.mappingContext)
+        def finder = SingleResultFinder.findBy(datastore.mappingContext)
 
         expect:
         finder.isMethodMatch('findByName')
@@ -288,7 +288,7 @@ class DynamicFinderCoverageSpec extends Specification {
 
     void "invoke(Class, methodName, DetachedCriteria, arguments) merges the detached criteria into the finder invocation"() {
         given:
-        def finder = new FindAllByFinder(datastore)
+        def finder = ListResultFinder.findAllBy(datastore)
         def detached = new grails.gorm.DetachedCriteria(DynamicFinderThing).build { gt('age', 20) }
 
         when:
diff --git a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/DynamicFinderInvocationSpec.groovy b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/DynamicFinderInvocationSpec.groovy
new file mode 100644
index 0000000000..d14dd289da
--- /dev/null
+++ b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/DynamicFinderInvocationSpec.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.finders
+
+import grails.gorm.DetachedCriteria
+import spock.lang.Specification
+
+/**
+ * DynamicFinderInvocation is an otherwise-immutable value object holding the pieces the
+ * DynamicFinder classes need to build and invoke a query - this spec confirms the constructor
+ * populates every getter, and that detachedCriteria (the one mutable field, wired in after
+ * construction by {@code DynamicFinder.invoke(Class, String, DetachedCriteria, Object[])}) starts
+ * out null and reflects whatever is later set on it.
+ */
+class DynamicFinderInvocationSpec extends Specification {
+
+    void "constructor populates every getter"() {
+        given:
+        MethodExpression.Equal expression = new MethodExpression.Equal('name')
+        Closure criteria = {}
+        Object[] arguments = ['Bob'] as Object[]
+
+        when:
+        DynamicFinderInvocation invocation = new DynamicFinderInvocation(
+                FinderTestEntity, 'findByName', arguments, [expression], criteria, 'And')
+
+        then:
+        invocation.javaClass == FinderTestEntity
+        invocation.methodName == 'findByName'
+        invocation.arguments.is(arguments)
+        invocation.expressions == [expression]
+        invocation.criteria.is(criteria)
+        invocation.operator == 'And'
+    }
+
+    void "getDetachedCriteria is null until explicitly set"() {
+        given:
+        DynamicFinderInvocation invocation = new DynamicFinderInvocation(
+                FinderTestEntity, 'findByName', [] as Object[], [], null, null)
+
+        expect:
+        invocation.detachedCriteria == null
+
+        when:
+        DetachedCriteria detachedCriteria = new DetachedCriteria(FinderTestEntity)
+        invocation.setDetachedCriteria(detachedCriteria)
+
+        then:
+        invocation.detachedCriteria.is(detachedCriteria)
+    }
+}
diff --git a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/DynamicFinderSpec.groovy b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/DynamicFinderSpec.groovy
index 23e0bdeceb..b128d915ee 100644
--- a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/DynamicFinderSpec.groovy
+++ b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/DynamicFinderSpec.groovy
@@ -18,13 +18,41 @@
  */
 package org.grails.datastore.gorm.finders
 
+import groovy.lang.MissingMethodException
+import jakarta.persistence.FetchType
+import jakarta.persistence.criteria.JoinType
+import org.grails.datastore.gorm.query.criteria.AbstractDetachedCriteria
+import org.grails.datastore.mapping.core.Session
+import org.grails.datastore.mapping.model.MappingContext
+import org.grails.datastore.mapping.model.PersistentEntity
+import org.grails.datastore.mapping.model.PersistentProperty
+import org.grails.datastore.mapping.model.types.Basic
+import org.grails.datastore.mapping.query.Query
+import org.grails.datastore.mapping.query.api.BuildableCriteria
+import org.grails.datastore.mapping.query.api.QueryArgumentsAware
+import org.springframework.core.convert.ConverterNotFoundException
+import org.springframework.core.convert.TypeDescriptor
+import org.springframework.core.convert.support.DefaultConversionService
 import spock.lang.Specification
+import spock.lang.Unroll
+
+import java.util.regex.Pattern
 
 /**
- * Created by graemerocher on 06/02/2017.
+ * Exercises {@link DynamicFinder}'s method-name grammar and its static parsing/argument-handling/
+ * junction-building surface. {@code DynamicFinder} is a plain, non-abstract, non-inheritable
+ * collaborator - constructed directly here with the same patterns the real finder classes
+ * ({@link SingleResultFinder}, {@link ListResultFinder}, {@link CountFinder}) use, rather than via
+ * a concrete finder subclass (there are none any more). Collaborators (MappingContext/
+ * PersistentEntity/Query) are mocked - this package's own responsibility is translating dynamic-
+ * finder method calls into {@code Query} objects, not persistence itself.
  */
 class DynamicFinderSpec extends Specification {
 
+    private static final Pattern FIND_BY_PATTERN = Pattern.compile('(findBy)([A-Z]\\w*)')
+    private static final Pattern FIND_BY_BOOLEAN_PATTERN = Pattern.compile('(find)((\\w+)(By)([A-Z]\\w*)|(\\w++))')
+    private static final String[] OPERATORS = ['And', 'Or'] as String[]
+
     void "test build match spec"() {
         given:
         MatchSpec spec = DynamicFinder.buildMatchSpec(prefix, methodName, parameters)
@@ -35,11 +63,842 @@ class DynamicFinderSpec extends Specification {
         spec.requiredArguments == parameters
         spec.prefix == prefix
         spec.queryExpression == queryExpression
+        spec.propertyNames == propertyNames
 
         where:
-        prefix   | methodName              | parameters | expressions | queryExpression    |   propertyNames
-        "findBy" | "findByTitle"           | 1          |    1        | "Title"            |  ['title']
-        "findBy" | "findByTitleBetween"    | 2          |    1        | "TitleBetween"     |  ['title']
-        "findBy" | "findByTitleAndAuthor"  | 2          |    2        | "TitleAndAuthor"   |  ['title', 'author']
+        prefix   | methodName                    | parameters | expressions | queryExpression        | propertyNames
+        "findBy" | "findByTitle"                 | 1          | 1           | "Title"                | ['title']
+        "findBy" | "findByTitleBetween"          | 2          | 1           | "TitleBetween"         | ['title']
+        "findBy" | "findByTitleAndAuthor"        | 2          | 2           | "TitleAndAuthor"       | ['title', 'author']
+        "findBy" | "findByTitleOrAuthor"         | 2          | 2           | "TitleOrAuthor"        | ['title', 'author']
+        "findBy" | "findByAgeGreaterThanEquals"  | 1          | 1           | "AgeGreaterThanEquals" | ['age']
+        "findBy" | "findByAgeLessThanEquals"     | 1          | 1           | "AgeLessThanEquals"    | ['age']
+        "findBy" | "findByActiveIsNull"          | 0          | 1           | "ActiveIsNull"         | ['active']
+        "findBy" | "findByActiveIsNotNull"       | 0          | 1           | "ActiveIsNotNull"      | ['active']
+        "findBy" | "findByAuthorNot"             | 1          | 1           | "AuthorNot"            | ['author']
+    }
+
+    void "buildMatchSpec returns null when there are fewer parameters than the expression requires"() {
+        expect:
+        DynamicFinder.buildMatchSpec("findBy", "findByTitleBetween", 1) == null
+    }
+
+    void "buildMatchSpec returns null when a lone expression's arguments exceed the given parameter count"() {
+        expect:
+        DynamicFinder.buildMatchSpec("findBy", "findByTitle", 0) == null
+    }
+
+    void "buildMatchSpec returns null when the method name does not match the prefix pattern at all"() {
+        expect:
+        DynamicFinder.buildMatchSpec("findBy", "getSomethingUnrelated", 1) == null
+    }
+
+    void "buildMatchSpec returns null when an And-combined expression's total arguments exceed the given parameter count"() {
+        expect:
+        // "TitleAndAuthorBetween" splits into Equal(title, 1 arg) + Between(author, 2 args) = 3
+        // required arguments total - only reachable via the post-loop check since neither
+        // individual expression alone exceeds parameterCount.
+        DynamicFinder.buildMatchSpec("findBy", "findByTitleAndAuthorBetween", 2) == null
+    }
+
+    void "buildMatchSpec resolves a GreaterThanEquals suffix without truncating it to GreaterThan"() {
+        given:
+        MatchSpec spec = DynamicFinder.buildMatchSpec("findBy", "findByAgeGreaterThanEquals", 1)
+
+        expect:
+        spec.methodCallExpressions[0] instanceof MethodExpression.GreaterThanEquals
+    }
+
+    void "buildMatchSpec wraps a Not-suffixed clause's criterion in a Negation"() {
+        given:
+        MatchSpec spec = DynamicFinder.buildMatchSpec("findBy", "findByAuthorNot", 1)
+        MethodExpression expression = spec.methodCallExpressions[0]
+        expression.setArguments(['Bob'] as Object[])
+
+        when:
+        Query.Criterion criterion = expression.createCriterion()
+
+        then:
+        criterion instanceof Query.Negation
+        Query.Negation negation = (Query.Negation) criterion
+        negation.criteria.size() == 1
+        negation.criteria[0] instanceof Query.Equals
+        negation.criteria[0].property == 'author'
+    }
+
+    void "buildMatchSpec throws when the derived property name is empty because the property itself shares the operator's name"() {
+        when:
+        // "findByLikeLike" means "property 'Like', operator Like" - but calcPropertyName finds the
+        // FIRST occurrence of "Like" in "LikeLike" (index 0, the property's own name), not the
+        // occurrence the regex actually matched, so the derived property name comes out empty.
+        DynamicFinder.buildMatchSpec("findBy", "findByLikeLike", 1)
+
+        then:
+        thrown(IllegalArgumentException)
+    }
+
+    void "buildMatchSpec's naive literal split on the And/Or operator can split inside an unrelated property name"() {
+        when:
+        // "findByAndroidVersionAndTitle" is meant to be "androidVersion And title", but
+        // querySequence.split("And") splits on every literal occurrence of "And", including the
+        // one inside "Android" itself, producing an empty leading segment - a real parsing bug in
+        // both buildMatchSpec and createFinderInvocation's identical split logic, not a
+        // hypothetical. Documented here as current behavior, not fixed as part of this pass.
+        DynamicFinder.buildMatchSpec("findBy", "findByAndroidVersionAndTitle", 2)
+
+        then:
+        thrown(IllegalArgumentException)
+    }
+
+    void "registerNewMethodExpression rejects a class without a (Class, String) constructor"() {
+        when:
+        DynamicFinder.registerNewMethodExpression(String)
+
+        then:
+        thrown(IllegalArgumentException)
+    }
+
+    void "registerNewMethodExpression registers a custom operator that buildMatchSpec can then resolve"() {
+        given:
+        DynamicFinder.registerNewMethodExpression(ZzzCustomTest)
+
+        when:
+        // the registered alternative is matched against the CLASS'S OWN SIMPLE NAME, not some
+        // separately-declared suffix string - same convention every built-in operator uses
+        // (e.g. MethodExpression.GreaterThan's suffix is "GreaterThan", not a custom string).
+        MatchSpec spec = DynamicFinder.buildMatchSpec("findBy", "findByTitleZzzCustomTest", 1)
+
+        then:
+        spec.methodCallExpressions[0] instanceof ZzzCustomTest
+        spec.propertyNames == ['title']
+    }
+
+    static class ZzzCustomTest extends MethodExpression {
+        ZzzCustomTest(Class targetClass, String propertyName) {
+            super(targetClass, propertyName)
+        }
+
+        @Override
+        Query.Criterion createCriterion() {
+            return org.grails.datastore.mapping.query.Restrictions.eq(propertyName, arguments[0])
+        }
+    }
+
+    PersistentProperty idProperty = Stub(PersistentProperty) {
+        getName() >> 'id'
+        getType() >> Long
+    }
+    PersistentProperty nameProperty = Stub(PersistentProperty) {
+        getName() >> 'name'
+        getType() >> String
+    }
+    PersistentProperty ageProperty = Stub(PersistentProperty) {
+        getName() >> 'age'
+        getType() >> Integer
+    }
+    PersistentProperty authorProperty = Stub(PersistentProperty) {
+        getName() >> 'author'
+        getType() >> String
+    }
+    PersistentProperty activeProperty = Stub(PersistentProperty) {
+        getName() >> 'active'
+        getType() >> Boolean
+    }
+    PersistentProperty tagsProperty = Stub(Basic) {
+        getName() >> 'tags'
+        getType() >> List
+    }
+    MappingContext mappingContext = Stub(MappingContext)
+    PersistentEntity persistentEntity = Stub(PersistentEntity)
+
+    // Wired in setup() rather than inline field initializers: mappingContext.getPersistentEntity()
+    // needs to return `persistentEntity`, and persistentEntity.getMappingContext() needs to return
+    // `mappingContext` right back - a genuine circular reference that inline field initializers
+    // can't express (a Stub(...) { } closure captures the RHS field's value at the moment its own
+    // field initializer runs, so whichever field is declared second would still be null).
+    void setup() {
+        mappingContext.getConversionService() >> new DefaultConversionService()
+        mappingContext.getPersistentEntity(FinderTestEntity.name) >> persistentEntity
+        persistentEntity.getIdentity() >> idProperty
+        persistentEntity.getMappingContext() >> mappingContext
+        persistentEntity.getPropertyByName('name') >> nameProperty
+        persistentEntity.getPropertyByName('age') >> ageProperty
+        persistentEntity.getPropertyByName('author') >> authorProperty
+        persistentEntity.getPropertyByName('active') >> activeProperty
+        persistentEntity.getPropertyByName('tags') >> tagsProperty
+    }
+
+    DynamicFinder findByGrammar = new DynamicFinder(FIND_BY_PATTERN, OPERATORS, mappingContext, false)
+    DynamicFinder findByBooleanGrammar = new DynamicFinder(FIND_BY_BOOLEAN_PATTERN, OPERATORS, mappingContext, true)
+
+    @Unroll
+    void "isMethodMatch('#methodName') == #matches"() {
+        expect:
+        findByGrammar.isMethodMatch(methodName) == matches
+
+        where:
+        methodName            | matches
+        'findByName'          | true
+        'findByNameAndAge'    | true
+        'somethingElse'       | false
+    }
+
+    void "firstExpressionIsRequiredBoolean reflects the constructor flag"() {
+        expect:
+        !findByGrammar.firstExpressionIsRequiredBoolean()
+        findByBooleanGrammar.firstExpressionIsRequiredBoolean()
+    }
+
+    void "createFinderInvocation resolves a single equality expression"() {
+        when:
+        DynamicFinderInvocation invocation = findByGrammar.createFinderInvocation(FinderTestEntity, 'findByName', null, ['Bob'] as Object[])
+
+        then:
+        invocation.expressions.size() == 1
+        invocation.expressions[0] instanceof MethodExpression.Equal
+        invocation.expressions[0].propertyName == 'name'
+        invocation.arguments.length == 0
+    }
+
+    void "createFinderInvocation splits an And junction into two expressions and records the operator"() {
+        when:
+        DynamicFinderInvocation invocation = findByGrammar.createFinderInvocation(
+                FinderTestEntity, 'findByNameAndAge', null, ['Bob', 42] as Object[])
+
+        then:
+        invocation.operator == 'And'
+        invocation.expressions.size() == 2
+        invocation.expressions[0].propertyName == 'name'
+        invocation.expressions[1].propertyName == 'age'
+    }
+
+    void "createFinderInvocation splits an Or junction and records the Or operator"() {
+        when:
+        DynamicFinderInvocation invocation = findByGrammar.createFinderInvocation(
+                FinderTestEntity, 'findByNameOrAge', null, ['Bob', 42] as Object[])
+
+        then:
+        invocation.operator == 'Or'
+        invocation.expressions.size() == 2
+    }
+
+    void "createFinderInvocation wraps a Not-suffixed clause's expression so its criterion negates"() {
+        when:
+        DynamicFinderInvocation invocation = findByGrammar.createFinderInvocation(
+                FinderTestEntity, 'findByAuthorNot', null, ['Bob'] as Object[])
+        Query.Criterion criterion = invocation.expressions[0].createCriterion()
+
+        then:
+        invocation.expressions.size() == 1
+        criterion instanceof Query.Negation
+    }
+
+    void "createFinderInvocation throws MissingMethodException when too few arguments are supplied"() {
+        when:
+        findByGrammar.createFinderInvocation(FinderTestEntity, 'findByNameAndAge', null, ['Bob'] as Object[])
+
+        then:
+        thrown(MissingMethodException)
+    }
+
+    void "createFinderInvocation's naive literal split on the And/Or operator can split inside an unrelated property name"() {
+        when:
+        // Mirrors the "buildMatchSpec's naive literal split..." test above, but through the actual
+        // runtime entry point real finders call, proving the split logic really is identical - not
+        // merely asserted to be by comment. "findByAndroidVersionAndTitle" is meant to be
+        // "androidVersion And title", but querySequence.split("And") splits on every literal
+        // occurrence of "And", including the one starting "AndroidVersion" itself, producing an
+        // empty leading segment.
+        findByGrammar.createFinderInvocation(FinderTestEntity, 'findByAndroidVersionAndTitle', null, ['x', 'y'] as Object[])
+
+        then:
+        thrown(IllegalArgumentException)
+    }
+
+    void "createFinderInvocation rethrows a ConversionException as MissingMethodException for a non-Basic property"() {
+        given:
+        // "age" (a plain Integer, non-Basic property) with an argument that genuinely needs
+        // conversion - convertArguments only ever calls the ConversionService when the argument's
+        // type isn't already assignable to the property's type, so an already-matching argument
+        // (e.g. a String for a String property) would never reach the ConversionService at all.
+        mappingContext.getConversionService() >> Stub(org.springframework.core.convert.ConversionService) {
+            canConvert(_, _) >> true
+            convert(_, _) >> { throw new ConverterNotFoundException(TypeDescriptor.valueOf(String), TypeDescriptor.valueOf(Integer)) }
+        }
+
+        when:
+        findByGrammar.createFinderInvocation(FinderTestEntity, 'findByAge', null, ['not-a-number'] as Object[])
+
+        then:
+        thrown(MissingMethodException)
+    }
+
+    void "createFinderInvocation swallows a ConversionException for a Basic property"() {
+        given:
+        // Real Basic-typed properties always model a to-many/collection shape (Basic extends
+        // ToMany), and MethodExpression's own conversion logic skips the ConversionService
+        // entirely for a raw collection-typed property - so a scalar-typed Basic stub is used
+        // here purely to exercise DynamicFinder's own "instanceof Basic" dispatch in isolation,
+        // not to model a realistic Basic property.
+        PersistentProperty basicFlagProperty = Stub(Basic) {
+            getName() >> 'flag'
+            getType() >> Boolean
+        }
+        persistentEntity.getPropertyByName('flag') >> basicFlagProperty
+        mappingContext.getConversionService() >> Stub(org.springframework.core.convert.ConversionService) {
+            canConvert(_, _) >> true
+            convert(_, _) >> { throw new ConverterNotFoundException(TypeDescriptor.valueOf(String), TypeDescriptor.valueOf(Boolean)) }
+        }
+
+        when:
+        DynamicFinderInvocation invocation = findByGrammar.createFinderInvocation(FinderTestEntity, 'findByFlag', null, ['not-a-boolean'] as Object[])
+
+        then:
+        noExceptionThrown()
+        invocation.expressions.size() == 1
+    }
+
+    void "createFinderInvocation resolves the boolean-plus-By form, defaulting the boolean argument to TRUE"() {
+        when:
+        DynamicFinderInvocation invocation = findByBooleanGrammar.createFinderInvocation(
+                FinderTestEntity, 'findActiveByName', null, ['Bob'] as Object[])
+
+        then:
+        invocation.expressions.size() == 2
+        invocation.expressions[0].propertyName == 'active'
+        invocation.expressions[0].getArguments()[0] == Boolean.TRUE
+        invocation.expressions[1].propertyName == 'name'
+    }
+
+    void "createFinderInvocation resolves the lone-boolean form with no By clause and no consumed arguments"() {
+        when:
+        DynamicFinderInvocation invocation = findByBooleanGrammar.createFinderInvocation(
+                FinderTestEntity, 'findActive', null, [] as Object[])
+
+        then:
+        invocation.expressions.size() == 1
+        invocation.expressions[0].propertyName == 'active'
+        invocation.expressions[0].getArguments()[0] == Boolean.TRUE
+    }
+
+    void "createFinderInvocation flips the boolean argument to FALSE for a Not-prefixed boolean clause"() {
+        when:
+        DynamicFinderInvocation invocation = findByBooleanGrammar.createFinderInvocation(
+                FinderTestEntity, 'findNotActiveByName', null, ['Bob'] as Object[])
+
+        then:
+        invocation.expressions[0].propertyName == 'active'
+        invocation.expressions[0].getArguments()[0] == Boolean.FALSE
+    }
+
+    void "getJunction builds a Conjunction for the And operator"() {
+        given:
+        MethodExpression name = new MethodExpression.Equal('name')
+        name.setArguments(['Bob'] as Object[])
+        MethodExpression age = new MethodExpression.Equal('age')
+        age.setArguments([42] as Object[])
+        DynamicFinderInvocation invocation = new DynamicFinderInvocation(FinderTestEntity, 'findByNameAndAge', [] as Object[], [name, age], null, 'And')
+
+        when:
+        Query.Junction junction = findByGrammar.getJunction(invocation)
+
+        then:
+        junction instanceof Query.Conjunction
+        junction.criteria.size() == 2
+    }
+
+    void "getJunction builds a Disjunction for the Or operator when no boolean clause is required"() {
+        given:
+        MethodExpression name = new MethodExpression.Equal('name')
+        name.setArguments(['Bob'] as Object[])
+        MethodExpression age = new MethodExpression.Equal('age')
+        age.setArguments([42] as Object[])
+        DynamicFinderInvocation invocation = new DynamicFinderInvocation(FinderTestEntity, 'findByNameOrAge', [] as Object[], [name, age], null, 'Or')
+
+        when:
+        Query.Junction junction = findByGrammar.getJunction(invocation)
+
+        then:
+        junction instanceof Query.Disjunction
+        junction.criteria.size() == 2
+    }
+
+    void "getJunction keeps a required boolean clause AND'd even when the rest of the clauses use Or"() {
+        given:
+        MethodExpression active = new MethodExpression.Equal('active')
+        active.setArguments([Boolean.TRUE] as Object[])
+        MethodExpression name = new MethodExpression.Equal('name')
+        name.setArguments(['Bob'] as Object[])
+        MethodExpression age = new MethodExpression.Equal('age')
+        age.setArguments([42] as Object[])
+        DynamicFinderInvocation invocation = new DynamicFinderInvocation(
+                FinderTestEntity, 'findActiveByNameOrAge', [] as Object[], [active, name, age], null, 'Or')
+
+        when:
+        Query.Junction junction = findByBooleanGrammar.getJunction(invocation)
+
+        then:
+        junction instanceof Query.Conjunction
+        junction.criteria.size() == 2
+        junction.criteria[1] instanceof Query.Disjunction
+        junction.criteria[1].criteria.size() == 2
+    }
+
+    void "populateArgumentsForCriteria(Query) is a no-op for a null argument map"() {
+        given:
+        Query query = Mock(Query)
+
+        when:
+        DynamicFinder.populateArgumentsForCriteria(FinderTestEntity, query, null)
+
+        then:
+        0 * query._
+    }
+
+    void "populateArgumentsForCriteria(Query) applies max and offset, converting String values"() {
+        given:
+        Query query = Mock(Query) {
+            getEntity() >> Stub(PersistentEntity) { getMappingContext() >> mappingContext }
+        }
+
+        when:
+        DynamicFinder.populateArgumentsForCriteria(FinderTestEntity, query, [max: '10', offset: '5'])
+
+        then:
+        1 * query.max(10)
+        1 * query.offset(5)
+    }
+
+    void "populateArgumentsForCriteria(Query) applies a String sort, defaulting to ascending and ignoring case"() {
+        given:
+        Query query = Mock(Query) {
+            getEntity() >> Stub(PersistentEntity) { getMappingContext() >> mappingContext }
+        }
+
+        when:
+        DynamicFinder.populateArgumentsForCriteria(FinderTestEntity, query, [sort: 'age'])
+
+        then:
+        1 * query.order({ Query.Order order ->
+            order.property == 'age' && order.direction == Query.Order.Direction.ASC && order.ignoreCase
+        })
+    }
+
+    void "populateArgumentsForCriteria(Query) applies a descending order and honours ignoreCase: false"() {
+        given:
+        Query query = Mock(Query) {
+            getEntity() >> Stub(PersistentEntity) { getMappingContext() >> mappingContext }
+        }
+
+        when:
+        DynamicFinder.populateArgumentsForCriteria(FinderTestEntity, query, [sort: 'age', order: 'desc', ignoreCase: false])
+
+        then:
+        1 * query.order({ Query.Order order ->
+            order.property == 'age' && order.direction == Query.Order.Direction.DESC && !order.ignoreCase
+        })
+    }
+
+    void "populateArgumentsForCriteria(Query) applies a multi-field sort Map"() {
+        given:
+        Query query = Mock(Query) {
+            getEntity() >> Stub(PersistentEntity) { getMappingContext() >> mappingContext }
+        }
+
+        when:
+        DynamicFinder.populateArgumentsForCriteria(FinderTestEntity, query, [sort: [name: 'asc', age: 'desc']])
+
+        then:
+        1 * query.order({ Query.Order order -> order.property == 'name' && order.direction == Query.Order.Direction.ASC })
+        1 * query.order({ Query.Order order -> order.property == 'age' && order.direction == Query.Order.Direction.DESC })
+    }
+
+    @Unroll
+    void "populateArgumentsForCriteria(Query) applies fetch entry #fetchValue as #expectedMethod"() {
+        given:
+        Query query = Mock(Query) {
+            getEntity() >> Stub(PersistentEntity) { getMappingContext() >> mappingContext }
+        }
+
+        when:
+        DynamicFinder.populateArgumentsForCriteria(FinderTestEntity, query, [fetch: [author: fetchValue]])
+
+        then:
+        1 * query."$expectedMethod"('author')
+
+        where:
+        fetchValue          | expectedMethod
+        FetchType.EAGER     | 'join'
+        FetchType.LAZY      | 'select'
+        'eager'             | 'join'
+        'join'              | 'join'
+        'lazy'              | 'select'
+        'select'            | 'select'
+        'unrecognised'      | 'select'
+    }
+
+    void "populateArgumentsForCriteria(Query) applies an explicit JoinType fetch entry via the two-arg join"() {
+        given:
+        Query query = Mock(Query) {
+            getEntity() >> Stub(PersistentEntity) { getMappingContext() >> mappingContext }
+        }
+
+        when:
+        DynamicFinder.populateArgumentsForCriteria(FinderTestEntity, query, [fetch: [author: JoinType.LEFT]])
+
+        then:
+        1 * query.join('author', JoinType.LEFT)
+    }
+
+    void "populateArgumentsForCriteria(Query) applies cache and lock flags"() {
+        given:
+        Query query = Mock(Query) {
+            getEntity() >> Stub(PersistentEntity) { getMappingContext() >> mappingContext }
+        }
+
+        when:
+        DynamicFinder.populateArgumentsForCriteria(FinderTestEntity, query, [cache: true, lock: true])
+
+        then:
+        1 * query.cache(true)
+        1 * query.lock(true)
+    }
+
+    void "populateArgumentsForCriteria(Query) passes the raw argument map to a QueryArgumentsAware query"() {
+        given:
+        Query query = Mock(Query, additionalInterfaces: [QueryArgumentsAware]) {
+            getEntity() >> Stub(PersistentEntity) { getMappingContext() >> mappingContext }
+        }
+        Map argMap = [sort: 'name']
+
+        when:
+        DynamicFinder.populateArgumentsForCriteria(FinderTestEntity, query, argMap)
+
+        then:
+        1 * ((QueryArgumentsAware) query).setArguments(argMap)
+    }
+
+    void "populateArgumentsForCriteria(BuildableCriteria) is a no-op for a null argument map"() {
+        given:
+        BuildableCriteria criteria = Mock(BuildableCriteria)
+
+        when:
+        DynamicFinder.populateArgumentsForCriteria(criteria, null)
+
+        then:
+        0 * criteria._
+    }
+
+    void "populateArgumentsForCriteria(BuildableCriteria) applies cache and sort, but has no lock/max/offset support"() {
+        given:
+        BuildableCriteria criteria = Mock(BuildableCriteria)
+
+        when:
+        DynamicFinder.populateArgumentsForCriteria(criteria, [cache: true, sort: 'name'])
+
+        then:
+        1 * criteria.cache(true)
+        1 * criteria.order({ Query.Order order -> order.property == 'name' })
+        0 * criteria.lock(_)
+        0 * criteria.max(_)
+        0 * criteria.offset(_)
+    }
+
+    void "populateArgumentsForCriteria(BuildableCriteria) applies a descending order for a single-field CharSequence sort"() {
+        given:
+        BuildableCriteria criteria = Mock(BuildableCriteria)
+
+        when:
+        DynamicFinder.populateArgumentsForCriteria(criteria, [sort: 'name', order: 'desc'])
+
+        then:
+        1 * criteria.order({ Query.Order order -> order.property == 'name' && order.direction == Query.Order.Direction.DESC && order.ignoreCase })
+    }
+
+    void "populateArgumentsForCriteria(BuildableCriteria) honours ignoreCase: false for a single-field CharSequence sort"() {
+        given:
+        BuildableCriteria criteria = Mock(BuildableCriteria)
+
+        when:
+        DynamicFinder.populateArgumentsForCriteria(criteria, [sort: 'name', ignoreCase: false])
+
+        then:
+        1 * criteria.order({ Query.Order order -> order.property == 'name' && order.direction == Query.Order.Direction.ASC && !order.ignoreCase })
+    }
+
+    void "populateArgumentsForCriteria(BuildableCriteria) applies fetch strategies for EAGER and LAZY associations"() {
+        given:
+        BuildableCriteria criteria = Mock(BuildableCriteria)
+
+        when:
+        DynamicFinder.populateArgumentsForCriteria(criteria, [fetch: [author: FetchType.EAGER, tags: FetchType.LAZY]])
+
+        then:
+        1 * criteria.join('author')
+        1 * criteria.select('tags')
+    }
+
+    void "populateArgumentsForCriteria(BuildableCriteria) applies a multi-field sort Map, honouring each entry's own direction"() {
+        given:
+        BuildableCriteria criteria = Mock(BuildableCriteria)
+
+        when:
+        DynamicFinder.populateArgumentsForCriteria(criteria, [sort: [name: 'asc', age: 'desc']])
+
+        then:
+        1 * criteria.order({ Query.Order order -> order.property == 'name' && order.direction == Query.Order.Direction.ASC })
+        1 * criteria.order({ Query.Order order -> order.property == 'age' && order.direction == Query.Order.Direction.DESC })
+    }
+
+    void "populateArgumentsForCriteria(BuildableCriteria) ignores the top-level order argument for a sort Map, as the Query overload does"() {
+        given:
+        BuildableCriteria criteria = Mock(BuildableCriteria)
+
+        when:
+        DynamicFinder.populateArgumentsForCriteria(criteria, [sort: [name: 'asc', age: 'desc'], order: 'desc'])
+
+        then:
+        1 * criteria.order({ Query.Order order -> order.property == 'name' && order.direction == Query.Order.Direction.ASC })
+        1 * criteria.order({ Query.Order order -> order.property == 'age' && order.direction == Query.Order.Direction.DESC })
+    }
+
+    void "populateArgumentsForCriteria(BuildableCriteria) defaults a null sort Map entry direction to ascending"() {
+        given:
+        BuildableCriteria criteria = Mock(BuildableCriteria)
+
+        when:
+        DynamicFinder.populateArgumentsForCriteria(criteria, [sort: [name: null]])
+
+        then:
+        1 * criteria.order({ Query.Order order -> order.property == 'name' && order.direction == Query.Order.Direction.ASC })
+    }
+
+    @Unroll
+    void "populateArgumentsForCriteria(BuildableCriteria) rejects a sort key #description without echoing it"() {
+        given:
+        BuildableCriteria criteria = Mock(BuildableCriteria)
+
+        when:
+        DynamicFinder.populateArgumentsForCriteria(criteria, [sort: sort])
+
+        then:
+        IllegalArgumentException e = thrown()
+        e.message == 'Invalid sort property'
+        0 * criteria.order(_)
+
+        where:
+        sort        | description
+        'name desc' | 'carrying a direction'
+        'name, age' | 'carrying a second expression'
+    }
+
+    void "populateArgumentsForCriteria(BuildableCriteria) rejects an order direction that is neither asc nor desc"() {
+        given:
+        BuildableCriteria criteria = Mock(BuildableCriteria)
+
+        when:
+        DynamicFinder.populateArgumentsForCriteria(criteria, [sort: 'name', order: 'sideways'])
+
+        then:
+        IllegalArgumentException e = thrown()
+        e.message == 'Invalid sort direction'
+        0 * criteria.order(_)
+    }
+
+    void "populateArgumentsForCriteria(BuildableCriteria) passes the raw argument map to a QueryArgumentsAware criteria"() {
+        given:
+        BuildableCriteria criteria = Mock(BuildableCriteria, additionalInterfaces: [QueryArgumentsAware])
+        Map argMap = [sort: 'name']
+
+        when:
+        DynamicFinder.populateArgumentsForCriteria(criteria, argMap)
+
+        then:
+        1 * ((QueryArgumentsAware) criteria).setArguments(argMap)
+    }
+
+    void "getFetchMode resolves the FetchType for every recognised alias, defaulting to LAZY"() {
+        expect:
+        DynamicFinder.getFetchMode(input) == expected
+
+        where:
+        input           | expected
+        FetchType.EAGER | FetchType.EAGER
+        FetchType.LAZY  | FetchType.LAZY
+        'eager'         | FetchType.EAGER
+        'join'          | FetchType.EAGER
+        'lazy'          | FetchType.LAZY
+        'select'        | FetchType.LAZY
+        'anything-else' | FetchType.LAZY
+        null            | FetchType.LAZY
+    }
+
+    void "applySortForMap applies ascending/descending per-entry order values, defaulting to ascending"() {
+        given:
+        Query query = Mock(Query)
+
+        when:
+        DynamicFinder.applySortForMap(query, [name: 'asc', age: 'desc', title: null], true)
+
+        then:
+        1 * query.order({ Query.Order o -> o.property == 'name' && o.direction == Query.Order.Direction.ASC })
+        1 * query.order({ Query.Order o -> o.property == 'age' && o.direction == Query.Order.Direction.DESC })
+        1 * query.order({ Query.Order o -> o.property == 'title' && o.direction == Query.Order.Direction.ASC })
+    }
+
+    void "applyDetachedCriteria is a no-op for a null detached criteria"() {
+        given:
+        Query query = Mock(Query)
+
+        when:
+        DynamicFinder.applyDetachedCriteria(query, null)
+
+        then:
+        0 * query._
+    }
+
+    void "applyDetachedCriteria merges fetch strategies, criteria, projections and orders onto the live query"() {
+        given:
+        Query query = Mock(Query)
+        MethodExpression.Equal expression = new MethodExpression.Equal('name')
+        expression.setArguments(['Bob'] as Object[])
+        Query.Criterion criterion = expression.createCriterion()
+        Query.Projection projection = Mock(Query.Projection)
+        Query.Order order = Query.Order.asc('age')
+        AbstractDetachedCriteria detachedCriteria = Stub(AbstractDetachedCriteria) {
+            getFetchStrategies() >> [author: FetchType.EAGER, tags: FetchType.LAZY]
+            getJoinTypes() >> [author: JoinType.LEFT]
+            getCriteria() >> [criterion]
+            getProjections() >> [projection]
+            getOrders() >> [order]
+        }
+        Query.ProjectionList projectionList = Mock(Query.ProjectionList)
+        query.projections() >> projectionList
+
+        when:
+        DynamicFinder.applyDetachedCriteria(query, detachedCriteria)
+
+        then:
+        1 * query.join('author', JoinType.LEFT)
+        1 * query.select('tags')
+        1 * query.add(criterion)
+        1 * projectionList.add(projection)
+        1 * query.order(order)
+    }
+
+    void "applyDetachedCriteria joins without an explicit JoinType when none is configured for the association"() {
+        given:
+        Query query = Mock(Query)
+        AbstractDetachedCriteria detachedCriteria = Stub(AbstractDetachedCriteria) {
+            getFetchStrategies() >> [author: FetchType.EAGER]
+            getJoinTypes() >> [:]
+            getCriteria() >> []
+            getProjections() >> []
+            getOrders() >> []
+        }
+
+        when:
+        DynamicFinder.applyDetachedCriteria(query, detachedCriteria)
+
+        then:
+        1 * query.join('author')
+        0 * query.join('author', _)
+    }
+
+    void "applyAdditionalCriteria is a no-op for a null closure"() {
+        given:
+        Query query = Mock(Query)
+
+        when:
+        DynamicFinder.applyAdditionalCriteria(query, null)
+
+        then:
+        0 * query._
+    }
+
+    void "applyAdditionalCriteria builds a real CriteriaBuilder from the query and adds the closure's criterion"() {
+        given:
+        PersistentEntity entity = Stub(PersistentEntity) {
+            getJavaClass() >> FinderTestEntity
+            getPropertyByName('name') >> nameProperty
+        }
+        Session session = Stub(Session) {
+            getMappingContext() >> mappingContext
+        }
+        Query query = Mock(Query) {
+            getEntity() >> entity
+            getSession() >> session
+        }
+        mappingContext.getPersistentEntity(FinderTestEntity.name) >> entity
+
+        when:
+        DynamicFinder.applyAdditionalCriteria(query, { eq('name', 'Bob') })
+
+        then:
+        1 * query.add({ Query.Criterion criterion ->
+            criterion instanceof Query.Equals && criterion.property == 'name' && criterion.value == 'Bob'
+        })
+    }
+
+    void "buildQuery creates a query from the session, applies additional criteria, detached criteria, arguments and the junction"() {
+        given:
+        Query query = Mock(Query) {
+            getEntity() >> Stub(PersistentEntity) { getMappingContext() >> mappingContext }
+        }
+        Session session = Stub(Session) {
+            createQuery(FinderTestEntity) >> query
+            getMappingContext() >> mappingContext
+        }
+        DynamicFinderInvocation invocation = findByGrammar.createFinderInvocation(
+                FinderTestEntity, 'findByName', null, ['Bob'] as Object[])
+
+        when:
+        Query result = findByGrammar.buildQuery(invocation, session)
+
+        then:
+        result.is(query)
+        1 * query.add({ it instanceof Query.Conjunction })
+    }
+
+    void "buildQuery consumes the remaining argument map via configureQueryWithArguments"() {
+        given:
+        Query query = Mock(Query) {
+            getEntity() >> Stub(PersistentEntity) { getMappingContext() >> mappingContext }
+        }
+        Session session = Stub(Session) {
+            createQuery(FinderTestEntity) >> query
+            getMappingContext() >> mappingContext
+        }
+        DynamicFinderInvocation invocation = findByGrammar.createFinderInvocation(
+                FinderTestEntity, 'findByName', null, ['Bob', [max: 5]] as Object[])
+
+        when:
+        findByGrammar.buildQuery(invocation, session)
+
+        then:
+        1 * query.max(5)
+    }
+
+    void "buildQuery ignores a remaining argument that is not a Map"() {
+        given:
+        // configureQueryWithArguments only inspects arguments[0] as a criteria Map when it actually
+        // is one - a non-Map remaining argument must fall through exactly like having no remaining
+        // arguments at all.
+        Query query = Mock(Query) {
+            getEntity() >> Stub(PersistentEntity) { getMappingContext() >> mappingContext }
+        }
+        Session session = Stub(Session) {
+            createQuery(FinderTestEntity) >> query
+            getMappingContext() >> mappingContext
+        }
+        DynamicFinderInvocation invocation = findByGrammar.createFinderInvocation(
+                FinderTestEntity, 'findByName', null, ['Bob', 'unexpectedExtra'] as Object[])
+
+        when:
+        findByGrammar.buildQuery(invocation, session)
+
+        then:
+        0 * query.max(_)
+        0 * query.offset(_)
     }
 }
diff --git a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/FindOrSaveByDynamicFinderSpec.groovy b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/FindOrSaveByDynamicFinderSpec.groovy
new file mode 100644
index 0000000000..fefbe2913a
--- /dev/null
+++ b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/FindOrSaveByDynamicFinderSpec.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.finders
+
+import grails.gorm.annotation.Entity
+import org.grails.datastore.mapping.simple.SimpleMapDatastore
+import spock.lang.AutoCleanup
+import spock.lang.Specification
+
+/**
+ * Drives {@code findOrSaveBy*} through a {@code SimpleMapDatastore}-backed entity: the composed
+ * {@link SingleResultFinder#findOrSaveBy} shares its construct-on-miss logic with
+ * {@code findOrCreateBy*} and differs only in saving the constructed instance.
+ */
+class FindOrSaveByDynamicFinderSpec extends Specification {
+
+    @AutoCleanup
+    SimpleMapDatastore datastore = new SimpleMapDatastore(FindOrSaveByThing)
+
+    void "findOrSaveBy creates and persists a new instance when no match exists"() {
+        when:
+        def result = FindOrSaveByThing.findOrSaveByTitle('brand new')
+
+        then: "the new instance is actually saved (gets an id), unlike findOrCreateBy which only\n" +
+                "constructs it in memory"
+        result != null
+        result.title == 'brand new'
+        result.id != null
+    }
+
+    void "findOrSaveBy returns the existing match without creating a duplicate"() {
+        given:
+        def existing = FindOrSaveByThing.newInstance(title: 'already here').save(flush: true)
+
+        when:
+        def result = FindOrSaveByThing.findOrSaveByTitle('already here')
+
+        then:
+        result.title == 'already here'
+        result.id == existing.id
+    }
+}
+
+@Entity
+class FindOrSaveByThing {
+    String title
+}
diff --git a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/FindOrSaveByFinderSpec.groovy b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/FindOrSaveByFinderSpec.groovy
deleted file mode 100644
index 731d8ee9ba..0000000000
--- a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/FindOrSaveByFinderSpec.groovy
+++ /dev/null
@@ -1,88 +0,0 @@
-/*
- *  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.finders
-
-import grails.gorm.annotation.Entity
-import org.grails.datastore.gorm.DatastoreResolver
-import org.grails.datastore.mapping.simple.SimpleMapDatastore
-import spock.lang.AutoCleanup
-import spock.lang.Specification
-
-/**
- * The diff simplified this class drastically - dropped a whole bespoke {@code doInvokeInternal}
- * override (~25 lines of manual property-map construction and constructor invocation via
- * {@code MetaClass}) in favour of just overriding {@code shouldSaveOnCreate()} on the shared
- * {@code FindOrCreateByFinder} base, plus added 3 new constructor overloads
- * ({@code (String, Datastore)}, {@code (String, DatastoreResolver, MappingContext)},
- * {@code (MappingContext)}) matching sibling finder classes' shape. The 2-arg
- * {@code (DatastoreResolver, MappingContext)} and single-arg {@code (Datastore)} constructors are
- * the ones `DefaultGormApiFactory`/`GormEnhancer` actually register in production and were already
- * covered incidentally; the 3 new overloads have no callers anywhere in the codebase.
- */
-class FindOrSaveByFinderSpec extends Specification {
-
-    @AutoCleanup
-    SimpleMapDatastore datastore = new SimpleMapDatastore(FindOrSaveByFinderThing)
-
-    void "findOrSaveBy creates and persists a new instance when no match exists"() {
-        when:
-        def result = FindOrSaveByFinderThing.findOrSaveByTitle('brand new')
-
-        then: "shouldSaveOnCreate()==true means the new instance is actually saved (gets an id),\n" +
-                "unlike findOrCreateBy which only constructs it in memory"
-        result != null
-        result.title == 'brand new'
-        result.id != null
-    }
-
-    void "findOrSaveBy returns the existing match without creating a duplicate"() {
-        given:
-        def existing = FindOrSaveByFinderThing.newInstance(title: 'already here').save(flush: true)
-
-        when:
-        def result = FindOrSaveByFinderThing.findOrSaveByTitle('already here')
-
-        then:
-        result.title == 'already here'
-        result.id == existing.id
-    }
-
-    void "the (String, Datastore) constructor is usable directly"() {
-        expect:
-        new FindOrSaveByFinder(FindOrSaveByFinder.METHOD_PATTERN, datastore) != null
-    }
-
-    void "the (String, DatastoreResolver, MappingContext) constructor is usable directly"() {
-        given:
-        def resolver = { datastore } as DatastoreResolver
-
-        expect:
-        new FindOrSaveByFinder(FindOrSaveByFinder.METHOD_PATTERN, resolver, datastore.mappingContext) != null
-    }
-
-    void "the (MappingContext) constructor is usable directly"() {
-        expect:
-        new FindOrSaveByFinder(datastore.mappingContext) != null
-    }
-}
-
-@Entity
-class FindOrSaveByFinderThing {
-    String title
-}
diff --git a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/FinderSupportSpec.groovy b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/FinderSupportSpec.groovy
new file mode 100644
index 0000000000..92ef168ee3
--- /dev/null
+++ b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/FinderSupportSpec.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.finders
+
+import org.grails.datastore.gorm.DatastoreResolver
+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.VoidSessionCallback
+import spock.lang.Specification
+
+/**
+ * Exercises {@link FinderSupport} - the shared session-execution helper every synchronous finder
+ * in this package calls with its own {@code DatastoreResolver} field, rather than inheriting an
+ * {@code execute} method. The datastore is resolved on every call, so a lazy resolver is honoured.
+ */
+class FinderSupportSpec extends Specification {
+
+    void "resolverFor wraps a datastore in a resolver that always returns it"() {
+        given:
+        Datastore datastore = Stub(Datastore)
+
+        expect:
+        FinderSupport.resolverFor(datastore).resolve().is(datastore)
+    }
+
+    void "resolverFor returns null for a null datastore, preserving stateless mode"() {
+        expect:
+        FinderSupport.resolverFor(null) == null
+    }
+
+    void "execute(SessionCallback) throws IllegalStateException when the resolver is null"() {
+        given:
+        SessionCallback callback = { Session session -> 'result' } as SessionCallback
+
+        when:
+        FinderSupport.execute(null, callback)
+
+        then:
+        IllegalStateException e = thrown()
+        e.message == 'Cannot execute session query with null datastore'
+    }
+
+    void "execute(SessionCallback) throws IllegalStateException when the resolver resolves no datastore"() {
+        given:
+        DatastoreResolver resolver = Mock(DatastoreResolver)
+        SessionCallback callback = { Session session -> 'result' } as SessionCallback
+
+        when:
+        FinderSupport.execute(resolver, callback)
+
+        then:
+        IllegalStateException e = thrown()
+        e.message == 'Cannot execute session query with null datastore'
+    }
+
+    void "execute(VoidSessionCallback) throws IllegalStateException when the resolver is null"() {
+        given:
+        VoidSessionCallback callback = { Session session -> } as VoidSessionCallback
+
+        when:
+        FinderSupport.execute(null, callback)
+
+        then:
+        IllegalStateException e = thrown()
+        e.message == 'Cannot execute session query with null datastore'
+    }
+
+    void "execute(SessionCallback) resolves the datastore on every call and runs the callback in its session"() {
+        given:
+        Session session = Stub(Session)
+        Datastore datastore = Stub(Datastore) {
+            hasCurrentSession() >> true
+            getCurrentSession() >> session
+        }
+        DatastoreResolver resolver = Mock(DatastoreResolver)
+        SessionCallback callback = { Session s -> s } as SessionCallback
+
+        when:
+        def first = FinderSupport.execute(resolver, callback)
+        def second = FinderSupport.execute(resolver, callback)
+
+        then:
+        2 * resolver.resolve() >> datastore
+        first.is(session)
+        second.is(session)
+    }
+
+    void "execute(VoidSessionCallback) resolves the datastore and runs the callback in its session"() {
+        given:
+        Session session = Stub(Session)
+        Datastore datastore = Stub(Datastore) {
+            hasCurrentSession() >> true
+            getCurrentSession() >> session
+        }
+        DatastoreResolver resolver = Mock(DatastoreResolver)
+        List<Session> seen = []
+        VoidSessionCallback callback = { Session s -> seen << s } as VoidSessionCallback
+
+        when:
+        FinderSupport.execute(resolver, callback)
+
+        then:
+        1 * resolver.resolve() >> datastore
+        seen == [session]
+    }
+}
diff --git a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/FinderTestEntity.groovy b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/FinderTestEntity.groovy
new file mode 100644
index 0000000000..7c35f3e699
--- /dev/null
+++ b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/FinderTestEntity.groovy
@@ -0,0 +1,49 @@
+/*
+ *  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.finders
+
+/**
+ * Shared fixture domain class for unit-testing the {@code finders} package's classes
+ * (MethodExpression, DynamicFinder and its subclasses) against a real {@code PersistentEntity}
+ * shape rather than a fully mocked one. Not persisted anywhere - the finders package only
+ * translates method calls into {@code Query.Criterion}/{@code Query} objects, it never touches
+ * a real datastore itself.
+ */
+class FinderTestEntity {
+    Long id
+    String name
+    String title
+    String author
+    Integer age
+    BigDecimal price
+    Boolean active
+    List tags
+
+    // Not a real GORM enhancement - FinderTestEntity is a plain Groovy class, never registered
+    // with a datastore. This stand-in exists purely so FindOrSaveByFinderSpec can observe that
+    // FindOrSaveByFinder actually invokes save() on the instance it constructs (via
+    // metaClass.invokeMethod(result, "save", null)), which would otherwise fail with a
+    // MissingMethodException against an unenhanced class.
+    boolean saved = false
+
+    def save(args = null) {
+        saved = true
+        this
+    }
+}
diff --git a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/ListOrderByFinderSpec.groovy b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/ListOrderByFinderSpec.groovy
new file mode 100644
index 0000000000..7560eb3684
--- /dev/null
+++ b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/ListOrderByFinderSpec.groovy
@@ -0,0 +1,210 @@
+/*
+ *  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.finders
+
+import org.grails.datastore.gorm.DatastoreResolver
+import org.grails.datastore.mapping.core.Datastore
+import org.grails.datastore.mapping.core.Session
+import org.grails.datastore.mapping.model.MappingContext
+import org.grails.datastore.mapping.model.PersistentEntity
+import org.grails.datastore.mapping.model.PersistentProperty
+import org.grails.datastore.mapping.query.Query
+import org.springframework.core.convert.support.DefaultConversionService
+import spock.lang.Specification
+import spock.lang.Unroll
+
+/**
+ * Exercises ListOrderByFinder - unlike every other finder in this package, it never shared
+ * {@link DynamicFinder}'s grammar, with its own regex matching and its own {@code invoke()}
+ * method. Collaborators are wired using the same Datastore/Session/Query mock idiom as elsewhere
+ * in this package: the "existing bound session" branch of {@code DatastoreUtils.execute}
+ * (Datastore.hasCurrentSession() == true) is the simplest reliable seam for exercising the real
+ * {@code invoke()} round trip.
+ */
+class ListOrderByFinderSpec extends Specification {
+
+    MappingContext mappingContext = Stub(MappingContext) {
+        getConversionService() >> new DefaultConversionService()
+    }
+    PersistentEntity persistentEntity = Stub(PersistentEntity) {
+        getMappingContext() >> mappingContext
+        getPropertyByName('age') >> Stub(PersistentProperty) { getName() >> 'age' }
+    }
+    Query query = Mock(Query) {
+        getEntity() >> persistentEntity
+    }
+    Session session = Stub(Session) {
+        createQuery(FinderTestEntity) >> query
+    }
+    Datastore datastore = Stub(Datastore) {
+        hasCurrentSession() >> true
+        getCurrentSession() >> session
+    }
+    ListOrderByFinder finder = new ListOrderByFinder(datastore)
+
+    @Unroll
+    void "isMethodMatch('#methodName') == #matches"() {
+        expect:
+        finder.isMethodMatch(methodName) == matches
+
+        where:
+        methodName               | matches
+        'listOrderByName'        | true
+        'listOrderByNameAndAge'  | true
+        'somethingElse'          | false
+    }
+
+    void "setPattern replaces the method-name pattern"() {
+        when:
+        finder.setPattern('(customOrderBy)(\\w+)')
+
+        then:
+        finder.isMethodMatch('customOrderByName')
+        !finder.isMethodMatch('listOrderByName')
+    }
+
+    void "invoke defaults to ascending order when no Map argument is supplied and lists the results without a distinct projection"() {
+        when:
+        finder.invoke(FinderTestEntity, 'listOrderByAge', [] as Object[])
+
+        then:
+        1 * query.order({ Query.Order order -> order.property == 'age' && order.direction == Query.Order.Direction.ASC })
+        0 * query.projections()
+        1 * query.list()
+        0 * query.singleResult()
+    }
+
+    void "invoke orders by every And-separated property in the method name, in order"() {
+        when:
+        finder.invoke(FinderTestEntity, 'listOrderByNameAndAge', [] as Object[])
+
+        then:
+        1 * query.order({ Query.Order order -> order.property == 'name' && order.direction == Query.Order.Direction.ASC })
+
+        then:
+        1 * query.order({ Query.Order order -> order.property == 'age' && order.direction == Query.Order.Direction.ASC })
+        1 * query.list()
+    }
+
+    void "invoke lower-cases only the first character of each property segment"() {
+        when:
+        finder.invoke(FinderTestEntity, 'listOrderByISize', [] as Object[])
+
+        then:
+        1 * query.order({ Query.Order order -> order.property == 'iSize' })
+        1 * query.list()
+    }
+
+    void "invoke ignores a non-Map first argument, defaulting to ascending order without delegating to DynamicFinder.populateArgumentsForCriteria"() {
+        when:
+        finder.invoke(FinderTestEntity, 'listOrderByAge', ['not a map'] as Object[])
+
+        then:
+        1 * query.order({ Query.Order order -> order.property == 'age' && order.direction == Query.Order.Direction.ASC })
+        0 * query.max(_)
+        1 * query.list()
+    }
+
+    @Unroll
+    void "invoke stays ascending when the Map's order entry is '#order'"() {
+        when:
+        finder.invoke(FinderTestEntity, 'listOrderByAge', [order != null ? [order: order] : [:]] as Object[])
+
+        then:
+        1 * query.order({ Query.Order o -> o.property == 'age' && o.direction == Query.Order.Direction.ASC })
+        1 * query.list()
+
+        where:
+        order << [null, 'asc', 'ASC', ' Asc ', '']
+    }
+
+    @Unroll
+    void "invoke flips to descending order for '#order' and delegates the remaining Map entries to DynamicFinder.populateArgumentsForCriteria"() {
+        when:
+        // "max: 5" alongside "order: desc" proves the remaining-arguments Map is actually
+        // delegated to DynamicFinder.populateArgumentsForCriteria (which applies max()), not just
+        // that the order flag itself is honoured.
+        finder.invoke(FinderTestEntity, 'listOrderByAge', [[order: order, max: 5]] as Object[])
+
+        then:
+        1 * query.order({ Query.Order o -> o.property == 'age' && o.direction == Query.Order.Direction.DESC })
+        1 * query.max(5)
+        1 * query.list()
+
+        where:
+        order << ['desc', 'DESC', ' Desc ']
+    }
+
+    @Unroll
+    void "invoke rejects the order direction '#order' without echoing it and before touching the query"() {
+        when:
+        finder.invoke(FinderTestEntity, 'listOrderByAge', [[order: order]] as Object[])
+
+        then:
+        IllegalArgumentException e = thrown()
+        e.message == 'Invalid sort direction'
+        0 * query.order(_)
+        0 * query.list()
+
+        where:
+        order << ['sideways', 'desc extra']
+    }
+
+    void "invoke applies an additional criteria closure to the query"() {
+        given:
+        Closure additionalCriteria = { -> }
+        query.getSession() >> session
+        persistentEntity.getJavaClass() >> FinderTestEntity
+        session.getMappingContext() >> mappingContext
+
+        when:
+        finder.invoke(FinderTestEntity, 'listOrderByAge', additionalCriteria, [] as Object[])
+
+        then:
+        1 * query.getSession() >> session
+        1 * query.list()
+    }
+
+    void "invoke resolves its datastore through the DatastoreResolver on every invocation, not at construction"() {
+        given:
+        DatastoreResolver resolver = Mock(DatastoreResolver)
+
+        when:
+        ListOrderByFinder resolved = new ListOrderByFinder(resolver, mappingContext)
+
+        then:
+        0 * resolver.resolve()
+
+        when:
+        resolved.invoke(FinderTestEntity, 'listOrderByAge', [] as Object[])
+
+        then:
+        1 * resolver.resolve() >> datastore
+        1 * query.list()
+    }
+
+    void "invoke throws IllegalStateException when constructed with a null datastore"() {
+        when:
+        new ListOrderByFinder((Datastore) null).invoke(FinderTestEntity, 'listOrderByAge', [] as Object[])
+
+        then:
+        IllegalStateException e = thrown()
+        e.message == 'Cannot execute session query with null datastore'
+    }
+}
diff --git a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/ListResultFinderSpec.groovy b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/ListResultFinderSpec.groovy
new file mode 100644
index 0000000000..75642b4c28
--- /dev/null
+++ b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/ListResultFinderSpec.groovy
@@ -0,0 +1,222 @@
+/*
+ *  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.finders
+
+import org.grails.datastore.gorm.DatastoreResolver
+import org.grails.datastore.mapping.core.Datastore
+import org.grails.datastore.mapping.core.Session
+import org.grails.datastore.mapping.model.MappingContext
+import org.grails.datastore.mapping.model.PersistentEntity
+import org.grails.datastore.mapping.query.Query
+import org.springframework.core.convert.support.DefaultConversionService
+import spock.lang.Specification
+import spock.lang.Unroll
+
+/**
+ * Exercises {@link ListResultFinder} - the composed replacement for the deleted
+ * FindAllByFinder/FindAllByBooleanFinder pair.
+ */
+class ListResultFinderSpec extends Specification {
+
+    MappingContext mappingContext = Stub(MappingContext)
+    PersistentEntity persistentEntity = Stub(PersistentEntity)
+    Datastore datastore = Stub(Datastore) {
+        getMappingContext() >> mappingContext
+    }
+
+    void setup() {
+        mappingContext.getConversionService() >> new DefaultConversionService()
+        mappingContext.getPersistentEntity(FinderTestEntity.name) >> persistentEntity
+        persistentEntity.getMappingContext() >> mappingContext
+        persistentEntity.getPropertyByName('name') >> Stub(org.grails.datastore.mapping.model.PersistentProperty) {
+            getName() >> 'name'
+            getType() >> String
+        }
+    }
+
+    @Unroll
+    void "findAllBy isMethodMatch('#methodName') == #matches"() {
+        expect:
+        ListResultFinder.findAllBy(datastore).isMethodMatch(methodName) == matches
+
+        where:
+        methodName            | matches
+        'findAllByName'       | true
+        'findAllByNameAndAge' | true
+        'somethingElse'       | false
+    }
+
+    @Unroll
+    void "findAllByBoolean isMethodMatch('#methodName') == #matches"() {
+        expect:
+        ListResultFinder.findAllByBoolean(datastore).isMethodMatch(methodName) == matches
+
+        where:
+        methodName            | matches
+        'findAllActiveByName' | true
+        'findAllActive'       | true
+        'somethingElse'       | false
+    }
+
+    void "setPattern delegates to the grammar"() {
+        given:
+        ListResultFinder finder = ListResultFinder.findAllBy(datastore)
+
+        expect:
+        finder.isMethodMatch('findAllByName')
+        !finder.isMethodMatch('customPrefixName')
+
+        when:
+        finder.setPattern('(customPrefix)([A-Z]\\w*)')
+
+        then:
+        finder.isMethodMatch('customPrefixName')
+        !finder.isMethodMatch('findAllByName')
+    }
+
+    void "findAllBy runs the full round trip without a distinct projection and returns the full list"() {
+        given:
+        List<FinderTestEntity> expected = [new FinderTestEntity(name: 'Bob')]
+        Query query = Mock(Query) {
+            getEntity() >> persistentEntity
+        }
+        Session session = Stub(Session) {
+            createQuery(FinderTestEntity) >> query
+        }
+        datastore.hasCurrentSession() >> true
+        datastore.getCurrentSession() >> session
+
+        when:
+        Object result = ListResultFinder.findAllBy(datastore).invoke(FinderTestEntity, 'findAllByName', ['Bob'] as Object[])
+
+        then:
+        result.is(expected)
+        0 * query.projections()
+        1 * query.list() >> expected
+        0 * query.singleResult()
+    }
+
+    void "invoke(Class, methodName, DetachedCriteria, Object[]) merges the detached criteria onto the built query"() {
+        given:
+        // Called reflectively (dynamic Groovy dispatch, not part of FinderMethod) by
+        // AbstractDetachedCriteria#methodMissing.
+        List<FinderTestEntity> expected = [new FinderTestEntity(name: 'Bob')]
+        Query query = Mock(Query) {
+            getEntity() >> persistentEntity
+        }
+        Session session = Stub(Session) {
+            createQuery(FinderTestEntity) >> query
+        }
+        datastore.hasCurrentSession() >> true
+        datastore.getCurrentSession() >> session
+        grails.gorm.DetachedCriteria detachedCriteria = Stub(grails.gorm.DetachedCriteria) {
+            getFetchStrategies() >> [:]
+            getCriteria() >> [org.grails.datastore.mapping.query.Restrictions.eq('age', 42)]
+            getProjections() >> []
+            getOrders() >> []
+        }
+
+        when:
+        Object result = ListResultFinder.findAllBy(datastore).invoke(FinderTestEntity, 'findAllByName', detachedCriteria, ['Bob'] as Object[])
+
+        then:
+        result.is(expected)
+        1 * query.add({ it instanceof Query.PropertyCriterion })
+        0 * query.projections()
+        1 * query.list() >> expected
+    }
+
+    void "invoke(Class, methodName, DetachedCriteria, Object[]) with a null detachedCriteria never merges anything onto the built query"() {
+        given:
+        List<FinderTestEntity> expected = [new FinderTestEntity(name: 'Bob')]
+        Query query = Mock(Query) {
+            getEntity() >> persistentEntity
+        }
+        Session session = Stub(Session) {
+            createQuery(FinderTestEntity) >> query
+        }
+        datastore.hasCurrentSession() >> true
+        datastore.getCurrentSession() >> session
+
+        when:
+        Object result = ListResultFinder.findAllBy(datastore).invoke(FinderTestEntity, 'findAllByName', (grails.gorm.DetachedCriteria) null, ['Bob'] as Object[])
+
+        then:
+        result.is(expected)
+        0 * query.add({ it instanceof Query.PropertyCriterion })
+        0 * query.projections()
+        1 * query.list() >> expected
+    }
+
+    void "invoke throws IllegalStateException when constructed in stateless mode"() {
+        when:
+        ListResultFinder.findAllBy(mappingContext).invoke(FinderTestEntity, 'findAllByName', ['Bob'] as Object[])
+
+        then:
+        thrown(IllegalStateException)
+    }
+
+    void "findAllByBoolean invoke throws IllegalStateException when constructed in stateless mode"() {
+        when:
+        ListResultFinder.findAllByBoolean(mappingContext).invoke(FinderTestEntity, 'findAllActiveByName', ['Bob'] as Object[])
+
+        then:
+        thrown(IllegalStateException)
+    }
+    void "findAllBy resolves its datastore through the DatastoreResolver on every invocation, not at construction"() {
+        given:
+        Query query = Mock(Query) {
+            getEntity() >> persistentEntity
+            projections() >> Mock(Query.ProjectionList)
+        }
+        Session session = Stub(Session) {
+            createQuery(FinderTestEntity) >> query
+        }
+        Datastore lateBoundDatastore = Stub(Datastore) {
+            hasCurrentSession() >> true
+            getCurrentSession() >> session
+        }
+        DatastoreResolver resolver = Mock(DatastoreResolver)
+
+        when:
+        def finder = ListResultFinder.findAllBy(resolver, mappingContext)
+
+        then:
+        0 * resolver.resolve()
+
+        when:
+        finder.invoke(FinderTestEntity, 'findAllByName', ['Bob'] as Object[])
+
+        then:
+        1 * resolver.resolve() >> lateBoundDatastore
+        1 * query.list() >> []
+    }
+
+    void "findAllBy throws IllegalStateException when the DatastoreResolver resolves no datastore"() {
+        given:
+        DatastoreResolver resolver = Mock(DatastoreResolver)
+
+        when:
+        ListResultFinder.findAllBy(resolver, mappingContext).invoke(FinderTestEntity, 'findAllByName', ['Bob'] as Object[])
+
+        then:
+        IllegalStateException e = thrown()
+        e.message == 'Cannot execute session query with null datastore'
+    }
+}
diff --git a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/MethodExpressionSpec.groovy b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/MethodExpressionSpec.groovy
new file mode 100644
index 0000000000..1343e5a6ec
--- /dev/null
+++ b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/MethodExpressionSpec.groovy
@@ -0,0 +1,574 @@
+/*
+ *  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.finders
+
+import org.grails.datastore.mapping.model.MappingContext
+import org.grails.datastore.mapping.model.PersistentEntity
+import org.grails.datastore.mapping.model.PersistentProperty
+import org.grails.datastore.mapping.query.Query
+import org.springframework.core.convert.support.DefaultConversionService
+import spock.lang.Specification
+import spock.lang.Unroll
+
+/**
+ * Exercises every {@link MethodExpression} nested operator class - the DSL surface that dynamic
+ * finder method-name suffixes (GreaterThan, Like, InList, Between, ...) get parsed into by
+ * {@link DynamicFinder}. Collaborators (PersistentEntity/MappingContext) are stubbed with a real
+ * Spring {@link DefaultConversionService} since {@code convertArguments}' coercion behavior
+ * (GString -> String, collection/array element skip, delegated ConversionService conversion) is
+ * itself part of what several of these tests verify.
+ */
+class MethodExpressionSpec extends Specification {
+
+    PersistentProperty idProperty = Stub(PersistentProperty) {
+        getName() >> 'id'
+        getType() >> Long
+    }
+    PersistentProperty nameProperty = Stub(PersistentProperty) {
+        getName() >> 'name'
+        getType() >> String
+    }
+    PersistentProperty ageProperty = Stub(PersistentProperty) {
+        getName() >> 'age'
+        getType() >> Integer
+    }
+    PersistentProperty tagsProperty = Stub(PersistentProperty) {
+        getName() >> 'tags'
+        getType() >> List
+    }
+    MappingContext mappingContext = Stub(MappingContext) {
+        getConversionService() >> new DefaultConversionService()
+    }
+    PersistentEntity persistentEntity = Stub(PersistentEntity) {
+        getIdentity() >> idProperty
+        getMappingContext() >> mappingContext
+        getPropertyByName('name') >> nameProperty
+        getPropertyByName('age') >> ageProperty
+        getPropertyByName('tags') >> tagsProperty
+        getPropertyByName('id') >> null
+        getPropertyByName('missing') >> null
+    }
+
+    @Unroll
+    void "#expressionType.simpleName createCriterion builds the matching Query.Criterion"() {
+        given:
+        MethodExpression expression = expressionType.getConstructor(String).newInstance('name')
+        expression.setArguments(['Bob'] as Object[])
+
+        when:
+        Query.Criterion criterion = expression.createCriterion()
+
+        then:
+        criterionType.isInstance(criterion)
+        criterion.property == 'name'
+        criterion.value == 'Bob'
+
+        where:
+        expressionType             | criterionType
+        MethodExpression.GreaterThan       | Query.GreaterThan
+        MethodExpression.GreaterThanEquals | Query.GreaterThanEquals
+        MethodExpression.LessThan          | Query.LessThan
+        MethodExpression.LessThanEquals    | Query.LessThanEquals
+    }
+
+    @Unroll
+    void "#expressionType.simpleName createCriterion builds the matching pattern criterion"() {
+        given:
+        MethodExpression expression = expressionType.getConstructor(String).newInstance('name')
+        expression.setArguments(['Bo%'] as Object[])
+
+        when:
+        Query.Criterion criterion = expression.createCriterion()
+
+        then:
+        criterionType.isInstance(criterion)
+        criterion.property == 'name'
+        criterion.pattern == 'Bo%'
+
+        where:
+        expressionType     | criterionType
+        MethodExpression.Like  | Query.Like
+        MethodExpression.Ilike | Query.ILike
+        MethodExpression.Rlike | Query.RLike
+    }
+
+    void "Like coerces a non-String argument via toString()"() {
+        given:
+        MethodExpression expression = new MethodExpression.Like('age')
+        expression.setArguments([42] as Object[])
+
+        expect:
+        expression.createCriterion().pattern == '42'
+    }
+
+    void "Equal builds an Equals criterion when the argument is non-null"() {
+        given:
+        MethodExpression expression = new MethodExpression.Equal('name')
+        expression.setArguments(['Bob'] as Object[])
+
+        when:
+        Query.Criterion criterion = expression.createCriterion()
+
+        then:
+        criterion instanceof Query.Equals
+        criterion.property == 'name'
+        criterion.value == 'Bob'
+    }
+
+    void "Equal falls back to an IsNull criterion when the argument is null"() {
+        given:
+        MethodExpression expression = new MethodExpression.Equal('name')
+        expression.setArguments([null] as Object[])
+
+        when:
+        Query.Criterion criterion = expression.createCriterion()
+
+        then:
+        criterion instanceof Query.IsNull
+        criterion.property == 'name'
+    }
+
+    void "NotEqual builds a NotEquals criterion when the argument is non-null"() {
+        given:
+        MethodExpression expression = new MethodExpression.NotEqual('name')
+        expression.setArguments(['Bob'] as Object[])
+
+        when:
+        Query.Criterion criterion = expression.createCriterion()
+
+        then:
+        criterion instanceof Query.NotEquals
+        criterion.property == 'name'
+        criterion.value == 'Bob'
+    }
+
+    void "NotEqual falls back to an IsNotNull criterion when the argument is null"() {
+        given:
+        MethodExpression expression = new MethodExpression.NotEqual('name')
+        expression.setArguments([null] as Object[])
+
+        when:
+        Query.Criterion criterion = expression.createCriterion()
+
+        then:
+        criterion instanceof Query.IsNotNull
+        criterion.property == 'name'
+    }
+
+    @Unroll
+    void "#expressionType.simpleName requires zero arguments and builds a bare property criterion"() {
+        given:
+        MethodExpression expression = expressionType.getConstructor(String).newInstance('name')
+
+        expect:
+        expression.argumentsRequired == 0
+
+        when:
+        Query.Criterion criterion = expression.createCriterion()
+
+        then:
+        criterionType.isInstance(criterion)
+        criterion.property == 'name'
+
+        where:
+        expressionType             | criterionType
+        MethodExpression.IsNull        | Query.IsNull
+        MethodExpression.IsNotNull     | Query.IsNotNull
+        MethodExpression.IsEmpty       | Query.IsEmpty
+        MethodExpression.IsNotEmpty    | Query.IsNotEmpty
+    }
+
+    void "InList builds an In criterion from a Collection argument"() {
+        given:
+        MethodExpression expression = new MethodExpression.InList('name')
+        expression.setArguments([['a', 'b']] as Object[])
+
+        when:
+        Query.Criterion criterion = expression.createCriterion()
+
+        then:
+        criterion instanceof Query.In
+        criterion.property == 'name'
+        // Query.In.getValues() returns Collections.unmodifiableCollection(), which - unlike
+        // unmodifiableList()/unmodifiableSet() - does not delegate equals(), so it must be
+        // materialized before comparing.
+        criterion.values.toList() == ['a', 'b']
+    }
+
+    void "InList setArguments rejects a non-Collection, non-null argument"() {
+        given:
+        MethodExpression expression = new MethodExpression.InList('name')
+
+        when:
+        expression.setArguments(['not-a-collection'] as Object[])
+
+        then:
+        thrown(IllegalArgumentException)
+    }
+
+    void "InList setArguments accepts a null argument"() {
+        given:
+        MethodExpression expression = new MethodExpression.InList('name')
+
+        when:
+        expression.setArguments([null] as Object[])
+
+        then:
+        noExceptionThrown()
+    }
+
+    void "InList setArguments rejects an empty argument array"() {
+        given:
+        MethodExpression expression = new MethodExpression.InList('name')
+
+        when:
+        expression.setArguments([] as Object[])
+
+        then:
+        thrown(IllegalArgumentException)
+    }
+
+    void "InList convertArguments converts each collection element individually"() {
+        given:
+        MethodExpression expression = new MethodExpression.InList('age')
+        expression.setArguments([['1', '2']] as Object[])
+
+        when:
+        expression.convertArguments(persistentEntity)
+
+        then:
+        expression.getArguments()[0] == [1, 2]
+    }
+
+    void "InList convertArguments converts a null collection into an empty list"() {
+        given:
+        MethodExpression expression = new MethodExpression.InList('age')
+        expression.setArguments([null] as Object[])
+
+        when:
+        expression.convertArguments(persistentEntity)
+
+        then:
+        expression.getArguments()[0] == []
+    }
+
+    void "InList convertArguments is a no-op when the property cannot be resolved"() {
+        given:
+        MethodExpression expression = new MethodExpression.InList('missing')
+        expression.setArguments([['1', '2']] as Object[])
+
+        when:
+        expression.convertArguments(persistentEntity)
+
+        then:
+        expression.getArguments()[0] == ['1', '2']
+    }
+
+    void "NotInList wraps an In criterion in a Negation"() {
+        given:
+        MethodExpression expression = new MethodExpression.NotInList('name')
+        expression.setArguments([['a', 'b']] as Object[])
+
+        when:
+        Query.Criterion criterion = expression.createCriterion()
+
+        then:
+        criterion instanceof Query.Negation
+        Query.Negation negation = (Query.Negation) criterion
+        negation.criteria.size() == 1
+        Query.In wrapped = negation.criteria[0]
+        wrapped.property == 'name'
+        wrapped.values.toList() == ['a', 'b']
+    }
+
+    void "NotInList setArguments applies the same Collection-or-null validation as InList"() {
+        given:
+        MethodExpression expression = new MethodExpression.NotInList('name')
+
+        when:
+        expression.setArguments(['not-a-collection'] as Object[])
+
+        then:
+        thrown(IllegalArgumentException)
+    }
+
+    void "NotInList convertArguments converts each collection element individually"() {
+        given:
+        MethodExpression expression = new MethodExpression.NotInList('age')
+        expression.setArguments([['1', '2']] as Object[])
+
+        when:
+        expression.convertArguments(persistentEntity)
+
+        then:
+        expression.getArguments()[0] == [1, 2]
+    }
+
+    void "Between requires two arguments"() {
+        given:
+        MethodExpression expression = new MethodExpression.Between('age')
+
+        expect:
+        expression.argumentsRequired == 2
+    }
+
+    void "Between builds a Between criterion from two Comparable arguments"() {
+        given:
+        MethodExpression expression = new MethodExpression.Between('age')
+        expression.setArguments([1, 10] as Object[])
+
+        when:
+        Query.Criterion criterion = expression.createCriterion()
+
+        then:
+        criterion instanceof Query.Between
+        criterion.property == 'age'
+        criterion.from == 1
+        criterion.to == 10
+    }
+
+    void "Between setArguments rejects fewer than two arguments"() {
+        given:
+        MethodExpression expression = new MethodExpression.Between('age')
+
+        when:
+        expression.setArguments([1] as Object[])
+
+        then:
+        thrown(IllegalArgumentException)
+    }
+
+    @Unroll
+    void "Between setArguments rejects a non-Comparable argument (#description)"() {
+        given:
+        MethodExpression expression = new MethodExpression.Between('age')
+
+        when:
+        expression.setArguments([first, second] as Object[])
+
+        then:
+        thrown(IllegalArgumentException)
+
+        where:
+        description       | first             | second
+        'non-Comparable first argument'  | new Object() | 10
+        'non-Comparable second argument' | 1            | new Object()
+    }
+
+    void "InRange requires exactly one argument"() {
+        given:
+        MethodExpression expression = new MethodExpression.InRange('age')
+
+        expect:
+        expression.argumentsRequired == 1
+    }
+
+    void "InRange builds a Between criterion from a Range's from/to bounds"() {
+        given:
+        MethodExpression expression = new MethodExpression.InRange('age')
+        expression.setArguments([1..10] as Object[])
+
+        when:
+        Query.Criterion criterion = expression.createCriterion()
+
+        then:
+        criterion instanceof Query.Between
+        criterion.property == 'age'
+        criterion.from == 1
+        criterion.to == 10
+    }
+
+    void "InRange setArguments rejects a non-Range argument"() {
+        given:
+        MethodExpression expression = new MethodExpression.InRange('age')
+
+        when:
+        expression.setArguments(['not-a-range'] as Object[])
+
+        then:
+        thrown(IllegalArgumentException)
+    }
+
+    void "InRange setArguments rejects more than one argument"() {
+        given:
+        MethodExpression expression = new MethodExpression.InRange('age')
+
+        when:
+        expression.setArguments([1..10, 'extra'] as Object[])
+
+        then:
+        thrown(IllegalArgumentException)
+    }
+
+    void "InRange convertArguments is a no-op, trusting setArguments already validated the Range"() {
+        given:
+        MethodExpression expression = new MethodExpression.InRange('age')
+        def range = 1..10
+        expression.setArguments([range] as Object[])
+
+        when:
+        expression.convertArguments(persistentEntity)
+
+        then:
+        expression.getArguments()[0].is(range)
+    }
+
+    void "convertArguments coerces a GString argument to a real String"() {
+        given:
+        MethodExpression expression = new MethodExpression.Equal('name')
+        def suffix = 'Bo'
+        expression.setArguments(["${suffix}b"] as Object[])
+
+        when:
+        expression.convertArguments(persistentEntity)
+
+        then:
+        expression.getArguments()[0] == 'Bob'
+        expression.getArguments()[0] instanceof String
+    }
+
+    void "convertArguments delegates to the ConversionService for a convertible mismatched type"() {
+        given:
+        MethodExpression expression = new MethodExpression.Equal('age')
+        expression.setArguments(['42'] as Object[])
+
+        when:
+        expression.convertArguments(persistentEntity)
+
+        then:
+        expression.getArguments()[0] == 42
+    }
+
+    void "convertArguments leaves an already-assignable argument untouched"() {
+        given:
+        MethodExpression expression = new MethodExpression.Equal('name')
+        String value = 'Bob'
+        expression.setArguments([value] as Object[])
+
+        when:
+        expression.convertArguments(persistentEntity)
+
+        then:
+        expression.getArguments()[0].is(value)
+    }
+
+    void "convertArguments skips a Collection argument whose elements already match the declared element type"() {
+        given:
+        MethodExpression expression = new MethodExpression.Equal('tags')
+        List value = ['a', 'b']
+        expression.setArguments([value] as Object[])
+
+        when:
+        expression.convertArguments(persistentEntity)
+
+        then:
+        expression.getArguments()[0].is(value)
+    }
+
+    void "convertArguments resolves the identity property when propertyName matches it"() {
+        given:
+        MethodExpression expression = new MethodExpression.Equal('id')
+        expression.setArguments(['42'] as Object[])
+
+        when:
+        expression.convertArguments(persistentEntity)
+
+        then:
+        expression.getArguments()[0] == 42
+    }
+
+    void "InList convertArguments resolves the identity property when propertyName matches it"() {
+        given:
+        // InList/NotInList's convertArguments delegates to the private static
+        // convertArgumentsForProp helper, which has its own COPY of the identity-property fallback
+        // used by the base convertArguments above - a separate branch, separately worth covering.
+        MethodExpression expression = new MethodExpression.InList('id')
+        expression.setArguments([['42', '43']] as Object[])
+
+        when:
+        expression.convertArguments(persistentEntity)
+
+        then:
+        expression.getArguments()[0] == [42L, 43L]
+    }
+
+    void "convertArguments is a no-op when the property cannot be resolved at all"() {
+        given:
+        MethodExpression expression = new MethodExpression.Equal('missing')
+        expression.setArguments(['unchanged'] as Object[])
+
+        when:
+        expression.convertArguments(persistentEntity)
+
+        then:
+        expression.getArguments()[0] == 'unchanged'
+    }
+
+    void "getArguments returns a defensive copy, not the live backing array"() {
+        given:
+        MethodExpression expression = new MethodExpression.Equal('name')
+        expression.setArguments(['Bob'] as Object[])
+
+        when:
+        Object[] copy = expression.getArguments()
+        copy[0] = 'Mutated'
+
+        then:
+        expression.getArguments()[0] == 'Bob'
+    }
+
+    void "getPropertyName returns the property this expression targets"() {
+        expect:
+        new MethodExpression.Equal('name').propertyName == 'name'
+    }
+
+    @Unroll
+    void "#expressionType.simpleName's (Class, String) constructor delegates identically to its (String) constructor"() {
+        given:
+        // DynamicFinder always constructs operators via the (Class, String) overload (the Class
+        // arg feeds the deprecated, unused `targetClass` field) - this confirms every concrete
+        // operator's second constructor sets propertyName the same way the first one does, rather
+        // than only ever exercising the (String)-only overload used elsewhere in this spec.
+        MethodExpression expression = expressionType.getConstructor(Class, String).newInstance(FinderTestEntity, 'name')
+
+        expect:
+        expression.propertyName == 'name'
+        expression.argumentsRequired == argumentsRequired
+
+        where:
+        expressionType                      | argumentsRequired
+        MethodExpression.Equal              | 1
+        MethodExpression.NotEqual           | 1
+        MethodExpression.GreaterThan        | 1
+        MethodExpression.GreaterThanEquals  | 1
+        MethodExpression.LessThan           | 1
+        MethodExpression.LessThanEquals     | 1
+        MethodExpression.Like               | 1
+        MethodExpression.Ilike              | 1
+        MethodExpression.Rlike              | 1
+        MethodExpression.InList             | 1
+        MethodExpression.NotInList          | 1
+        MethodExpression.Between            | 2
+        MethodExpression.InRange            | 1
+        MethodExpression.IsNull             | 0
+        MethodExpression.IsNotNull          | 0
+        MethodExpression.IsEmpty            | 0
+        MethodExpression.IsNotEmpty         | 0
+    }
+}
diff --git a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/SingleResultFinderSpec.groovy b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/SingleResultFinderSpec.groovy
new file mode 100644
index 0000000000..da39076000
--- /dev/null
+++ b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/SingleResultFinderSpec.groovy
@@ -0,0 +1,412 @@
+/*
+ *  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.finders
+
+import groovy.lang.MissingMethodException
+import org.grails.datastore.gorm.DatastoreResolver
+import org.grails.datastore.mapping.core.Datastore
+import org.grails.datastore.mapping.core.Session
+import org.grails.datastore.mapping.core.exceptions.ConfigurationException
+import org.grails.datastore.mapping.model.MappingContext
+import org.grails.datastore.mapping.model.PersistentEntity
+import org.grails.datastore.mapping.model.PersistentProperty
+import org.grails.datastore.mapping.query.Query
+import org.springframework.core.convert.support.DefaultConversionService
+import spock.lang.Specification
+import spock.lang.Unroll
+
+/**
+ * Exercises {@link SingleResultFinder} - the composed replacement for the deleted
+ * AbstractFindByFinder/FindByFinder/FindByBooleanFinder/FindOrCreateByFinder/FindOrSaveByFinder
+ * class hierarchy. Every factory method builds its own {@link DynamicFinder} grammar instance
+ * (never shared across finders, since a shared instance would be a real thread-safety hazard
+ * given finder instances are long-lived and invoked from arbitrary request threads), so each
+ * test constructs the specific factory under test directly rather than reusing a single shared
+ * field.
+ */
+class SingleResultFinderSpec extends Specification {
+
+    PersistentProperty nameProperty = Stub(PersistentProperty) {
+        getName() >> 'name'
+        getType() >> String
+    }
+    PersistentProperty ageProperty = Stub(PersistentProperty) {
+        getName() >> 'age'
+        getType() >> Integer
+    }
+    MappingContext mappingContext = Stub(MappingContext)
+    PersistentEntity persistentEntity = Stub(PersistentEntity)
+    Datastore datastore = Stub(Datastore) {
+        getMappingContext() >> mappingContext
+    }
+
+    // Every property lookup a test's method name touches must be stubbed explicitly: Spock's Stub
+    // "smart null" returns a DUMMY PersistentProperty (not null) for any unstubbed
+    // getPropertyByName(...) call, since PersistentProperty is an interface - that dummy's own
+    // unstubbed getType() then needs a dummy java.lang.Class, which Spock cannot create (final
+    // class), crashing every test that reaches convertArguments for an unstubbed property.
+    void setup() {
+        mappingContext.getConversionService() >> new DefaultConversionService()
+        mappingContext.getPersistentEntity(FinderTestEntity.name) >> persistentEntity
+        persistentEntity.getMappingContext() >> mappingContext
+        persistentEntity.getPropertyByName('name') >> nameProperty
+        persistentEntity.getPropertyByName('age') >> ageProperty
+    }
+
+    @Unroll
+    void "findBy isMethodMatch('#methodName') == #matches"() {
+        expect:
+        SingleResultFinder.findBy(datastore).isMethodMatch(methodName) == matches
+
+        where:
+        methodName         | matches
+        'findByName'       | true
+        'findByNameAndAge' | true
+        'somethingElse'    | false
+    }
+
+    @Unroll
+    void "findByBoolean isMethodMatch('#methodName') == #matches"() {
+        expect:
+        SingleResultFinder.findByBoolean(datastore).isMethodMatch(methodName) == matches
+
+        where:
+        methodName         | matches
+        'findActiveByName' | true
+        'findActive'       | true
+        'somethingElse'    | false
+    }
+
+    void "setPattern delegates to the grammar"() {
+        given:
+        SingleResultFinder finder = SingleResultFinder.findBy(datastore)
+
+        expect:
+        finder.isMethodMatch('findByName')
+        !finder.isMethodMatch('customPrefixName')
+
+        when:
+        finder.setPattern('(customPrefix)([A-Z]\\w*)')
+
+        then:
+        finder.isMethodMatch('customPrefixName')
+        !finder.isMethodMatch('findByName')
+    }
+
+    void "findBy runs the full round trip through the real DatastoreUtils.execute seam and returns singleResult()"() {
+        given:
+        FinderTestEntity expected = new FinderTestEntity(name: 'Bob')
+        Query query = Mock(Query) {
+            getEntity() >> persistentEntity
+            singleResult() >> expected
+        }
+        Session session = Stub(Session) {
+            createQuery(FinderTestEntity) >> query
+        }
+        // DatastoreUtils.execute() takes the "existing bound session" branch when
+        // Datastore.hasCurrentSession() answers true, invoking the callback directly with that
+        // session - the simplest reliable seam for exercising the real round trip without wiring
+        // Spring's transaction synchronization machinery.
+        datastore.hasCurrentSession() >> true
+        datastore.getCurrentSession() >> session
+
+        when:
+        Object result = SingleResultFinder.findBy(datastore).invoke(FinderTestEntity, 'findByName', ['Bob'] as Object[])
+
+        then:
+        result.is(expected)
+        1 * query.add({ it instanceof Query.Conjunction })
+    }
+
+    void "invoke(Class, methodName, DetachedCriteria, Object[]) merges the detached criteria onto the built query"() {
+        given:
+        // Called reflectively (dynamic Groovy dispatch, not part of FinderMethod) by
+        // AbstractDetachedCriteria#methodMissing - this is the seam a real
+        // `SomeDetachedCriteria.findByName(...)` call goes through.
+        FinderTestEntity expected = new FinderTestEntity(name: 'Bob')
+        Query query = Mock(Query) {
+            getEntity() >> persistentEntity
+            singleResult() >> expected
+        }
+        Session session = Stub(Session) {
+            createQuery(FinderTestEntity) >> query
+        }
+        datastore.hasCurrentSession() >> true
+        datastore.getCurrentSession() >> session
+        grails.gorm.DetachedCriteria detachedCriteria = Stub(grails.gorm.DetachedCriteria) {
+            getFetchStrategies() >> [:]
+            getCriteria() >> [org.grails.datastore.mapping.query.Restrictions.eq('age', 42)]
+            getProjections() >> []
+            getOrders() >> []
+        }
+
+        when:
+        Object result = SingleResultFinder.findBy(datastore).invoke(FinderTestEntity, 'findByName', detachedCriteria, ['Bob'] as Object[])
+
+        then:
+        result.is(expected)
+        1 * query.add({ it instanceof Query.PropertyCriterion })
+        1 * query.add({ it instanceof Query.Conjunction })
+    }
+
+    void "invoke(Class, methodName, DetachedCriteria, Object[]) with a null detachedCriteria never merges anything onto the built query"() {
+        given:
+        // Same reflective entry point as above, but for the case AbstractDetachedCriteria's own
+        // detached criteria has nothing to merge - proving the null-check actually guards the merge
+        // rather than it happening to be a no-op some other way.
+        FinderTestEntity expected = new FinderTestEntity(name: 'Bob')
+        Query query = Mock(Query) {
+            getEntity() >> persistentEntity
+            singleResult() >> expected
+        }
+        Session session = Stub(Session) {
+            createQuery(FinderTestEntity) >> query
+        }
+        datastore.hasCurrentSession() >> true
+        datastore.getCurrentSession() >> session
+
+        when:
+        Object result = SingleResultFinder.findBy(datastore).invoke(FinderTestEntity, 'findByName', (grails.gorm.DetachedCriteria) null, ['Bob'] as Object[])
+
+        then:
+        result.is(expected)
+        0 * query.add({ it instanceof Query.PropertyCriterion })
+        1 * query.add({ it instanceof Query.Conjunction })
+    }
+
+    void "invoke(Class, methodName) throws IllegalStateException when constructed in stateless mode"() {
+        when:
+        SingleResultFinder.findBy(mappingContext).invoke(FinderTestEntity, 'findByName', ['Bob'] as Object[])
+
+        then:
+        thrown(IllegalStateException)
+    }
+
+    void "findByBoolean invoke throws IllegalStateException when constructed in stateless mode"() {
+        when:
+        SingleResultFinder.findByBoolean(mappingContext).invoke(FinderTestEntity, 'findActiveByName', ['Bob'] as Object[])
+
+        then:
+        thrown(IllegalStateException)
+    }
+
+    void "findOrCreateBy rejects the Or operator without ever touching the datastore"() {
+        given:
+        Datastore datastoreThatMustNotBeUsed = Mock(Datastore) {
+            getMappingContext() >> mappingContext
+        }
+
+        when:
+        SingleResultFinder.findOrCreateBy(datastoreThatMustNotBeUsed).invoke(
+                FinderTestEntity, 'findOrCreateByNameOrAge', ['Bob', 42] as Object[])
+
+        then:
+        thrown(MissingMethodException)
+        0 * datastoreThatMustNotBeUsed.hasCurrentSession()
+        0 * datastoreThatMustNotBeUsed.connect()
+    }
+
+    @Unroll
+    void "findOrCreateBy rejects a #expressionType.simpleName expression as not equality-based"() {
+        given:
+        Datastore datastoreThatMustNotBeUsed = Mock(Datastore) {
+            getMappingContext() >> mappingContext
+        }
+
+        when:
+        SingleResultFinder.findOrCreateBy(datastoreThatMustNotBeUsed).invoke(
+                FinderTestEntity, "findOrCreateByAge${expressionType.simpleName}", [18] as Object[])
+
+        then:
+        thrown(ConfigurationException)
+
+        where:
+        expressionType << [MethodExpression.GreaterThan, MethodExpression.LessThan,
+                            MethodExpression.GreaterThanEquals, MethodExpression.LessThanEquals]
+    }
+
+    void "findOrCreateBy constructs a new instance via the Map constructor when the query returns null, without saving it"() {
+        given:
+        Query query = Mock(Query) {
+            getEntity() >> persistentEntity
+            singleResult() >> null
+        }
+        Session session = Stub(Session) {
+            createQuery(FinderTestEntity) >> query
+        }
+        datastore.hasCurrentSession() >> true
+        datastore.getCurrentSession() >> session
+
+        when:
+        Object result = SingleResultFinder.findOrCreateBy(datastore).invoke(FinderTestEntity, 'findOrCreateByName', ['Bob'] as Object[])
+
+        then:
+        result instanceof FinderTestEntity
+        result.name == 'Bob'
+        !result.saved
+    }
+
+    void "findOrCreateBy wraps a ConversionException from singleResult() into a MissingMethodException"() {
+        given:
+        // Guards the onNullResult-configured path (findOrCreateBy/findOrSaveBy only): if the query
+        // itself fails to convert an argument rather than simply returning null, that failure must
+        // still surface as a MissingMethodException, not propagate as a raw ConversionException.
+        Query query = Mock(Query) {
+            getEntity() >> persistentEntity
+            singleResult() >> { throw new org.springframework.core.convert.ConversionFailedException(
+                    org.springframework.core.convert.TypeDescriptor.valueOf(String), org.springframework.core.convert.TypeDescriptor.valueOf(Integer), 'Bob', new IllegalArgumentException()) }
+        }
+        Session session = Stub(Session) {
+            createQuery(FinderTestEntity) >> query
+        }
+        datastore.hasCurrentSession() >> true
+        datastore.getCurrentSession() >> session
+
+        when:
+        SingleResultFinder.findOrCreateBy(datastore).invoke(FinderTestEntity, 'findOrCreateByName', ['Bob'] as Object[])
+
+        then:
+        thrown(MissingMethodException)
+    }
+
+    void "findOrCreateBy invoke throws IllegalStateException when constructed in stateless mode"() {
+        when:
+        SingleResultFinder.findOrCreateBy(mappingContext).invoke(FinderTestEntity, 'findOrCreateByName', ['Bob'] as Object[])
+
+        then:
+        thrown(IllegalStateException)
+    }
+
+    void "findOrCreateBy throws MissingMethodException on the null-result path when a non-Equal expression reached it"() {
+        given:
+        // validate only rejects GT/LT/GTE/LTE - Like/InList/etc slip past it, so this defensive
+        // check in the null-result construction path is a genuinely separate branch.
+        Query query = Mock(Query) {
+            getEntity() >> persistentEntity
+            singleResult() >> null
+        }
+        Session session = Stub(Session) {
+            createQuery(FinderTestEntity) >> query
+        }
+        datastore.hasCurrentSession() >> true
+        datastore.getCurrentSession() >> session
+
+        when:
+        SingleResultFinder.findOrCreateBy(datastore).invoke(FinderTestEntity, 'findOrCreateByNameLike', ['Bo%'] as Object[])
+
+        then:
+        thrown(MissingMethodException)
+    }
+
+    void "findOrSaveBy rejects the Or operator, mirroring findOrCreateBy"() {
+        when:
+        SingleResultFinder.findOrSaveBy(datastore).invoke(FinderTestEntity, 'findOrSaveByNameOrAge', ['Bob', 42] as Object[])
+
+        then:
+        thrown(MissingMethodException)
+    }
+
+    void "findOrSaveBy constructs a new instance and saves it when the query returns null"() {
+        given:
+        Query query = Mock(Query) {
+            getEntity() >> persistentEntity
+            singleResult() >> null
+        }
+        Session session = Stub(Session) {
+            createQuery(FinderTestEntity) >> query
+        }
+        datastore.hasCurrentSession() >> true
+        datastore.getCurrentSession() >> session
+
+        when:
+        Object result = SingleResultFinder.findOrSaveBy(datastore).invoke(FinderTestEntity, 'findOrSaveByName', ['Bob'] as Object[])
+
+        then:
+        result instanceof FinderTestEntity
+        result.name == 'Bob'
+        result.saved
+    }
+
+    void "findOrSaveBy invoke throws IllegalStateException when constructed in stateless mode"() {
+        when:
+        SingleResultFinder.findOrSaveBy(mappingContext).invoke(FinderTestEntity, 'findOrSaveByName', ['Bob'] as Object[])
+
+        then:
+        thrown(IllegalStateException)
+    }
+
+    void "findOrSaveBy throws MissingMethodException on the null-result path when a non-Equal expression reached it"() {
+        given:
+        Query query = Mock(Query) {
+            getEntity() >> persistentEntity
+            singleResult() >> null
+        }
+        Session session = Stub(Session) {
+            createQuery(FinderTestEntity) >> query
+        }
+        datastore.hasCurrentSession() >> true
+        datastore.getCurrentSession() >> session
+
+        when:
+        SingleResultFinder.findOrSaveBy(datastore).invoke(FinderTestEntity, 'findOrSaveByNameLike', ['Bo%'] as Object[])
+
+        then:
+        thrown(MissingMethodException)
+    }
+    void "findBy resolves its datastore through the DatastoreResolver on every invocation, not at construction"() {
+        given:
+        Query query = Mock(Query) {
+            getEntity() >> persistentEntity
+            projections() >> Mock(Query.ProjectionList)
+        }
+        Session session = Stub(Session) {
+            createQuery(FinderTestEntity) >> query
+        }
+        Datastore lateBoundDatastore = Stub(Datastore) {
+            hasCurrentSession() >> true
+            getCurrentSession() >> session
+        }
+        DatastoreResolver resolver = Mock(DatastoreResolver)
+
+        when:
+        def finder = SingleResultFinder.findBy(resolver, mappingContext)
+
+        then:
+        0 * resolver.resolve()
+
+        when:
+        finder.invoke(FinderTestEntity, 'findByName', ['Bob'] as Object[])
+
+        then:
+        1 * resolver.resolve() >> lateBoundDatastore
+        1 * query.singleResult()
+    }
+
+    void "findBy throws IllegalStateException when the DatastoreResolver resolves no datastore"() {
+        given:
+        DatastoreResolver resolver = Mock(DatastoreResolver)
+
+        when:
+        SingleResultFinder.findBy(resolver, mappingContext).invoke(FinderTestEntity, 'findByName', ['Bob'] as Object[])
+
+        then:
+        IllegalStateException e = thrown()
+        e.message == 'Cannot execute session query with null datastore'
+    }
+}
diff --git a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc
index eb7f271a6d..988d2293e1 100644
--- a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc
+++ b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc
@@ -4636,3 +4636,38 @@ grails:
 ----
 
 See link:security.html#codecs[Encoding and Decoding Objects].
+
+==== 82. GORM Dynamic Finder Classes Replaced
+
+The dynamic finder implementations in the `org.grails.datastore.gorm.finders` package no longer form an inheritance hierarchy. The following public classes have been removed:
+
+* `AbstractFinder`
+* `AbstractFindByFinder`
+* `FindByFinder`
+* `FindByBooleanFinder`
+* `FindAllByFinder`
+* `FindAllByBooleanFinder`
+* `FindOrCreateByFinder`
+* `FindOrSaveByFinder`
+* `CountByFinder`
+
+`DynamicFinder` is now a concrete class that only parses a finder method name and builds its query criteria. It no longer implements `FinderMethod`, has no `invoke` or `doInvokeInternal` methods, and its constructor takes a method pattern, the junction operators, a `MappingContext` and a boolean-clause flag. Finder execution lives in three classes that compose a `DynamicFinder` instead of extending it and are created through static factory methods:
+
+[source,groovy]
+----
+import org.grails.datastore.gorm.finders.CountFinder
+import org.grails.datastore.gorm.finders.ListResultFinder
+import org.grails.datastore.gorm.finders.SingleResultFinder
+
+SingleResultFinder.findBy(datastoreResolver, mappingContext)          // findBy*
+SingleResultFinder.findByBoolean(datastoreResolver, mappingContext)   // find<Boolean>By*
+SingleResultFinder.findOrCreateBy(datastoreResolver, mappingContext)  // findOrCreateBy*
+SingleResultFinder.findOrSaveBy(datastoreResolver, mappingContext)    // findOrSaveBy*
+ListResultFinder.findAllBy(datastoreResolver, mappingContext)         // findAllBy*
+ListResultFinder.findAllByBoolean(datastoreResolver, mappingContext)  // findAll<Boolean>By*
+CountFinder.countBy(datastoreResolver, mappingContext)                // countBy*
+----
+
+Each factory also accepts a `Datastore`, or only a `MappingContext` for a finder that parses method names without executing queries. `ListOrderByFinder` keeps its constructors but no longer extends `AbstractFinder`, and the list of finders a `DetachedCriteria` consults is now typed as `List<FinderMethod>`.
+
+Applications are unaffected: dynamic finders such as `Book.findByTitle('The Stand')` behave as before. A GORM datastore implementation or plugin outside the Grails repository that extends one of the removed classes, or that calls `new FindByFinder(...)` and its siblings when building its finder list, must switch to the factory methods above. Code that only holds finders as `FinderMethod` compiles unchanged.