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

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

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

borinquenkid pushed a commit to branch test/document-datamapping-core-finders
in repository https://gitbox.apache.org/repos/asf/grails-core.git

commit 9ed990a5d1574143190f648de6a2d6e9aa1b0f39
Author: Walter Duque de Estrada <wb...@mac.com>
AuthorDate: Sat Aug 15 13:59:03 2026 -0500

    Replace finders inheritance hierarchy with composition
    
    The 4-level DynamicFinder inheritance chain (AbstractFinder ->
    DynamicFinder -> AbstractFindByFinder -> concrete finders) made
    FindOrSaveByFinder carry dead code: its own copy of the
    construct-from-Equal-expressions logic could never run because
    FindOrCreateByFinder's superclass method already handled it. The
    FindByBooleanFinder/FindAllByBooleanFinder pair duplicated the same
    pattern-swap override for the same structural reason.
    
    Replace the hierarchy with flat classes (SingleResultFinder,
    ListResultFinder, CountFinder, ListOrderByFinder) that compose a
    DynamicFinder grammar instance and are configured via static
    factories, in both grails-datamapping-core and its rx mirror. The
    construct-from-Equal-expressions logic and the boolean-clause
    handling now exist in exactly one place each.
    
    Co-Authored-By: Claude Sonnet 5 <no...@anthropic.com>
---
 .../org/grails/datastore/gorm/GormEnhancer.groovy  |  24 +-
 .../gorm/finders/AbstractFindByFinder.java         |  65 --
 .../datastore/gorm/finders/CountByFinder.java      |  90 ---
 .../grails/datastore/gorm/finders/CountFinder.java | 154 +++++
 .../datastore/gorm/finders/DynamicFinder.java      |  87 ++-
 .../gorm/finders/FindAllByBooleanFinder.java       |  56 --
 .../datastore/gorm/finders/FindAllByFinder.java    |  66 --
 .../gorm/finders/FindByBooleanFinder.java          |  60 --
 .../datastore/gorm/finders/FindByFinder.java       |  40 --
 .../gorm/finders/FindOrCreateByFinder.java         | 108 ---
 .../datastore/gorm/finders/FindOrSaveByFinder.java |  73 --
 .../datastore/gorm/finders/FinderGrammar.java      |  64 ++
 .../{AbstractFinder.java => FinderSupport.java}    |  52 +-
 .../datastore/gorm/finders/ListOrderByFinder.java  |  25 +-
 .../datastore/gorm/finders/ListResultFinder.java   | 126 ++++
 .../datastore/gorm/finders/SingleResultFinder.java | 229 +++++++
 .../datastore/gorm/finders/CountFinderSpec.groovy  | 184 +++++
 .../finders/DynamicFinderInvocationSpec.groovy     |  67 ++
 .../gorm/finders/DynamicFinderSpec.groovy          | 761 ++++++++++++++++++++-
 .../gorm/finders/FinderSupportSpec.groovy          |  56 ++
 .../datastore/gorm/finders/FinderTestEntity.groovy |  49 ++
 .../gorm/finders/ListOrderByFinderSpec.groovy      | 107 +++
 .../gorm/finders/ListResultFinderSpec.groovy       | 139 ++++
 .../gorm/finders/MethodExpressionSpec.groovy       | 574 ++++++++++++++++
 .../gorm/finders/SingleResultFinderSpec.groovy     | 282 ++++++++
 .../org/grails/gorm/rx/api/RxGormStaticApi.groovy  |  24 +-
 .../grails/gorm/rx/finders/CountByFinder.groovy    |  47 --
 .../gorm/rx/finders/FindAllByBooleanFinder.groovy  |  42 --
 .../grails/gorm/rx/finders/FindAllByFinder.groovy  |  59 --
 .../gorm/rx/finders/FindByBooleanFinder.groovy     |  42 --
 .../org/grails/gorm/rx/finders/FindByFinder.groovy |  51 --
 .../gorm/rx/finders/FindOrCreateByFinder.groovy    |  92 ---
 .../gorm/rx/finders/FindOrSaveByFinder.groovy      |  40 --
 .../grails/gorm/rx/finders/RxCountFinder.groovy    |  98 +++
 .../gorm/rx/finders/RxListResultFinder.groovy      | 120 ++++
 .../gorm/rx/finders/RxSingleResultFinder.groovy    | 186 +++++
 .../rx/finders/FindAllByBooleanFinderSpec.groovy   |  96 ---
 .../gorm/rx/finders/FindAllByFinderSpec.groovy     | 146 ----
 .../gorm/rx/finders/FindByBooleanFinderSpec.groovy |  86 ---
 .../grails/gorm/rx/finders/FindByFinderSpec.groovy | 133 ----
 .../rx/finders/FindOrCreateByFinderSpec.groovy     | 185 -----
 .../gorm/rx/finders/FindOrSaveByFinderSpec.groovy  | 114 ---
 ...yFinderSpec.groovy => RxCountFinderSpec.groovy} |  73 +-
 .../gorm/rx/finders/RxListResultFinderSpec.groovy  | 196 ++++++
 .../rx/finders/RxSingleResultFinderSpec.groovy     | 325 +++++++++
 45 files changed, 3859 insertions(+), 1834 deletions(-)

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 efadd5c1cc..0d0650f27e 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
@@ -30,15 +30,11 @@ import org.springframework.transaction.TransactionSystemException
 
 import grails.gorm.MultiTenant
 import grails.gorm.multitenancy.Tenants
-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.FindOrCreateByFinder
-import org.grails.datastore.gorm.finders.FindOrSaveByFinder
+import org.grails.datastore.gorm.finders.CountFinder
 import org.grails.datastore.gorm.finders.FinderMethod
 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.gorm.internal.InstanceMethodInvokingClosure
 import org.grails.datastore.gorm.internal.StaticMethodInvokingClosure
 import org.grails.datastore.mapping.core.Datastore
@@ -698,13 +694,13 @@ class GormEnhancer implements Closeable {
 
     @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 e2b03e51e0..0000000000
--- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/AbstractFindByFinder.java
+++ /dev/null
@@ -1,65 +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.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, MappingContext mappingContext) {
-        super(pattern, OPERATORS, mappingContext);
-    }
-
-    @Override
-    protected Object doInvokeInternal(final DynamicFinderInvocation invocation) {
-        return execute(new SessionCallback<>() {
-            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/CountByFinder.java b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/CountByFinder.java
deleted file mode 100644
index 499c07cd45..0000000000
--- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/CountByFinder.java
+++ /dev/null
@@ -1,90 +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.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 counting objects. For example Book.countByTitle("The Stand")
- */
-public class CountByFinder extends DynamicFinder implements QueryBuildingFinder {
-
-    private static final String OPERATOR_OR = "Or";
-    private static final String OPERATOR_AND = "And";
-
-    private static final Pattern METHOD_PATTERN = Pattern.compile("(countBy)(\\w+)");
-    private static final String[] OPERATORS = { OPERATOR_AND, OPERATOR_OR };
-
-    public CountByFinder(final Datastore datastore) {
-        super(METHOD_PATTERN, OPERATORS, datastore);
-    }
-
-    public CountByFinder(MappingContext mappingContext) {
-        super(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);
-                return invokeQuery(query);
-            }
-        });
-    }
-
-    protected Object invokeQuery(Query q) {
-        return q.singleResult();
-    }
-
-    public Query buildQuery(DynamicFinderInvocation invocation, Session session) {
-        final Class<?> clazz = invocation.getJavaClass();
-        Query q = session.createQuery(clazz);
-        return buildQuery(invocation, clazz, q);
-    }
-
-    protected Query buildQuery(DynamicFinderInvocation invocation, Class<?> clazz, Query q) {
-        applyAdditionalCriteria(q, invocation.getCriteria());
-        applyDetachedCriteria(q, invocation.getDetachedCriteria());
-        configureQueryWithArguments(clazz, q, invocation.getArguments());
-
-        String operatorInUse = invocation.getOperator();
-        if (operatorInUse != null && operatorInUse.equals(OPERATOR_OR)) {
-            Query.Junction disjunction = q.disjunction();
-
-            for (MethodExpression expression : invocation.getExpressions()) {
-                q.add(disjunction, expression.createCriterion());
-            }
-        }
-        else {
-            for (MethodExpression expression : invocation.getExpressions()) {
-                q.add(expression.createCriterion());
-            }
-        }
-
-        q.projections().count();
-        return q;
-    }
-}
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..3057c50fa8
--- /dev/null
+++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/CountFinder.java
@@ -0,0 +1,154 @@
+/*
+ *  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.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 {@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 Datastore datastore;
+    private final DynamicFinder grammar;
+
+    private CountFinder(Datastore datastore, DynamicFinder grammar) {
+        this.datastore = datastore;
+        this.grammar = grammar;
+    }
+
+    public static CountFinder countBy(Datastore datastore) {
+        return new CountFinder(datastore, new DynamicFinder(METHOD_PATTERN, OPERATORS, datastore.getMappingContext(), false));
+    }
+
+    public static CountFinder countBy(MappingContext mappingContext) {
+        return new CountFinder(null, new DynamicFinder(METHOD_PATTERN, OPERATORS, mappingContext, false));
+    }
+
+    @Override
+    public void setPattern(String pattern) {
+        grammar.setPattern(pattern);
+    }
+
+    @Override
+    public boolean isMethodMatch(String methodName) {
+        return grammar.isMethodMatch(methodName);
+    }
+
+    @Override
+    public Object invoke(Class clazz, String methodName, Object[] arguments) {
+        return invoke(clazz, methodName, (Closure) null, arguments);
+    }
+
+    @Override
+    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
+     */
+    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(datastore, new SessionCallback<Object>() {
+            @Override
+            public Object doInSession(Session 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
+     */
+    @SuppressWarnings("unchecked")
+    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 8792a485d8..f93a7a4d8e 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
@@ -35,7 +35,7 @@ import org.springframework.core.convert.ConversionException;
 import org.springframework.core.convert.ConversionService;
 import org.springframework.util.StringUtils;
 
-import grails.gorm.DetachedCriteria;
+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;
@@ -54,7 +54,6 @@ import org.grails.datastore.gorm.finders.MethodExpression.NotEqual;
 import org.grails.datastore.gorm.finders.MethodExpression.NotInList;
 import org.grails.datastore.gorm.finders.MethodExpression.Rlike;
 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;
@@ -66,13 +65,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 {
+public class DynamicFinder implements FinderGrammar {
 
     public static final String ARGUMENT_FETCH_SIZE = "fetchSize";
     public static final String ARGUMENT_TIMEOUT = "timeout";
@@ -104,6 +106,7 @@ public abstract class DynamicFinder extends AbstractFinder implements QueryBuild
     private static final String NOT = "Not";
     private static final Map<String, Constructor> methodExpressions = new LinkedHashMap<String, Constructor>();
     protected final MappingContext mappingContext;
+    private final boolean firstExpressionIsRequiredBoolean;
 
     static {
         defaultOperationPatterns = new Pattern[2];
@@ -132,21 +135,20 @@ public abstract class DynamicFinder extends AbstractFinder implements QueryBuild
         resetMethodExpressionPattern();
     }
 
-    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(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);
     }
 
@@ -250,23 +252,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) {
 
@@ -393,10 +384,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
@@ -613,7 +600,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) {
@@ -732,7 +733,7 @@ public abstract class DynamicFinder extends AbstractFinder implements QueryBuild
         }
     }
 
-    protected void configureQueryWithArguments(Class clazz, Query query, Object[] arguments) {
+    public static void configureQueryWithArguments(Class clazz, Query query, Object[] arguments) {
         if (arguments.length == 0 || !(arguments[0] instanceof Map)) {
             populateArgumentsForCriteria(clazz, query, Collections.emptyMap());
             return;
@@ -773,14 +774,16 @@ public abstract class DynamicFinder extends AbstractFinder implements QueryBuild
         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));
@@ -800,6 +803,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/FindAllByBooleanFinder.java b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/FindAllByBooleanFinder.java
deleted file mode 100644
index ce3e03aa1d..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.mapping.core.Datastore;
-import org.grails.datastore.mapping.model.MappingContext;
-
-/**
- * The "findAll&lt;booleanProperty&gt;By*" static persistent method. This method allows querying for
- * instances of grails domain classes based on a boolean property and any other arbitrary
- * properties.
- *
- * eg.
- * Account.findAllActiveByHolder("Joe Blogs"); // Where class "Account" has a properties called "active" and "holder"
- * Account.findAllActiveByHolderAndBranch("Joe Blogs", "London"); // Where class "Account" has a properties called "active', "holder" and "branch"
- *
- * In both of those queries, the query will only select Account objects where active=true.
- *
- * @author Jeff Brown
- * @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(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 ed1eaede5e..0000000000
--- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/FindAllByFinder.java
+++ /dev/null
@@ -1,66 +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.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(final 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 q) {
-        return q.list();
-    }
-
-    protected void adjustQuery(Query query) {
-        query.projections().distinct();
-    }
-
-}
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 c1591f9398..0000000000
--- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/FindByBooleanFinder.java
+++ /dev/null
@@ -1,60 +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.mapping.core.Datastore;
-import org.grails.datastore.mapping.model.MappingContext;
-
-/**
- * 
- * <p>The "find&lt;booleanProperty&gt;By*" static persistent method. This method allows querying for
- * instances of grails domain classes based on a boolean property and any other arbitrary
- * properties. This method returns the first result of the query.</p>
- *
- * <pre><code>
- * eg.
- * Account.findActiveByHolder("Joe Blogs"); // Where class "Account" has a properties called "active" and "holder"
- * Account.findActiveByHolderAndBranch("Joe Blogs", "London"); // Where class "Account" has a properties called "active', "holder" and "branch"
- * </code></pre>
- *
- * <p>
- * In both of those queries, the query will only select Account objects where active=true.
- * </p>
- *
- * @author Graeme Rocher
- * @author Jeff Brown
- */
-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(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 5e8ee5cf61..0000000000
--- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/FindByFinder.java
+++ /dev/null
@@ -1,40 +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.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(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 8205dec00b..0000000000
--- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/FindOrCreateByFinder.java
+++ /dev/null
@@ -1,108 +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.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 Datastore datastore) {
-        this(METHOD_PATTERN, datastore);
-    }
-
-    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 3552214b8f..0000000000
--- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/FindOrSaveByFinder.java
+++ /dev/null
@@ -1,73 +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 groovy.lang.GroovySystem;
-import groovy.lang.MetaClass;
-import groovy.lang.MissingMethodException;
-
-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 Datastore datastore) {
-        super(METHOD_PATTERN, datastore);
-    }
-
-    public FindOrSaveByFinder(final MappingContext mappingContext) {
-        super(METHOD_PATTERN, 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());
-        }
-
-        Object result = super.doInvokeInternal(invocation);
-        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});
-        }
-        return result;
-    }
-
-    @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..61b3eee3c0
--- /dev/null
+++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/FinderGrammar.java
@@ -0,0 +1,64 @@
+/*
+ *  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.
+ */
+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/AbstractFinder.java b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/FinderSupport.java
similarity index 53%
rename from grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/AbstractFinder.java
rename to grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/FinderSupport.java
index fc23f59795..fd8530d32d 100644
--- 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/FinderSupport.java
@@ -18,54 +18,50 @@
  */
 package org.grails.datastore.gorm.finders;
 
-import groovy.lang.Closure;
-
-import grails.gorm.CriteriaBuilder;
 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
+ * Shared session-execution helper for the synchronous finder implementations in this package.
+ * Not a base class - each finder holds a {@link Datastore} field and calls these statically,
+ * rather than inheriting an {@code execute} method.
  */
-@SuppressWarnings("rawtypes")
-public abstract class AbstractFinder implements FinderMethod {
-
-    protected final Datastore datastore;
+public final class FinderSupport {
 
-    public AbstractFinder(final Datastore datastore) {
-        this.datastore = datastore;
+    private FinderSupport() {
     }
 
-    protected <T> T execute(final SessionCallback<T> callback) {
+    /**
+     * Executes the given callback within a session bound to the given datastore.
+     *
+     * @param datastore The datastore, or null for stateless mode
+     * @param callback The callback
+     * @param <T> The callback's result type
+     * @return The callback's result
+     * @throws IllegalStateException if datastore is null (stateless mode)
+     */
+    public static <T> T execute(final Datastore datastore, final SessionCallback<T> callback) {
         if (datastore != null) {
             return DatastoreUtils.execute(datastore, callback);
         }
-        else {
-            throw new IllegalStateException("Cannot execute session query in stateless mode");
-        }
+        throw new IllegalStateException("Cannot execute session query in stateless mode");
     }
 
-    protected void execute(final VoidSessionCallback callback) {
+    /**
+     * Executes the given void callback within a session bound to the given datastore.
+     *
+     * @param datastore The datastore, or null for stateless mode
+     * @param callback The callback
+     * @throws IllegalStateException if datastore is null (stateless mode)
+     */
+    public static void execute(final Datastore datastore, final VoidSessionCallback callback) {
         if (datastore != null) {
             DatastoreUtils.execute(datastore, callback);
         }
         else {
             throw new IllegalStateException("Cannot execute session query in stateless mode");
         }
-
-    }
-
-    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/ListOrderByFinder.java b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/ListOrderByFinder.java
index 1f522b9554..2816249ddd 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
@@ -38,25 +38,34 @@ import org.grails.datastore.mapping.reflect.NameUtils;
  * Account.listOrderByHolder();
  * Account.listOrderByHolder(max); // max results
  *
+ * <p>Never shared {@link DynamicFinder}'s grammar (no And/Or/operator-suffix parsing - just a
+ * single trailing property name), 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 final Datastore datastore;
     private Pattern pattern = METHOD_PATTERN;
 
     public ListOrderByFinder(Datastore datastore) {
-        super(datastore);
+        this.datastore = datastore;
     }
 
+    @Override
     public void setPattern(String pattern) {
         this.pattern = Pattern.compile(pattern);
     }
 
+    @Override
     @SuppressWarnings("rawtypes")
     public Object invoke(final Class clazz, final String methodName, final Object[] arguments) {
         return invoke(clazz, methodName, null, arguments);
     }
 
+    @Override
     @SuppressWarnings("rawtypes")
     public Object invoke(final Class clazz, final String methodName, final Closure additionalCriteria, final Object[] arguments) {
 
@@ -66,10 +75,10 @@ public class ListOrderByFinder extends AbstractFinder {
         String nameInSignature = match.group(2);
         final String propertyName = NameUtils.decapitalizeFirstChar(nameInSignature);
 
-        return execute(new SessionCallback<>() {
+        return FinderSupport.execute(datastore, new SessionCallback<>() {
             public Object doInSession(final Session session) {
                 Query q = session.createQuery(clazz);
-                applyAdditionalCriteria(q, additionalCriteria);
+                DynamicFinder.applyAdditionalCriteria(q, additionalCriteria);
 
                 boolean ascending = true;
                 if (arguments.length > 0 && (arguments[0] instanceof Map)) {
@@ -83,17 +92,13 @@ public class ListOrderByFinder extends AbstractFinder {
 
                 q.order(ascending ? Query.Order.asc(propertyName) : Query.Order.desc(propertyName));
                 q.projections().distinct();
-                return invokeQuery(q);
+                return q.list();
             }
         });
     }
 
-    protected Object invokeQuery(Query q) {
-        return q.list();
-    }
-
+    @Override
     public boolean isMethodMatch(String methodName) {
         return pattern.matcher(methodName.subSequence(0, methodName.length())).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..1b45256d17
--- /dev/null
+++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/ListResultFinder.java
@@ -0,0 +1,126 @@
+/*
+ *  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.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 Datastore datastore;
+    private final DynamicFinder grammar;
+
+    private ListResultFinder(Datastore datastore, DynamicFinder grammar) {
+        this.datastore = datastore;
+        this.grammar = grammar;
+    }
+
+    public static ListResultFinder findAllBy(Datastore datastore) {
+        return new ListResultFinder(datastore, grammar(FIND_ALL_BY_PATTERN, datastore.getMappingContext(), false));
+    }
+
+    public static ListResultFinder findAllBy(MappingContext mappingContext) {
+        return new ListResultFinder(null, grammar(FIND_ALL_BY_PATTERN, mappingContext, false));
+    }
+
+    public static ListResultFinder findAllByBoolean(Datastore datastore) {
+        return new ListResultFinder(datastore, grammar(FIND_ALL_BY_BOOLEAN_PATTERN, datastore.getMappingContext(), true));
+    }
+
+    public static ListResultFinder findAllByBoolean(MappingContext mappingContext) {
+        return new ListResultFinder(null, grammar(FIND_ALL_BY_BOOLEAN_PATTERN, mappingContext, true));
+    }
+
+    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
+    public Object invoke(Class clazz, String methodName, Object[] arguments) {
+        return invoke(clazz, methodName, (Closure) null, arguments);
+    }
+
+    @Override
+    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
+     */
+    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(datastore, new SessionCallback<Object>() {
+            @Override
+            public Object doInSession(Session session) {
+                Query query = buildQuery(invocation, session);
+                query.projections().distinct();
+                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/SingleResultFinder.java b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/SingleResultFinder.java
new file mode 100644
index 0000000000..a05fce66a8
--- /dev/null
+++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/SingleResultFinder.java
@@ -0,0 +1,229 @@
+/*
+ *  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.mapping.core.Datastore;
+import org.grails.datastore.mapping.core.Session;
+import org.grails.datastore.mapping.core.SessionCallback;
+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 Datastore datastore;
+    private final DynamicFinder grammar;
+    private final Consumer<DynamicFinderInvocation> validate;
+    private final Function<DynamicFinderInvocation, Object> onNullResult;
+
+    private SingleResultFinder(Datastore datastore, DynamicFinder grammar,
+            Consumer<DynamicFinderInvocation> validate, Function<DynamicFinderInvocation, Object> onNullResult) {
+        this.datastore = datastore;
+        this.grammar = grammar;
+        this.validate = validate;
+        this.onNullResult = onNullResult;
+    }
+
+    public static SingleResultFinder findBy(Datastore datastore) {
+        return new SingleResultFinder(datastore, grammar(FIND_BY_PATTERN, datastore.getMappingContext(), false), null, null);
+    }
+
+    public static SingleResultFinder findBy(MappingContext mappingContext) {
+        return new SingleResultFinder(null, grammar(FIND_BY_PATTERN, mappingContext, false), null, null);
+    }
+
+    public static SingleResultFinder findByBoolean(Datastore datastore) {
+        return new SingleResultFinder(datastore, grammar(FIND_BY_BOOLEAN_PATTERN, datastore.getMappingContext(), true), null, null);
+    }
+
+    public static SingleResultFinder findByBoolean(MappingContext mappingContext) {
+        return new SingleResultFinder(null, grammar(FIND_BY_BOOLEAN_PATTERN, mappingContext, true), null, null);
+    }
+
+    public static SingleResultFinder findOrCreateBy(Datastore datastore) {
+        return findOrCreateOrSave(datastore, datastore.getMappingContext(), false);
+    }
+
+    public static SingleResultFinder findOrCreateBy(MappingContext mappingContext) {
+        return findOrCreateOrSave(null, mappingContext, false);
+    }
+
+    public static SingleResultFinder findOrSaveBy(Datastore datastore) {
+        return findOrCreateOrSave(datastore, datastore.getMappingContext(), true);
+    }
+
+    public static SingleResultFinder findOrSaveBy(MappingContext mappingContext) {
+        return findOrCreateOrSave(null, mappingContext, true);
+    }
+
+    private static SingleResultFinder findOrCreateOrSave(Datastore datastore, MappingContext mappingContext, boolean save) {
+        String pattern = save ? FIND_OR_SAVE_BY_PATTERN : FIND_OR_CREATE_BY_PATTERN;
+        return new SingleResultFinder(datastore, 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")
+    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
+    public Object invoke(Class clazz, String methodName, Object[] arguments) {
+        return invoke(clazz, methodName, (Closure) null, arguments);
+    }
+
+    @Override
+    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
+     */
+    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(datastore, new SessionCallback<Object>() {
+            @Override
+            public Object doInSession(Session 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/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..b6011f5806
--- /dev/null
+++ b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/CountFinderSpec.groovy
@@ -0,0 +1,184 @@
+/*
+ *  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.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 "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 throws IllegalStateException when constructed in stateless mode"() {
+        when:
+        CountFinder.countBy(mappingContext).invoke(FinderTestEntity, 'countByName', ['Bob'] as Object[])
+
+        then:
+        thrown(IllegalStateException)
+    }
+}
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..71d4738907 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,734 @@ 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']
+        "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 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 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 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, but ignores each entry's own direction"() {
+        given:
+        // Real, surprising inconsistency between the two populateArgumentsForCriteria overloads:
+        // the Query-overload's multi-field sort (applySortForMap, tested above) reads EACH entry's
+        // own value ('asc'/'desc') to pick that field's direction. This BuildableCriteria-overload
+        // has its own separate inline loop that does NOT do that - it reads sortMap.get(key) into
+        // `value` but never uses it, applying a single direction (from the top-level "order" arg,
+        // defaulting to ascending) uniformly to every entry in the map instead. A per-field 'desc'
+        // here is silently ignored.
+        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.ASC })
+    }
+
+    void "populateArgumentsForCriteria(BuildableCriteria) applies the top-level order argument uniformly to every sort Map entry"() {
+        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.DESC })
+        1 * criteria.order({ Query.Order order -> order.property == 'age' && order.direction == Query.Order.Direction.DESC })
+    }
+
+    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:
-        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']
+        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)
     }
 }
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..b7adf3c57b
--- /dev/null
+++ b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/FinderSupportSpec.groovy
@@ -0,0 +1,56 @@
+/*
+ *  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.core.Session
+import org.grails.datastore.mapping.core.SessionCallback
+import org.grails.datastore.mapping.core.VoidSessionCallback
+import spock.lang.Specification
+
+/**
+ * Exercises {@link FinderSupport#execute}'s "stateless mode" guard - the shared session-execution
+ * helper every synchronous finder in this package calls with its own {@code Datastore} field,
+ * rather than inheriting an {@code execute} method.
+ */
+class FinderSupportSpec extends Specification {
+
+    void "execute(SessionCallback) throws IllegalStateException when the datastore 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 in stateless mode'
+    }
+
+    void "execute(VoidSessionCallback) throws IllegalStateException when the datastore is null"() {
+        given:
+        VoidSessionCallback callback = { Session session -> } as VoidSessionCallback
+
+        when:
+        FinderSupport.execute(null, callback)
+
+        then:
+        IllegalStateException e = thrown()
+        e.message == 'Cannot execute session query in stateless mode'
+    }
+}
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..e4827a29e2
--- /dev/null
+++ b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/ListOrderByFinderSpec.groovy
@@ -0,0 +1,107 @@
+/*
+ *  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.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 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()
+    }
+    Datastore datastore = Stub(Datastore)
+    ListOrderByFinder finder = new ListOrderByFinder(datastore)
+
+    @Unroll
+    void "isMethodMatch('#methodName') == #matches"() {
+        expect:
+        finder.isMethodMatch(methodName) == matches
+
+        where:
+        methodName        | matches
+        'listOrderByName' | true
+        'somethingElse'   | false
+    }
+
+    void "invoke defaults to ascending order when no Map argument is supplied, and lists distinct results"() {
+        given:
+        Query.ProjectionList projectionList = Mock(Query.ProjectionList)
+        Query query = Mock(Query) {
+            projections() >> projectionList
+        }
+        Session session = Stub(Session) {
+            createQuery(FinderTestEntity) >> query
+        }
+        datastore.hasCurrentSession() >> true
+        datastore.getCurrentSession() >> session
+
+        when:
+        finder.invoke(FinderTestEntity, 'listOrderByAge', [] as Object[])
+
+        then:
+        1 * query.order({ Query.Order order -> order.property == 'age' && order.direction == Query.Order.Direction.ASC })
+        1 * projectionList.distinct()
+        1 * query.list()
+        0 * query.singleResult()
+    }
+
+    void "invoke flips to descending order and delegates the remaining Map entries to DynamicFinder.populateArgumentsForCriteria"() {
+        given:
+        Query.ProjectionList projectionList = Mock(Query.ProjectionList)
+        PersistentEntity persistentEntity = Stub(PersistentEntity) {
+            getMappingContext() >> mappingContext
+        }
+        Query query = Mock(Query) {
+            getEntity() >> persistentEntity
+            projections() >> projectionList
+        }
+        Session session = Stub(Session) {
+            createQuery(FinderTestEntity) >> query
+        }
+        datastore.hasCurrentSession() >> true
+        datastore.getCurrentSession() >> session
+
+        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: 'desc', max: 5]] as Object[])
+
+        then:
+        1 * query.order({ Query.Order order -> order.property == 'age' && order.direction == Query.Order.Direction.DESC })
+        1 * query.max(5)
+        1 * projectionList.distinct()
+        1 * query.list()
+    }
+}
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..6b0164203f
--- /dev/null
+++ b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/ListResultFinderSpec.groovy
@@ -0,0 +1,139 @@
+/*
+ *  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.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 "findAllBy runs the full round trip, applies distinct and returns the full list"() {
+        given:
+        List<FinderTestEntity> expected = [new FinderTestEntity(name: 'Bob')]
+        Query.ProjectionList projectionList = Mock(Query.ProjectionList)
+        Query query = Mock(Query) {
+            getEntity() >> persistentEntity
+            projections() >> projectionList
+        }
+        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)
+        1 * projectionList.distinct()
+        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.ProjectionList projectionList = Mock(Query.ProjectionList)
+        Query query = Mock(Query) {
+            getEntity() >> persistentEntity
+            projections() >> projectionList
+        }
+        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 })
+        1 * projectionList.distinct()
+        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)
+    }
+}
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..d62ec34abe
--- /dev/null
+++ b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/SingleResultFinderSpec.groovy
@@ -0,0 +1,282 @@
+/*
+ *  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.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 - see {@code DynamicFinderSpec}'s field-ordering note for why that
+ * matters), 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 "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) throws IllegalStateException when constructed in stateless mode"() {
+        when:
+        SingleResultFinder.findBy(mappingContext).invoke(FinderTestEntity, 'findByName', ['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 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 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)
+    }
+}
diff --git a/grails-datamapping-rx/src/main/groovy/org/grails/gorm/rx/api/RxGormStaticApi.groovy b/grails-datamapping-rx/src/main/groovy/org/grails/gorm/rx/api/RxGormStaticApi.groovy
index 1278d50e86..13d50acbe0 100644
--- a/grails-datamapping-rx/src/main/groovy/org/grails/gorm/rx/api/RxGormStaticApi.groovy
+++ b/grails-datamapping-rx/src/main/groovy/org/grails/gorm/rx/api/RxGormStaticApi.groovy
@@ -40,13 +40,9 @@ import org.grails.datastore.mapping.validation.ValidationException
 import org.grails.datastore.rx.RxDatastoreClient
 import org.grails.datastore.rx.query.RxQuery
 import org.grails.gorm.rx.api.multitenancy.TenantDelegatingRxGormOperations
-import org.grails.gorm.rx.finders.CountByFinder
-import org.grails.gorm.rx.finders.FindAllByBooleanFinder
-import org.grails.gorm.rx.finders.FindAllByFinder
-import org.grails.gorm.rx.finders.FindByBooleanFinder
-import org.grails.gorm.rx.finders.FindByFinder
-import org.grails.gorm.rx.finders.FindOrCreateByFinder
-import org.grails.gorm.rx.finders.FindOrSaveByFinder
+import org.grails.gorm.rx.finders.RxCountFinder
+import org.grails.gorm.rx.finders.RxListResultFinder
+import org.grails.gorm.rx.finders.RxSingleResultFinder
 import org.springframework.beans.PropertyAccessorFactory
 import rx.Observable
 import rx.Subscriber
@@ -499,13 +495,13 @@ class RxGormStaticApi<D> implements RxGormAllOperations<D> {
     }
 
     protected List<FinderMethod> createDynamicFinders() {
-        [new FindOrCreateByFinder(datastoreClient),
-         new FindOrSaveByFinder(datastoreClient),
-         new FindByFinder(datastoreClient),
-         new FindAllByFinder(datastoreClient),
-         new CountByFinder(datastoreClient),
-         new FindByBooleanFinder(datastoreClient),
-         new FindAllByBooleanFinder(datastoreClient)] as List<FinderMethod>
+        [RxSingleResultFinder.findOrCreateBy(datastoreClient),
+         RxSingleResultFinder.findOrSaveBy(datastoreClient),
+         RxSingleResultFinder.findBy(datastoreClient),
+         RxListResultFinder.findAllBy(datastoreClient),
+         RxCountFinder.countBy(datastoreClient),
+         RxSingleResultFinder.findByBoolean(datastoreClient),
+         RxListResultFinder.findAllByBoolean(datastoreClient)] as List<FinderMethod>
     }
 
     @Override
diff --git a/grails-datamapping-rx/src/main/groovy/org/grails/gorm/rx/finders/CountByFinder.groovy b/grails-datamapping-rx/src/main/groovy/org/grails/gorm/rx/finders/CountByFinder.groovy
deleted file mode 100644
index 1f5b5d9580..0000000000
--- a/grails-datamapping-rx/src/main/groovy/org/grails/gorm/rx/finders/CountByFinder.groovy
+++ /dev/null
@@ -1,47 +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.gorm.rx.finders
-
-import groovy.transform.CompileStatic
-import org.grails.datastore.gorm.finders.DynamicFinderInvocation
-import org.grails.datastore.rx.RxDatastoreClient
-
-/**
- * Implementation of countBy* dynamic finder for RxGORM
- *
- * @since 6.0
- */
-@CompileStatic
-class CountByFinder extends org.grails.datastore.gorm.finders.CountByFinder {
-
-    final RxDatastoreClient datastoreClient
-    CountByFinder(RxDatastoreClient datastoreClient) {
-        super(datastoreClient.mappingContext)
-        this.datastoreClient = datastoreClient
-    }
-
-    @Override
-    protected Object doInvokeInternal(DynamicFinderInvocation invocation) {
-        def javaClass = invocation.getJavaClass()
-        def query = datastoreClient.createQuery(javaClass)
-        query = buildQuery(invocation, javaClass, query)
-        query.singleResult()
-    }
-}
diff --git a/grails-datamapping-rx/src/main/groovy/org/grails/gorm/rx/finders/FindAllByBooleanFinder.groovy b/grails-datamapping-rx/src/main/groovy/org/grails/gorm/rx/finders/FindAllByBooleanFinder.groovy
deleted file mode 100644
index 296e9a74ae..0000000000
--- a/grails-datamapping-rx/src/main/groovy/org/grails/gorm/rx/finders/FindAllByBooleanFinder.groovy
+++ /dev/null
@@ -1,42 +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.gorm.rx.finders
-
-import groovy.transform.CompileStatic
-import org.grails.datastore.rx.RxDatastoreClient
-
-/**
- * Implementation of findAllBy* boolean finder for RxGORM
- *
- * @see org.grails.datastore.gorm.finders.FindAllByBooleanFinder
- */
-@CompileStatic
-class FindAllByBooleanFinder extends FindAllByFinder {
-
-    FindAllByBooleanFinder(RxDatastoreClient datastoreClient) {
-        super(datastoreClient)
-        setPattern(org.grails.datastore.gorm.finders.FindAllByBooleanFinder.METHOD_PATTERN)
-    }
-
-    @Override
-    boolean firstExpressionIsRequiredBoolean() {
-        return true
-    }
-}
diff --git a/grails-datamapping-rx/src/main/groovy/org/grails/gorm/rx/finders/FindAllByFinder.groovy b/grails-datamapping-rx/src/main/groovy/org/grails/gorm/rx/finders/FindAllByFinder.groovy
deleted file mode 100644
index 13ac471813..0000000000
--- a/grails-datamapping-rx/src/main/groovy/org/grails/gorm/rx/finders/FindAllByFinder.groovy
+++ /dev/null
@@ -1,59 +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.gorm.rx.finders
-
-import groovy.transform.CompileStatic
-import org.grails.datastore.gorm.finders.DynamicFinderInvocation
-import org.grails.datastore.rx.RxDatastoreClient
-import org.grails.datastore.rx.query.RxQuery
-
-/**
- * Implementation of findAllBy* dynamic finder for RxGORM
- *
- * @since 6.0
- */
-@CompileStatic
-class FindAllByFinder extends org.grails.datastore.gorm.finders.FindAllByFinder {
-
-    final RxDatastoreClient datastoreClient
-
-    FindAllByFinder(RxDatastoreClient datastoreClient) {
-        super(datastoreClient.mappingContext)
-        this.datastoreClient = datastoreClient
-    }
-
-    @Override
-    protected Object doInvokeInternal(DynamicFinderInvocation invocation) {
-        def javaClass = invocation.getJavaClass()
-        def query = datastoreClient.createQuery(javaClass)
-        applyAdditionalCriteria(query, invocation.getCriteria())
-        applyDetachedCriteria(query, invocation.getDetachedCriteria())
-        configureQueryWithArguments(javaClass, query, invocation.getArguments())
-        query.add(getJunction(invocation))
-        def arguments = invocation.getArguments()
-        if (arguments.length > 0 && (arguments[0] instanceof Map)) {
-            ((RxQuery)query).findAll((Map)arguments[0])
-        }
-        else {
-            return ((RxQuery)query).findAll()
-        }
-
-    }
-}
diff --git a/grails-datamapping-rx/src/main/groovy/org/grails/gorm/rx/finders/FindByBooleanFinder.groovy b/grails-datamapping-rx/src/main/groovy/org/grails/gorm/rx/finders/FindByBooleanFinder.groovy
deleted file mode 100644
index f0bdf2c0fc..0000000000
--- a/grails-datamapping-rx/src/main/groovy/org/grails/gorm/rx/finders/FindByBooleanFinder.groovy
+++ /dev/null
@@ -1,42 +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.gorm.rx.finders
-
-import groovy.transform.CompileStatic
-import org.grails.datastore.rx.RxDatastoreClient
-
-/**
- * Implementation of findBy* boolean finder for RxGORM
- *
- * @see org.grails.datastore.gorm.finders.FindByBooleanFinder
- */
-@CompileStatic
-class FindByBooleanFinder extends FindByFinder {
-
-    FindByBooleanFinder(RxDatastoreClient datastoreClient) {
-        super(datastoreClient)
-        setPattern(org.grails.datastore.gorm.finders.FindByBooleanFinder.METHOD_PATTERN)
-    }
-
-    @Override
-    boolean firstExpressionIsRequiredBoolean() {
-        return true
-    }
-}
diff --git a/grails-datamapping-rx/src/main/groovy/org/grails/gorm/rx/finders/FindByFinder.groovy b/grails-datamapping-rx/src/main/groovy/org/grails/gorm/rx/finders/FindByFinder.groovy
deleted file mode 100644
index 06ce2a582f..0000000000
--- a/grails-datamapping-rx/src/main/groovy/org/grails/gorm/rx/finders/FindByFinder.groovy
+++ /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.gorm.rx.finders
-
-import groovy.transform.CompileStatic
-import org.grails.datastore.gorm.finders.DynamicFinderInvocation
-import org.grails.datastore.rx.RxDatastoreClient
-
-/**
- * Implementation of findBy* dynamic finder for RxGORM
- *
- * @since 6.0
- */
-@CompileStatic
-class FindByFinder extends org.grails.datastore.gorm.finders.FindByFinder {
-
-    final RxDatastoreClient datastoreClient
-
-    FindByFinder(RxDatastoreClient datastoreClient) {
-        super(datastoreClient.mappingContext)
-        this.datastoreClient = datastoreClient
-    }
-
-    @Override
-    protected Object doInvokeInternal(DynamicFinderInvocation invocation) {
-        def javaClass = invocation.getJavaClass()
-        def query = datastoreClient.createQuery(javaClass)
-        applyAdditionalCriteria(query, invocation.getCriteria())
-        applyDetachedCriteria(query, invocation.getDetachedCriteria())
-        configureQueryWithArguments(javaClass, query, invocation.getArguments())
-        query.add(getJunction(invocation))
-        invokeQuery(query)
-    }
-}
diff --git a/grails-datamapping-rx/src/main/groovy/org/grails/gorm/rx/finders/FindOrCreateByFinder.groovy b/grails-datamapping-rx/src/main/groovy/org/grails/gorm/rx/finders/FindOrCreateByFinder.groovy
deleted file mode 100644
index 9fb8c114bb..0000000000
--- a/grails-datamapping-rx/src/main/groovy/org/grails/gorm/rx/finders/FindOrCreateByFinder.groovy
+++ /dev/null
@@ -1,92 +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.gorm.rx.finders
-
-import grails.gorm.rx.RxEntity
-import groovy.transform.CompileStatic
-import org.grails.datastore.gorm.finders.DynamicFinderInvocation
-import org.grails.datastore.gorm.finders.MethodExpression
-import org.grails.datastore.rx.RxDatastoreClient
-import rx.Observable
-import rx.Subscriber
-
-/**
- * Implementation of findOrCreateBy* finder for RxGORM
- *
- * @since 6.0
- */
-@CompileStatic
-class FindOrCreateByFinder extends FindByFinder {
-
-    FindOrCreateByFinder(RxDatastoreClient datastoreClient) {
-        super(datastoreClient)
-        setPattern(org.grails.datastore.gorm.finders.FindOrCreateByFinder.METHOD_PATTERN)
-    }
-
-    @Override
-    protected Object doInvokeInternal(DynamicFinderInvocation invocation) {
-        Observable observable = (Observable)super.doInvokeInternal(invocation)
-        observable.switchIfEmpty(Observable.create({ Subscriber s ->
-                Thread.start {
-                    Map m = [:]
-                    List<MethodExpression> expressions = invocation.getExpressions()
-                    for (MethodExpression me in expressions) {
-                        if (!(me instanceof MethodExpression.Equal)) {
-                            throw new MissingMethodException(invocation.methodName, invocation.javaClass, invocation.arguments)
-                        }
-                        String propertyName = me.propertyName
-                        Object[] arguments = me.getArguments()
-                        m.put(propertyName, arguments[0])
-                    }
-
-                    def newInstance = invocation.javaClass.newInstance(m)
-                    if (shouldSaveOnCreate()) {
-                        def saveObservable = ((RxEntity) newInstance).save()
-                        saveObservable.subscribe(new Subscriber() {
-                        @Override
-                        void onCompleted() {
-                            s.onCompleted()
-                        }
-
-                        @Override
-                        void onError(Throwable e) {
-                            s.onError(e)
-                        }
-
-                        @Override
-                        void onNext(Object o) {
-                            s.onNext o
-                        }
-                        })
-                    }
-                    else {
-                        s.onNext newInstance
-                        s.onCompleted()
-                    }
-                }
-
-            } as Observable.OnSubscribe)
-        )
-    }
-
-    protected boolean shouldSaveOnCreate() {
-        return false
-    }
-}
diff --git a/grails-datamapping-rx/src/main/groovy/org/grails/gorm/rx/finders/FindOrSaveByFinder.groovy b/grails-datamapping-rx/src/main/groovy/org/grails/gorm/rx/finders/FindOrSaveByFinder.groovy
deleted file mode 100644
index 77df23793f..0000000000
--- a/grails-datamapping-rx/src/main/groovy/org/grails/gorm/rx/finders/FindOrSaveByFinder.groovy
+++ /dev/null
@@ -1,40 +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.gorm.rx.finders
-
-import groovy.transform.CompileStatic
-import org.grails.datastore.rx.RxDatastoreClient
-
-/**
- * Created by graemerocher on 06/05/16.
- */
-@CompileStatic
-class FindOrSaveByFinder extends FindOrCreateByFinder {
-
-    FindOrSaveByFinder(RxDatastoreClient datastoreClient) {
-        super(datastoreClient)
-        setPattern(org.grails.datastore.gorm.finders.FindOrSaveByFinder.METHOD_PATTERN)
-    }
-
-    @Override
-    protected boolean shouldSaveOnCreate() {
-        return true
-    }
-}
diff --git a/grails-datamapping-rx/src/main/groovy/org/grails/gorm/rx/finders/RxCountFinder.groovy b/grails-datamapping-rx/src/main/groovy/org/grails/gorm/rx/finders/RxCountFinder.groovy
new file mode 100644
index 0000000000..31b965664a
--- /dev/null
+++ b/grails-datamapping-rx/src/main/groovy/org/grails/gorm/rx/finders/RxCountFinder.groovy
@@ -0,0 +1,98 @@
+/*
+ *  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.gorm.rx.finders
+
+import java.util.regex.Pattern
+
+import groovy.transform.CompileStatic
+
+import grails.gorm.DetachedCriteria
+import org.grails.datastore.gorm.finders.CountFinder
+import org.grails.datastore.gorm.finders.DynamicFinder
+import org.grails.datastore.gorm.finders.DynamicFinderInvocation
+import org.grails.datastore.gorm.finders.FinderMethod
+import org.grails.datastore.mapping.query.Query
+import org.grails.datastore.rx.RxDatastoreClient
+
+/**
+ * Implements {@code countBy*} for RxGORM - the rx-module mirror of {@link
+ * org.grails.datastore.gorm.finders.CountFinder}, reusing that class's {@link
+ * CountFinder#applyCriteriaAndCount} helper for the same independent (non-{@code getJunction})
+ * And/Or criteria handling rather than duplicating it.
+ */
+@CompileStatic
+class RxCountFinder implements FinderMethod {
+
+    private static final Pattern METHOD_PATTERN = Pattern.compile('(countBy)(\\w+)')
+    private static final String[] OPERATORS = ['And', 'Or'] as String[]
+
+    private final RxDatastoreClient datastoreClient
+    private final DynamicFinder grammar
+
+    private RxCountFinder(RxDatastoreClient datastoreClient, DynamicFinder grammar) {
+        this.datastoreClient = datastoreClient
+        this.grammar = grammar
+    }
+
+    static RxCountFinder countBy(RxDatastoreClient datastoreClient) {
+        new RxCountFinder(datastoreClient, new DynamicFinder(METHOD_PATTERN, OPERATORS, datastoreClient.mappingContext, false))
+    }
+
+    @Override
+    void setPattern(String pattern) {
+        grammar.setPattern(pattern)
+    }
+
+    @Override
+    boolean isMethodMatch(String methodName) {
+        grammar.isMethodMatch(methodName)
+    }
+
+    @Override
+    Object invoke(Class clazz, String methodName, Object[] arguments) {
+        invoke(clazz, methodName, (Closure) null, arguments)
+    }
+
+    @Override
+    Object invoke(Class clazz, String methodName, Closure additionalCriteria, Object[] arguments) {
+        DynamicFinderInvocation invocation = grammar.createFinderInvocation(clazz, methodName, additionalCriteria, arguments)
+        doInvoke(clazz, invocation)
+    }
+
+    /**
+     * Not part of {@link FinderMethod} - called reflectively (dynamic Groovy dispatch) by {@link
+     * org.grails.datastore.gorm.query.criteria.AbstractDetachedCriteria#methodMissing}. See {@link
+     * RxSingleResultFinder#invoke(Class, String, DetachedCriteria, Object[])} for the full
+     * rationale, including why this is typed against core's {@link DetachedCriteria}.
+     */
+    Object invoke(Class clazz, String methodName, DetachedCriteria detachedCriteria, Object[] arguments) {
+        DynamicFinderInvocation invocation = grammar.createFinderInvocation(clazz, methodName, null, arguments)
+        if (detachedCriteria != null) {
+            invocation.setDetachedCriteria(detachedCriteria)
+        }
+        doInvoke(clazz, invocation)
+    }
+
+    private Object doInvoke(Class clazz, DynamicFinderInvocation invocation) {
+        Query query = datastoreClient.createQuery(clazz)
+        query = CountFinder.applyCriteriaAndCount(invocation, clazz, query)
+        query.singleResult()
+    }
+}
diff --git a/grails-datamapping-rx/src/main/groovy/org/grails/gorm/rx/finders/RxListResultFinder.groovy b/grails-datamapping-rx/src/main/groovy/org/grails/gorm/rx/finders/RxListResultFinder.groovy
new file mode 100644
index 0000000000..58e515ec97
--- /dev/null
+++ b/grails-datamapping-rx/src/main/groovy/org/grails/gorm/rx/finders/RxListResultFinder.groovy
@@ -0,0 +1,120 @@
+/*
+ *  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.gorm.rx.finders
+
+import java.util.regex.Pattern
+
+import groovy.transform.CompileStatic
+
+import grails.gorm.DetachedCriteria
+import org.grails.datastore.gorm.finders.DynamicFinder
+import org.grails.datastore.gorm.finders.DynamicFinderInvocation
+import org.grails.datastore.gorm.finders.FinderMethod
+import org.grails.datastore.mapping.query.Query
+import org.grails.datastore.rx.RxDatastoreClient
+import org.grails.datastore.rx.query.RxQuery
+
+/**
+ * Implements {@code findAllBy*} and the {@code findAll<booleanProperty>By*} boolean-clause form
+ * for RxGORM - the rx-module mirror of {@link org.grails.datastore.gorm.finders.ListResultFinder}.
+ *
+ * <p>Preserved exactly as it existed in the previous per-type rx finder classes: unlike the
+ * synchronous {@code ListResultFinder}, this does NOT apply a {@code distinct()} projection - that
+ * was already a pre-existing difference between the sync and rx implementations, not something
+ * introduced or fixed by this refactor.
+ */
+@CompileStatic
+class RxListResultFinder implements FinderMethod {
+
+    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'] as String[]
+
+    private final RxDatastoreClient datastoreClient
+    private final DynamicFinder grammar
+
+    private RxListResultFinder(RxDatastoreClient datastoreClient, DynamicFinder grammar) {
+        this.datastoreClient = datastoreClient
+        this.grammar = grammar
+    }
+
+    static RxListResultFinder findAllBy(RxDatastoreClient datastoreClient) {
+        new RxListResultFinder(datastoreClient, grammar(FIND_ALL_BY_PATTERN, datastoreClient, false))
+    }
+
+    static RxListResultFinder findAllByBoolean(RxDatastoreClient datastoreClient) {
+        new RxListResultFinder(datastoreClient, grammar(FIND_ALL_BY_BOOLEAN_PATTERN, datastoreClient, true))
+    }
+
+    private static DynamicFinder grammar(String pattern, RxDatastoreClient datastoreClient, boolean booleanClause) {
+        new DynamicFinder(Pattern.compile(pattern), OPERATORS, datastoreClient.mappingContext, booleanClause)
+    }
+
+    @Override
+    void setPattern(String pattern) {
+        grammar.setPattern(pattern)
+    }
+
+    @Override
+    boolean isMethodMatch(String methodName) {
+        grammar.isMethodMatch(methodName)
+    }
+
+    @Override
+    Object invoke(Class clazz, String methodName, Object[] arguments) {
+        invoke(clazz, methodName, (Closure) null, arguments)
+    }
+
+    @Override
+    Object invoke(Class clazz, String methodName, Closure additionalCriteria, Object[] arguments) {
+        DynamicFinderInvocation invocation = grammar.createFinderInvocation(clazz, methodName, additionalCriteria, arguments)
+        doInvoke(clazz, invocation)
+    }
+
+    /**
+     * Not part of {@link FinderMethod} - called reflectively (dynamic Groovy dispatch) by {@link
+     * org.grails.datastore.gorm.query.criteria.AbstractDetachedCriteria#methodMissing}. See {@link
+     * RxSingleResultFinder#invoke(Class, String, DetachedCriteria, Object[])} for the full
+     * rationale, including why this is typed against core's {@link DetachedCriteria}.
+     */
+    Object invoke(Class clazz, String methodName, DetachedCriteria detachedCriteria, Object[] arguments) {
+        DynamicFinderInvocation invocation = grammar.createFinderInvocation(clazz, methodName, null, arguments)
+        if (detachedCriteria != null) {
+            invocation.setDetachedCriteria(detachedCriteria)
+        }
+        doInvoke(clazz, invocation)
+    }
+
+    private Object doInvoke(Class clazz, DynamicFinderInvocation invocation) {
+        Query query = datastoreClient.createQuery(clazz)
+        DynamicFinder.applyAdditionalCriteria(query, invocation.criteria)
+        DynamicFinder.applyDetachedCriteria(query, invocation.detachedCriteria)
+        DynamicFinder.configureQueryWithArguments(clazz, query, invocation.arguments)
+        query.add(grammar.getJunction(invocation))
+
+        Object[] remainingArguments = invocation.arguments
+        if (remainingArguments.length > 0 && (remainingArguments[0] instanceof Map)) {
+            ((RxQuery) query).findAll((Map) remainingArguments[0])
+        }
+        else {
+            ((RxQuery) query).findAll()
+        }
+    }
+}
diff --git a/grails-datamapping-rx/src/main/groovy/org/grails/gorm/rx/finders/RxSingleResultFinder.groovy b/grails-datamapping-rx/src/main/groovy/org/grails/gorm/rx/finders/RxSingleResultFinder.groovy
new file mode 100644
index 0000000000..d1235beed5
--- /dev/null
+++ b/grails-datamapping-rx/src/main/groovy/org/grails/gorm/rx/finders/RxSingleResultFinder.groovy
@@ -0,0 +1,186 @@
+/*
+ *  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.gorm.rx.finders
+
+import java.util.regex.Pattern
+
+import groovy.transform.CompileStatic
+
+import grails.gorm.DetachedCriteria
+import grails.gorm.rx.RxEntity
+import org.grails.datastore.gorm.finders.DynamicFinder
+import org.grails.datastore.gorm.finders.DynamicFinderInvocation
+import org.grails.datastore.gorm.finders.FinderMethod
+import org.grails.datastore.gorm.finders.MethodExpression
+import org.grails.datastore.mapping.query.Query
+import org.grails.datastore.rx.RxDatastoreClient
+import rx.Observable
+import rx.Subscriber
+
+/**
+ * Implements every RxGORM dynamic finder that returns a single result: {@code findBy*}, the
+ * {@code find<booleanProperty>By*} boolean-clause form, {@code findOrCreateBy*} and
+ * {@code findOrSaveBy*} - the rx-module mirror of {@link org.grails.datastore.gorm.finders.SingleResultFinder},
+ * composing the same {@link DynamicFinder} grammar class rather than extending core's finder
+ * classes as the previous per-type rx finder classes did.
+ *
+ * <p>Unlike the sync side, there is no {@code Session}/{@code Datastore.execute} concept here -
+ * queries are built directly via {@link RxDatastoreClient#createQuery}, and results are
+ * {@link Observable}s. The {@code findOrCreateBy*}/{@code findOrSaveBy*} empty-result handling
+ * (construct-and-maybe-save via {@code Observable.switchIfEmpty}) is preserved exactly as it
+ * existed in the previous per-type classes, including that it does NOT perform the
+ * Or-operator/comparison-expression rejection the synchronous {@code SingleResultFinder} does -
+ * that was already a pre-existing inconsistency between the sync and rx implementations, not
+ * something introduced or fixed by this refactor.
+ */
+@CompileStatic
+class RxSingleResultFinder implements FinderMethod {
+
+    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'] as String[]
+
+    private final RxDatastoreClient datastoreClient
+    private final DynamicFinder grammar
+    private final boolean handlesEmptyResult
+    private final boolean save
+
+    private RxSingleResultFinder(RxDatastoreClient datastoreClient, DynamicFinder grammar, boolean handlesEmptyResult, boolean save) {
+        this.datastoreClient = datastoreClient
+        this.grammar = grammar
+        this.handlesEmptyResult = handlesEmptyResult
+        this.save = save
+    }
+
+    static RxSingleResultFinder findBy(RxDatastoreClient datastoreClient) {
+        new RxSingleResultFinder(datastoreClient, grammar(FIND_BY_PATTERN, datastoreClient, false), false, false)
+    }
+
+    static RxSingleResultFinder findByBoolean(RxDatastoreClient datastoreClient) {
+        new RxSingleResultFinder(datastoreClient, grammar(FIND_BY_BOOLEAN_PATTERN, datastoreClient, true), false, false)
+    }
+
+    static RxSingleResultFinder findOrCreateBy(RxDatastoreClient datastoreClient) {
+        new RxSingleResultFinder(datastoreClient, grammar(FIND_OR_CREATE_BY_PATTERN, datastoreClient, false), true, false)
+    }
+
+    static RxSingleResultFinder findOrSaveBy(RxDatastoreClient datastoreClient) {
+        new RxSingleResultFinder(datastoreClient, grammar(FIND_OR_SAVE_BY_PATTERN, datastoreClient, false), true, true)
+    }
+
+    private static DynamicFinder grammar(String pattern, RxDatastoreClient datastoreClient, boolean booleanClause) {
+        new DynamicFinder(Pattern.compile(pattern), OPERATORS, datastoreClient.mappingContext, booleanClause)
+    }
+
+    @Override
+    void setPattern(String pattern) {
+        grammar.setPattern(pattern)
+    }
+
+    @Override
+    boolean isMethodMatch(String methodName) {
+        grammar.isMethodMatch(methodName)
+    }
+
+    @Override
+    Object invoke(Class clazz, String methodName, Object[] arguments) {
+        invoke(clazz, methodName, (Closure) null, arguments)
+    }
+
+    @Override
+    Object invoke(Class clazz, String methodName, Closure additionalCriteria, Object[] arguments) {
+        DynamicFinderInvocation invocation = grammar.createFinderInvocation(clazz, methodName, additionalCriteria, arguments)
+        doInvoke(clazz, invocation)
+    }
+
+    /**
+     * Not part of {@link FinderMethod} - called reflectively (dynamic Groovy dispatch) by {@link
+     * org.grails.datastore.gorm.query.criteria.AbstractDetachedCriteria#methodMissing}. Typed
+     * against core's {@link DetachedCriteria} - not rx's own {@code grails.gorm.rx.DetachedCriteria}
+     * - because {@code DynamicFinderInvocation.setDetachedCriteria} itself only accepts that type,
+     * exactly matching what the previous per-type rx finder classes had available via inheriting
+     * this same overload from core's {@code DynamicFinder}. Preserved as-is, not "fixed", since
+     * changing this narrow typing is outside the scope of this refactor.
+     */
+    Object invoke(Class clazz, String methodName, DetachedCriteria detachedCriteria, Object[] arguments) {
+        DynamicFinderInvocation invocation = grammar.createFinderInvocation(clazz, methodName, null, arguments)
+        if (detachedCriteria != null) {
+            invocation.setDetachedCriteria(detachedCriteria)
+        }
+        doInvoke(clazz, invocation)
+    }
+
+    private Object doInvoke(Class clazz, DynamicFinderInvocation invocation) {
+        Query query = datastoreClient.createQuery(clazz)
+        DynamicFinder.applyAdditionalCriteria(query, invocation.criteria)
+        DynamicFinder.applyDetachedCriteria(query, invocation.detachedCriteria)
+        DynamicFinder.configureQueryWithArguments(clazz, query, invocation.arguments)
+        query.add(grammar.getJunction(invocation))
+        Object result = query.singleResult()
+        if (!handlesEmptyResult) {
+            // Matches the previous implementation exactly: plain findBy/findByBoolean never
+            // assumed/cast the query's result to Observable, it just returned whatever
+            // singleResult() produced as Object and let the caller handle it.
+            return result
+        }
+        Observable observable = (Observable) result
+        return observable.switchIfEmpty(Observable.create({ Subscriber s ->
+            Thread.start {
+                Map m = [:]
+                List<MethodExpression> expressions = invocation.expressions
+                for (MethodExpression me in expressions) {
+                    if (!(me instanceof MethodExpression.Equal)) {
+                        throw new MissingMethodException(invocation.methodName, invocation.javaClass, invocation.arguments)
+                    }
+                    String propertyName = me.propertyName
+                    Object[] meArguments = me.getArguments()
+                    m.put(propertyName, meArguments[0])
+                }
+
+                def newInstance = invocation.javaClass.newInstance(m)
+                if (save) {
+                    def saveObservable = ((RxEntity) newInstance).save()
+                    saveObservable.subscribe(new Subscriber() {
+                        @Override
+                        void onCompleted() {
+                            s.onCompleted()
+                        }
+
+                        @Override
+                        void onError(Throwable e) {
+                            s.onError(e)
+                        }
+
+                        @Override
+                        void onNext(Object o) {
+                            s.onNext o
+                        }
+                    })
+                }
+                else {
+                    s.onNext newInstance
+                    s.onCompleted()
+                }
+            }
+        } as Observable.OnSubscribe))
+    }
+}
diff --git a/grails-datamapping-rx/src/test/groovy/org/grails/gorm/rx/finders/FindAllByBooleanFinderSpec.groovy b/grails-datamapping-rx/src/test/groovy/org/grails/gorm/rx/finders/FindAllByBooleanFinderSpec.groovy
deleted file mode 100644
index 87dc1f823c..0000000000
--- a/grails-datamapping-rx/src/test/groovy/org/grails/gorm/rx/finders/FindAllByBooleanFinderSpec.groovy
+++ /dev/null
@@ -1,96 +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.gorm.rx.finders
-
-import org.grails.datastore.gorm.finders.DynamicFinderInvocation
-import org.grails.datastore.gorm.finders.MethodExpression
-import org.grails.datastore.mapping.model.MappingContext
-import org.grails.datastore.mapping.model.PersistentEntity
-import org.grails.datastore.mapping.query.Query
-import org.grails.datastore.rx.RxDatastoreClient
-import org.grails.datastore.rx.query.RxQuery
-import org.springframework.core.convert.support.DefaultConversionService
-import rx.Observable
-import spock.lang.Specification
-
-class FindAllByBooleanFinderSpec extends Specification {
-
-    RxDatastoreClient datastoreClient = Mock()
-    MappingContext mappingContext = Mock()
-    PersistentEntity entity = Mock()
-    RxCapableQuery query = Mock()
-
-    def setup() {
-        entity.getMappingContext() >> mappingContext
-        entity.getJavaClass() >> Person
-        mappingContext.getConversionService() >> new DefaultConversionService()
-        query.getEntity() >> entity
-    }
-
-    def "overrides the inherited pattern to match boolean style method names"() {
-        when:
-        def finder = new FindAllByBooleanFinder(datastoreClient)
-
-        then:
-        1 * datastoreClient.getMappingContext() >> mappingContext
-        finder.isMethodMatch('findAllActive')
-    }
-
-    def "firstExpressionIsRequiredBoolean always reports true"() {
-        given:
-        def finder = new FindAllByBooleanFinder(datastoreClient)
-
-        expect:
-        finder.firstExpressionIsRequiredBoolean()
-    }
-
-    def "queries all results by the boolean property, combining it as a required expression with the rest via Or"() {
-        given:
-        def finder = new FindAllByBooleanFinder(datastoreClient)
-        def activeExpression = new MethodExpression.Equal(Person, 'active')
-        activeExpression.setArguments([true] as Object[])
-        def nameExpression = new MethodExpression.Equal(Person, 'name')
-        nameExpression.setArguments(['Fred'] as Object[])
-        def invocation = new DynamicFinderInvocation(Person, 'findAllActiveByNameOrCity', [] as Object[],
-                [activeExpression, nameExpression], null, 'Or')
-        def observable = Observable.just(new Person(name: 'Fred', active: true))
-
-        when:
-        def result = finder.doInvokeInternal(invocation)
-
-        then:
-        1 * datastoreClient.createQuery(Person) >> query
-        1 * query.add({ Query.Criterion it -> it instanceof Query.Conjunction && ((Query.Junction) it).criteria.size() == 2 })
-        1 * query.findAll() >> observable
-        0 * query.findAll(_)
-        result.is(observable)
-    }
-
-    private static class Person {
-        String name
-        boolean active
-    }
-
-    private static abstract class RxCapableQuery extends Query implements RxQuery {
-        protected RxCapableQuery() {
-            super(null, null)
-        }
-    }
-}
diff --git a/grails-datamapping-rx/src/test/groovy/org/grails/gorm/rx/finders/FindAllByFinderSpec.groovy b/grails-datamapping-rx/src/test/groovy/org/grails/gorm/rx/finders/FindAllByFinderSpec.groovy
deleted file mode 100644
index ab1bdc244f..0000000000
--- a/grails-datamapping-rx/src/test/groovy/org/grails/gorm/rx/finders/FindAllByFinderSpec.groovy
+++ /dev/null
@@ -1,146 +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.gorm.rx.finders
-
-import org.grails.datastore.gorm.finders.DynamicFinderInvocation
-import org.grails.datastore.gorm.finders.MethodExpression
-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.grails.datastore.mapping.query.Restrictions
-import org.grails.datastore.rx.RxDatastoreClient
-import org.grails.datastore.rx.query.RxQuery
-import org.springframework.core.convert.support.DefaultConversionService
-import rx.Observable
-import spock.lang.Specification
-
-class FindAllByFinderSpec extends Specification {
-
-    RxDatastoreClient datastoreClient = Mock()
-    MappingContext mappingContext = Mock()
-    PersistentEntity entity = Mock()
-    RxCapableQuery query = Mock()
-
-    def setup() {
-        entity.getMappingContext() >> mappingContext
-        entity.getJavaClass() >> Book
-        mappingContext.getConversionService() >> new DefaultConversionService()
-        query.getEntity() >> entity
-    }
-
-    def "obtains its mapping context from the datastore client when constructed"() {
-        when:
-        def finder = new FindAllByFinder(datastoreClient)
-
-        then:
-        1 * datastoreClient.getMappingContext() >> mappingContext
-        finder.datastoreClient.is(datastoreClient)
-    }
-
-    def "finds all without arguments by invoking findAll on the RX query"() {
-        given:
-        def finder = new FindAllByFinder(datastoreClient)
-        def titleExpression = new MethodExpression.Equal(Book, 'title')
-        titleExpression.setArguments(['Shogun'] as Object[])
-        def invocation = new DynamicFinderInvocation(Book, 'findAllByTitle', [] as Object[], [titleExpression], null, null)
-        def observable = Observable.just(new Book(title: 'Shogun'))
-
-        when:
-        def result = finder.doInvokeInternal(invocation)
-
-        then:
-        1 * datastoreClient.createQuery(Book) >> query
-        1 * query.add({ Query.Criterion it -> it instanceof Query.Junction })
-        1 * query.findAll() >> observable
-        0 * query.findAll(_)
-        result.is(observable)
-    }
-
-    def "finds all with map arguments by invoking findAll(Map) on the RX query and applying the arguments"() {
-        given:
-        def finder = new FindAllByFinder(datastoreClient)
-        def invocation = new DynamicFinderInvocation(Book, 'findAllByTitle', [[max: 5]] as Object[], [], null, null)
-        def observable = Observable.just(new Book(title: 'Shogun'))
-
-        when:
-        def result = finder.doInvokeInternal(invocation)
-
-        then:
-        1 * datastoreClient.createQuery(Book) >> query
-        1 * query.max(5)
-        1 * query.findAll([max: 5]) >> observable
-        0 * query.findAll()
-        result.is(observable)
-    }
-
-    def "applies detached criteria to the query when present"() {
-        given:
-        def finder = new FindAllByFinder(datastoreClient)
-        def invocation = new DynamicFinderInvocation(Book, 'findAllByTitle', [] as Object[], [], null, null)
-        def detachedCriteria = Stub(grails.gorm.DetachedCriteria) {
-            getFetchStrategies() >> [:]
-            getCriteria() >> [Restrictions.eq('author', 'Clavell')]
-            getProjections() >> []
-            getOrders() >> []
-        }
-        invocation.setDetachedCriteria(detachedCriteria)
-
-        when:
-        finder.doInvokeInternal(invocation)
-
-        then:
-        1 * datastoreClient.createQuery(Book) >> query
-        1 * query.add({ Query.Criterion it -> it instanceof Query.PropertyCriterion })
-        1 * query.add({ Query.Criterion it -> it instanceof Query.Junction })
-        1 * query.findAll() >> Observable.empty()
-    }
-
-    def "applies additional criteria to the query when present"() {
-        given:
-        def finder = new FindAllByFinder(datastoreClient)
-        def invocation = new DynamicFinderInvocation(Book, 'findAllByTitle', [] as Object[], [], { -> }, null)
-        def session = Stub(Session) {
-            getMappingContext() >> mappingContext
-        }
-        mappingContext.getPersistentEntity(_) >> entity
-        query.getSession() >> session
-
-        when:
-        finder.doInvokeInternal(invocation)
-
-        then:
-        1 * datastoreClient.createQuery(Book) >> query
-        1 * query.getSession() >> session
-        1 * query.findAll() >> Observable.empty()
-        noExceptionThrown()
-    }
-
-    private static class Book {
-        String title
-        String author
-    }
-
-    private static abstract class RxCapableQuery extends Query implements RxQuery {
-        protected RxCapableQuery() {
-            super(null, null)
-        }
-    }
-}
diff --git a/grails-datamapping-rx/src/test/groovy/org/grails/gorm/rx/finders/FindByBooleanFinderSpec.groovy b/grails-datamapping-rx/src/test/groovy/org/grails/gorm/rx/finders/FindByBooleanFinderSpec.groovy
deleted file mode 100644
index 34ea85fdf2..0000000000
--- a/grails-datamapping-rx/src/test/groovy/org/grails/gorm/rx/finders/FindByBooleanFinderSpec.groovy
+++ /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.gorm.rx.finders
-
-import org.grails.datastore.gorm.finders.DynamicFinderInvocation
-import org.grails.datastore.gorm.finders.MethodExpression
-import org.grails.datastore.mapping.model.MappingContext
-import org.grails.datastore.mapping.model.PersistentEntity
-import org.grails.datastore.mapping.query.Query
-import org.grails.datastore.rx.RxDatastoreClient
-import org.springframework.core.convert.support.DefaultConversionService
-import spock.lang.Specification
-
-class FindByBooleanFinderSpec extends Specification {
-
-    RxDatastoreClient datastoreClient = Mock()
-    MappingContext mappingContext = Mock()
-    PersistentEntity entity = Mock()
-    Query query = Mock()
-
-    def setup() {
-        entity.getMappingContext() >> mappingContext
-        entity.getJavaClass() >> Person
-        mappingContext.getConversionService() >> new DefaultConversionService()
-        query.getEntity() >> entity
-    }
-
-    def "overrides the inherited pattern to match boolean style method names"() {
-        when:
-        def finder = new FindByBooleanFinder(datastoreClient)
-
-        then:
-        1 * datastoreClient.getMappingContext() >> mappingContext
-        finder.isMethodMatch('findActive')
-    }
-
-    def "firstExpressionIsRequiredBoolean always reports true"() {
-        given:
-        def finder = new FindByBooleanFinder(datastoreClient)
-
-        expect:
-        finder.firstExpressionIsRequiredBoolean()
-    }
-
-    def "queries by the boolean property, combining it as a required expression with the rest via Or"() {
-        given:
-        def finder = new FindByBooleanFinder(datastoreClient)
-        def activeExpression = new MethodExpression.Equal(Person, 'active')
-        activeExpression.setArguments([true] as Object[])
-        def nameExpression = new MethodExpression.Equal(Person, 'name')
-        nameExpression.setArguments(['Fred'] as Object[])
-        def invocation = new DynamicFinderInvocation(Person, 'findActiveByNameOrCity', [] as Object[],
-                [activeExpression, nameExpression], null, 'Or')
-
-        when:
-        def result = finder.doInvokeInternal(invocation)
-
-        then:
-        1 * datastoreClient.createQuery(Person) >> query
-        1 * query.add({ Query.Criterion it -> it instanceof Query.Conjunction && ((Query.Junction) it).criteria.size() == 2 })
-        1 * query.singleResult() >> new Person(name: 'Fred', active: true)
-        result.name == 'Fred'
-    }
-
-    private static class Person {
-        String name
-        boolean active
-    }
-}
diff --git a/grails-datamapping-rx/src/test/groovy/org/grails/gorm/rx/finders/FindByFinderSpec.groovy b/grails-datamapping-rx/src/test/groovy/org/grails/gorm/rx/finders/FindByFinderSpec.groovy
deleted file mode 100644
index b4d95cd886..0000000000
--- a/grails-datamapping-rx/src/test/groovy/org/grails/gorm/rx/finders/FindByFinderSpec.groovy
+++ /dev/null
@@ -1,133 +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.gorm.rx.finders
-
-import org.grails.datastore.gorm.finders.DynamicFinderInvocation
-import org.grails.datastore.gorm.finders.MethodExpression
-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.grails.datastore.mapping.query.Restrictions
-import org.grails.datastore.rx.RxDatastoreClient
-import org.springframework.core.convert.support.DefaultConversionService
-import spock.lang.Specification
-
-class FindByFinderSpec extends Specification {
-
-    RxDatastoreClient datastoreClient = Mock()
-    MappingContext mappingContext = Mock()
-    PersistentEntity entity = Mock()
-    Query query = Mock()
-
-    def setup() {
-        entity.getMappingContext() >> mappingContext
-        entity.getJavaClass() >> Book
-        mappingContext.getConversionService() >> new DefaultConversionService()
-        query.getEntity() >> entity
-    }
-
-    def "obtains its mapping context from the datastore client when constructed"() {
-        when:
-        def finder = new FindByFinder(datastoreClient)
-
-        then:
-        1 * datastoreClient.getMappingContext() >> mappingContext
-        finder.datastoreClient.is(datastoreClient)
-    }
-
-    def "finds by building and executing a query via the RX datastore client instead of a session"() {
-        given:
-        def finder = new FindByFinder(datastoreClient)
-        def titleExpression = new MethodExpression.Equal(Book, 'title')
-        titleExpression.setArguments(['Shogun'] as Object[])
-        def invocation = new DynamicFinderInvocation(Book, 'findByTitle', [] as Object[], [titleExpression], null, null)
-
-        when:
-        def result = finder.doInvokeInternal(invocation)
-
-        then:
-        1 * datastoreClient.createQuery(Book) >> query
-        1 * query.add({ Query.Criterion it -> it instanceof Query.Junction })
-        1 * query.singleResult() >> new Book(title: 'Shogun')
-        result.title == 'Shogun'
-    }
-
-    def "applies detached criteria to the query when present"() {
-        given:
-        def finder = new FindByFinder(datastoreClient)
-        def invocation = new DynamicFinderInvocation(Book, 'findByTitle', [] as Object[], [], null, null)
-        def detachedCriteria = Stub(grails.gorm.DetachedCriteria) {
-            getFetchStrategies() >> [:]
-            getCriteria() >> [Restrictions.eq('author', 'Clavell')]
-            getProjections() >> []
-            getOrders() >> []
-        }
-        invocation.setDetachedCriteria(detachedCriteria)
-
-        when:
-        finder.doInvokeInternal(invocation)
-
-        then:
-        1 * datastoreClient.createQuery(Book) >> query
-        1 * query.add({ Query.Criterion it -> it instanceof Query.PropertyCriterion })
-        1 * query.add({ Query.Criterion it -> it instanceof Query.Junction })
-        1 * query.singleResult()
-    }
-
-    def "applies additional criteria to the query when present"() {
-        given:
-        def finder = new FindByFinder(datastoreClient)
-        def invocation = new DynamicFinderInvocation(Book, 'findByTitle', [] as Object[], [], { -> }, null)
-        def session = Stub(Session) {
-            getMappingContext() >> mappingContext
-        }
-        mappingContext.getPersistentEntity(_) >> entity
-        query.getSession() >> session
-
-        when:
-        finder.doInvokeInternal(invocation)
-
-        then:
-        1 * datastoreClient.createQuery(Book) >> query
-        1 * query.getSession() >> session
-        1 * query.singleResult()
-        noExceptionThrown()
-    }
-
-    def "applies query arguments to the query when present"() {
-        given:
-        def finder = new FindByFinder(datastoreClient)
-        def invocation = new DynamicFinderInvocation(Book, 'findByTitle', [[max: 5]] as Object[], [], null, null)
-
-        when:
-        finder.doInvokeInternal(invocation)
-
-        then:
-        1 * datastoreClient.createQuery(Book) >> query
-        1 * query.max(5)
-        1 * query.singleResult()
-    }
-
-    private static class Book {
-        String title
-        String author
-    }
-}
diff --git a/grails-datamapping-rx/src/test/groovy/org/grails/gorm/rx/finders/FindOrCreateByFinderSpec.groovy b/grails-datamapping-rx/src/test/groovy/org/grails/gorm/rx/finders/FindOrCreateByFinderSpec.groovy
deleted file mode 100644
index dc1c4acfa2..0000000000
--- a/grails-datamapping-rx/src/test/groovy/org/grails/gorm/rx/finders/FindOrCreateByFinderSpec.groovy
+++ /dev/null
@@ -1,185 +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.gorm.rx.finders
-
-import grails.gorm.rx.RxEntity
-import org.grails.datastore.gorm.finders.DynamicFinderInvocation
-import org.grails.datastore.gorm.finders.MethodExpression
-import org.grails.datastore.mapping.model.MappingContext
-import org.grails.datastore.mapping.model.PersistentEntity
-import org.grails.datastore.mapping.query.Query
-import org.grails.datastore.rx.RxDatastoreClient
-import org.springframework.core.convert.support.DefaultConversionService
-import rx.Observable
-import spock.lang.Specification
-
-class FindOrCreateByFinderSpec extends Specification {
-
-    RxDatastoreClient datastoreClient = Mock()
-    MappingContext mappingContext = Mock()
-    PersistentEntity entity = Mock()
-    Query query = Mock()
-
-    def setup() {
-        entity.getMappingContext() >> mappingContext
-        entity.getJavaClass() >> Person
-        mappingContext.getConversionService() >> new DefaultConversionService()
-        query.getEntity() >> entity
-    }
-
-    def "overrides the inherited pattern to match findOrCreateBy method names"() {
-        when:
-        def finder = new FindOrCreateByFinder(datastoreClient)
-
-        then:
-        1 * datastoreClient.getMappingContext() >> mappingContext
-        finder.isMethodMatch('findOrCreateByName')
-        !finder.isMethodMatch('findByName')
-    }
-
-    def "shouldSaveOnCreate defaults to false"() {
-        given:
-        def finder = new FindOrCreateByFinder(datastoreClient)
-
-        expect:
-        !finder.shouldSaveOnCreate()
-    }
-
-    def "returns the existing entity when the underlying query already finds a match"() {
-        given:
-        def finder = new FindOrCreateByFinder(datastoreClient)
-        def invocation = nameEqualsFredInvocation(Person)
-        def existing = new Person(name: 'Fred')
-
-        when:
-        Observable observable = finder.doInvokeInternal(invocation) as Observable
-        Person result = observable.toBlocking().first() as Person
-
-        then:
-        1 * datastoreClient.createQuery(Person) >> query
-        1 * query.singleResult() >> Observable.just(existing)
-        result.is(existing)
-    }
-
-    def "creates a new instance from the query arguments when no match exists and saving is not required"() {
-        given:
-        def finder = new FindOrCreateByFinder(datastoreClient)
-        def invocation = nameEqualsFredInvocation(Person)
-
-        when:
-        Observable observable = finder.doInvokeInternal(invocation) as Observable
-        Person result = observable.toBlocking().first() as Person
-
-        then:
-        1 * datastoreClient.createQuery(Person) >> query
-        1 * query.singleResult() >> Observable.empty()
-        result.name == 'Fred'
-    }
-
-    def "signals completion after emitting the new instance when saving is not required"() {
-        given:
-        def finder = new FindOrCreateByFinder(datastoreClient)
-        def invocation = nameEqualsFredInvocation(Person)
-
-        when:
-        Observable observable = finder.doInvokeInternal(invocation) as Observable
-        def subscriber = new rx.observers.TestSubscriber()
-        observable.subscribe(subscriber)
-        subscriber.awaitTerminalEvent(5, java.util.concurrent.TimeUnit.SECONDS)
-
-        then:
-        1 * datastoreClient.createQuery(Person) >> query
-        1 * query.singleResult() >> Observable.empty()
-        subscriber.assertCompleted()
-        subscriber.assertNoErrors()
-        subscriber.onNextEvents.size() == 1
-        (subscriber.onNextEvents[0] as Person).name == 'Fred'
-    }
-
-    def "creates, saves and returns a new instance when shouldSaveOnCreate is overridden to return true"() {
-        given:
-        def finder = new SavingFindOrCreateByFinder(datastoreClient)
-        def invocation = nameEqualsFredInvocation(PersonThatSavesSuccessfully)
-
-        when:
-        Observable observable = finder.doInvokeInternal(invocation) as Observable
-        PersonThatSavesSuccessfully result = observable.toBlocking().first() as PersonThatSavesSuccessfully
-
-        then:
-        1 * datastoreClient.createQuery(PersonThatSavesSuccessfully) >> query
-        1 * query.singleResult() >> Observable.empty()
-        result.name == 'Fred'
-    }
-
-    def "propagates the error when saving the newly created instance fails"() {
-        given:
-        def finder = new SavingFindOrCreateByFinder(datastoreClient)
-        def invocation = nameEqualsFredInvocation(PersonThatFailsToSave)
-
-        when:
-        Observable observable = finder.doInvokeInternal(invocation) as Observable
-        observable.toBlocking().first()
-
-        then:
-        1 * datastoreClient.createQuery(PersonThatFailsToSave) >> query
-        1 * query.singleResult() >> Observable.empty()
-        def ex = thrown(IllegalStateException)
-        ex.message == 'save failed'
-    }
-
-    private static DynamicFinderInvocation nameEqualsFredInvocation(Class type) {
-        def nameExpression = new MethodExpression.Equal(type, 'name')
-        nameExpression.setArguments(['Fred'] as Object[])
-        new DynamicFinderInvocation(type, 'findOrCreateByName', [] as Object[], [nameExpression], null, null)
-    }
-
-    private static class Person {
-        String name
-    }
-
-    private static class PersonThatSavesSuccessfully implements RxEntity<PersonThatSavesSuccessfully> {
-        String name
-
-        @Override
-        Observable<PersonThatSavesSuccessfully> save(Map<String, Object> arguments) {
-            Observable.just(this)
-        }
-    }
-
-    private static class PersonThatFailsToSave implements RxEntity<PersonThatFailsToSave> {
-        String name
-
-        @Override
-        Observable<PersonThatFailsToSave> save(Map<String, Object> arguments) {
-            Observable.error(new IllegalStateException('save failed'))
-        }
-    }
-
-    private static class SavingFindOrCreateByFinder extends FindOrCreateByFinder {
-        SavingFindOrCreateByFinder(RxDatastoreClient datastoreClient) {
-            super(datastoreClient)
-        }
-
-        @Override
-        protected boolean shouldSaveOnCreate() {
-            true
-        }
-    }
-}
diff --git a/grails-datamapping-rx/src/test/groovy/org/grails/gorm/rx/finders/FindOrSaveByFinderSpec.groovy b/grails-datamapping-rx/src/test/groovy/org/grails/gorm/rx/finders/FindOrSaveByFinderSpec.groovy
deleted file mode 100644
index 6b6861d3b0..0000000000
--- a/grails-datamapping-rx/src/test/groovy/org/grails/gorm/rx/finders/FindOrSaveByFinderSpec.groovy
+++ /dev/null
@@ -1,114 +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.gorm.rx.finders
-
-import grails.gorm.rx.RxEntity
-import org.grails.datastore.gorm.finders.DynamicFinderInvocation
-import org.grails.datastore.gorm.finders.MethodExpression
-import org.grails.datastore.mapping.model.MappingContext
-import org.grails.datastore.mapping.model.PersistentEntity
-import org.grails.datastore.mapping.query.Query
-import org.grails.datastore.rx.RxDatastoreClient
-import org.springframework.core.convert.support.DefaultConversionService
-import rx.Observable
-import spock.lang.Specification
-
-class FindOrSaveByFinderSpec extends Specification {
-
-    RxDatastoreClient datastoreClient = Mock()
-    MappingContext mappingContext = Mock()
-    PersistentEntity entity = Mock()
-    Query query = Mock()
-
-    def setup() {
-        entity.getMappingContext() >> mappingContext
-        entity.getJavaClass() >> Person
-        mappingContext.getConversionService() >> new DefaultConversionService()
-        query.getEntity() >> entity
-    }
-
-    def "overrides the inherited pattern to match findOrSaveBy method names"() {
-        when:
-        def finder = new FindOrSaveByFinder(datastoreClient)
-
-        then:
-        1 * datastoreClient.getMappingContext() >> mappingContext
-        finder.isMethodMatch('findOrSaveByName')
-        !finder.isMethodMatch('findOrCreateByName')
-    }
-
-    def "shouldSaveOnCreate is overridden to return true"() {
-        given:
-        def finder = new FindOrSaveByFinder(datastoreClient)
-
-        expect:
-        finder.shouldSaveOnCreate()
-    }
-
-    def "returns the existing entity when the underlying query already finds a match"() {
-        given:
-        def finder = new FindOrSaveByFinder(datastoreClient)
-        def invocation = nameEqualsFredInvocation(Person)
-        def existing = new Person(name: 'Fred')
-
-        when:
-        Observable observable = finder.doInvokeInternal(invocation) as Observable
-        Person result = observable.toBlocking().first() as Person
-
-        then:
-        1 * datastoreClient.createQuery(Person) >> query
-        1 * query.singleResult() >> Observable.just(existing)
-        result.is(existing)
-    }
-
-    def "creates, saves and returns a new instance when no match exists"() {
-        given:
-        def finder = new FindOrSaveByFinder(datastoreClient)
-        def invocation = nameEqualsFredInvocation(PersonThatSavesSuccessfully)
-
-        when:
-        Observable observable = finder.doInvokeInternal(invocation) as Observable
-        PersonThatSavesSuccessfully result = observable.toBlocking().first() as PersonThatSavesSuccessfully
-
-        then:
-        1 * datastoreClient.createQuery(PersonThatSavesSuccessfully) >> query
-        1 * query.singleResult() >> Observable.empty()
-        result.name == 'Fred'
-    }
-
-    private static DynamicFinderInvocation nameEqualsFredInvocation(Class type) {
-        def nameExpression = new MethodExpression.Equal(type, 'name')
-        nameExpression.setArguments(['Fred'] as Object[])
-        new DynamicFinderInvocation(type, 'findOrSaveByName', [] as Object[], [nameExpression], null, null)
-    }
-
-    private static class Person {
-        String name
-    }
-
-    private static class PersonThatSavesSuccessfully implements RxEntity<PersonThatSavesSuccessfully> {
-        String name
-
-        @Override
-        Observable<PersonThatSavesSuccessfully> save(Map<String, Object> arguments) {
-            Observable.just(this)
-        }
-    }
-}
diff --git a/grails-datamapping-rx/src/test/groovy/org/grails/gorm/rx/finders/CountByFinderSpec.groovy b/grails-datamapping-rx/src/test/groovy/org/grails/gorm/rx/finders/RxCountFinderSpec.groovy
similarity index 57%
rename from grails-datamapping-rx/src/test/groovy/org/grails/gorm/rx/finders/CountByFinderSpec.groovy
rename to grails-datamapping-rx/src/test/groovy/org/grails/gorm/rx/finders/RxCountFinderSpec.groovy
index 8b1f39e088..8ec6856b91 100644
--- a/grails-datamapping-rx/src/test/groovy/org/grails/gorm/rx/finders/CountByFinderSpec.groovy
+++ b/grails-datamapping-rx/src/test/groovy/org/grails/gorm/rx/finders/RxCountFinderSpec.groovy
@@ -19,50 +19,54 @@
 
 package org.grails.gorm.rx.finders
 
-import org.grails.datastore.gorm.finders.DynamicFinderInvocation
-import org.grails.datastore.gorm.finders.MethodExpression
 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.grails.datastore.mapping.query.Restrictions
 import org.grails.datastore.rx.RxDatastoreClient
 import org.springframework.core.convert.support.DefaultConversionService
 import spock.lang.Specification
 
-class CountByFinderSpec extends Specification {
+/**
+ * Exercises {@link RxCountFinder} - the rx-module mirror of {@link
+ * org.grails.datastore.gorm.finders.CountFinder}, reusing that class's {@code
+ * applyCriteriaAndCount} helper rather than duplicating its independent And/Or criteria handling.
+ */
+class RxCountFinderSpec extends Specification {
 
     RxDatastoreClient datastoreClient = Mock()
     MappingContext mappingContext = Mock()
     PersistentEntity entity = Mock()
     Query query = Mock()
 
+    PersistentProperty nameProperty = Stub(PersistentProperty) {
+        getName() >> 'name'
+        getType() >> String
+    }
+
     def setup() {
         entity.getMappingContext() >> mappingContext
-        entity.getJavaClass() >> Person
         mappingContext.getConversionService() >> new DefaultConversionService()
+        mappingContext.getPersistentEntity(_) >> entity
+        entity.getPropertyByName('name') >> nameProperty
         query.getEntity() >> entity
         query.projections() >> new Query.ProjectionList()
+        datastoreClient.getMappingContext() >> mappingContext
     }
 
-    def "obtains its mapping context from the datastore client when constructed"() {
-        when:
-        def finder = new CountByFinder(datastoreClient)
-
-        then:
-        1 * datastoreClient.getMappingContext() >> mappingContext
-        finder.datastoreClient.is(datastoreClient)
+    void "isMethodMatch matches countBy* method names"() {
+        expect:
+        RxCountFinder.countBy(datastoreClient).isMethodMatch('countByName')
+        !RxCountFinder.countBy(datastoreClient).isMethodMatch('somethingElse')
     }
 
-    def "counts by building and executing a query via the RX datastore client instead of a session"() {
+    void "counts by building and executing a query via the RX datastore client instead of a session"() {
         given:
-        def finder = new CountByFinder(datastoreClient)
-        def nameExpression = new MethodExpression.Equal(Person, 'name')
-        nameExpression.setArguments(['Fred'] as Object[])
-        def invocation = new DynamicFinderInvocation(Person, 'countByName', [] as Object[], [nameExpression], null, null)
+        def finder = RxCountFinder.countBy(datastoreClient)
 
         when:
-        def result = finder.doInvokeInternal(invocation)
+        def result = finder.invoke(Person, 'countByName', ['Fred'] as Object[])
 
         then:
         1 * datastoreClient.createQuery(Person) >> query
@@ -71,39 +75,39 @@ class CountByFinderSpec extends Specification {
         result == 4L
     }
 
-    def "applies detached criteria to the query when present"() {
+    void "invoke(Class, methodName, DetachedCriteria, Object[]) merges the detached criteria onto the built query"() {
         given:
-        def finder = new CountByFinder(datastoreClient)
-        def invocation = new DynamicFinderInvocation(Person, 'countByName', [] as Object[], [], null, null)
+        // Called reflectively (dynamic Groovy dispatch, not part of FinderMethod) by
+        // AbstractDetachedCriteria#methodMissing.
+        def finder = RxCountFinder.countBy(datastoreClient)
         def detachedCriteria = Stub(grails.gorm.DetachedCriteria) {
             getFetchStrategies() >> [:]
-            getCriteria() >> [Restrictions.eq('active', true)]
+            getCriteria() >> [org.grails.datastore.mapping.query.Restrictions.eq('active', true)]
             getProjections() >> []
             getOrders() >> []
         }
-        invocation.setDetachedCriteria(detachedCriteria)
 
         when:
-        finder.doInvokeInternal(invocation)
+        def result = finder.invoke(Person, 'countByName', detachedCriteria, ['Fred'] as Object[])
 
         then:
         1 * datastoreClient.createQuery(Person) >> query
-        1 * query.add({ Query.Criterion it -> it instanceof Query.PropertyCriterion })
-        1 * query.singleResult()
+        2 * query.add({ Query.Criterion it -> it instanceof Query.PropertyCriterion })
+        1 * query.singleResult() >> 4L
+        result == 4L
     }
 
-    def "applies additional criteria to the query when present"() {
+    void "applies additional criteria to the query when present"() {
         given:
-        def finder = new CountByFinder(datastoreClient)
-        def invocation = new DynamicFinderInvocation(Person, 'countByName', [] as Object[], [], { -> }, null)
+        def finder = RxCountFinder.countBy(datastoreClient)
         def session = Stub(Session) {
             getMappingContext() >> mappingContext
         }
-        mappingContext.getPersistentEntity(_) >> entity
         query.getSession() >> session
+        entity.getJavaClass() >> Person
 
         when:
-        finder.doInvokeInternal(invocation)
+        finder.invoke(Person, 'countByName', { -> }, ['Fred'] as Object[])
 
         then:
         1 * datastoreClient.createQuery(Person) >> query
@@ -112,13 +116,12 @@ class CountByFinderSpec extends Specification {
         noExceptionThrown()
     }
 
-    def "applies query arguments to the query when present"() {
+    void "applies query arguments to the query when present"() {
         given:
-        def finder = new CountByFinder(datastoreClient)
-        def invocation = new DynamicFinderInvocation(Person, 'countByName', [[max: 5]] as Object[], [], null, null)
+        def finder = RxCountFinder.countBy(datastoreClient)
 
         when:
-        finder.doInvokeInternal(invocation)
+        finder.invoke(Person, 'countByName', ['Fred', [max: 5]] as Object[])
 
         then:
         1 * datastoreClient.createQuery(Person) >> query
diff --git a/grails-datamapping-rx/src/test/groovy/org/grails/gorm/rx/finders/RxListResultFinderSpec.groovy b/grails-datamapping-rx/src/test/groovy/org/grails/gorm/rx/finders/RxListResultFinderSpec.groovy
new file mode 100644
index 0000000000..bd11405c3b
--- /dev/null
+++ b/grails-datamapping-rx/src/test/groovy/org/grails/gorm/rx/finders/RxListResultFinderSpec.groovy
@@ -0,0 +1,196 @@
+/*
+ *  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.gorm.rx.finders
+
+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.grails.datastore.rx.RxDatastoreClient
+import org.grails.datastore.rx.query.RxQuery
+import org.springframework.core.convert.support.DefaultConversionService
+import rx.Observable
+import spock.lang.Specification
+
+/**
+ * Exercises {@link RxListResultFinder} - the rx-module mirror of {@link
+ * org.grails.datastore.gorm.finders.ListResultFinder}, composing {@link
+ * org.grails.datastore.gorm.finders.DynamicFinder} rather than extending core's finder classes as
+ * the previous per-type rx finder classes (FindAllByFinder/FindAllByBooleanFinder) did. Tests go
+ * through the real public invoke() entry point with real method names.
+ */
+class RxListResultFinderSpec extends Specification {
+
+    RxDatastoreClient datastoreClient = Mock()
+    MappingContext mappingContext = Mock()
+    PersistentEntity entity = Mock()
+    RxCapableQuery query = Mock()
+
+    PersistentProperty titleProperty = Stub(PersistentProperty) {
+        getName() >> 'title'
+        getType() >> String
+    }
+    PersistentProperty nameProperty = Stub(PersistentProperty) {
+        getName() >> 'name'
+        getType() >> String
+    }
+    PersistentProperty activeProperty = Stub(PersistentProperty) {
+        getName() >> 'active'
+        getType() >> Boolean
+    }
+    PersistentProperty cityProperty = Stub(PersistentProperty) {
+        getName() >> 'city'
+        getType() >> String
+    }
+
+    def setup() {
+        entity.getMappingContext() >> mappingContext
+        mappingContext.getConversionService() >> new DefaultConversionService()
+        mappingContext.getPersistentEntity(_) >> entity
+        entity.getPropertyByName('title') >> titleProperty
+        entity.getPropertyByName('name') >> nameProperty
+        entity.getPropertyByName('active') >> activeProperty
+        entity.getPropertyByName('city') >> cityProperty
+        query.getEntity() >> entity
+        datastoreClient.getMappingContext() >> mappingContext
+    }
+
+    void "findAllBy isMethodMatch matches findAllBy* method names"() {
+        expect:
+        RxListResultFinder.findAllBy(datastoreClient).isMethodMatch('findAllByTitle')
+        !RxListResultFinder.findAllBy(datastoreClient).isMethodMatch('somethingElse')
+    }
+
+    void "findAllBy finds all without remaining arguments by invoking findAll on the RX query"() {
+        given:
+        def finder = RxListResultFinder.findAllBy(datastoreClient)
+        def observable = Observable.just(new Book(title: 'Shogun'))
+
+        when:
+        def result = finder.invoke(Book, 'findAllByTitle', ['Shogun'] as Object[])
+
+        then:
+        1 * datastoreClient.createQuery(Book) >> query
+        1 * query.add({ Query.Criterion it -> it instanceof Query.Junction })
+        1 * query.findAll() >> observable
+        0 * query.findAll(_)
+        result.is(observable)
+    }
+
+    void "findAllBy 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.
+        def finder = RxListResultFinder.findAllBy(datastoreClient)
+        def observable = Observable.just(new Book(title: 'Shogun'))
+        def detachedCriteria = Stub(grails.gorm.DetachedCriteria) {
+            getFetchStrategies() >> [:]
+            getCriteria() >> [org.grails.datastore.mapping.query.Restrictions.eq('author', 'Clavell')]
+            getProjections() >> []
+            getOrders() >> []
+        }
+
+        when:
+        def result = finder.invoke(Book, 'findAllByTitle', detachedCriteria, ['Shogun'] as Object[])
+
+        then:
+        1 * datastoreClient.createQuery(Book) >> query
+        1 * query.add({ Query.Criterion it -> it instanceof Query.PropertyCriterion })
+        1 * query.add({ Query.Criterion it -> it instanceof Query.Junction })
+        1 * query.findAll() >> observable
+        result.is(observable)
+    }
+
+    void "findAllBy finds all with remaining map arguments by invoking findAll(Map) on the RX query"() {
+        given:
+        def finder = RxListResultFinder.findAllBy(datastoreClient)
+        def observable = Observable.just(new Book(title: 'Shogun'))
+
+        when:
+        def result = finder.invoke(Book, 'findAllByTitle', ['Shogun', [max: 5]] as Object[])
+
+        then:
+        1 * datastoreClient.createQuery(Book) >> query
+        1 * query.max(5)
+        1 * query.findAll([max: 5]) >> observable
+        0 * query.findAll()
+        result.is(observable)
+    }
+
+    void "findAllBy applies additional criteria to the query when present"() {
+        given:
+        def finder = RxListResultFinder.findAllBy(datastoreClient)
+        def session = Stub(Session) {
+            getMappingContext() >> mappingContext
+        }
+        query.getSession() >> session
+        entity.getJavaClass() >> Book
+
+        when:
+        finder.invoke(Book, 'findAllByTitle', { -> }, ['Shogun'] as Object[])
+
+        then:
+        1 * datastoreClient.createQuery(Book) >> query
+        1 * query.getSession() >> session
+        1 * query.findAll() >> Observable.empty()
+        noExceptionThrown()
+    }
+
+    void "findAllByBoolean isMethodMatch matches boolean style method names"() {
+        expect:
+        RxListResultFinder.findAllByBoolean(datastoreClient).isMethodMatch('findAllActive')
+    }
+
+    void "findAllByBoolean queries all results by the boolean property, combining it as a required expression with the rest via Or"() {
+        given:
+        def finder = RxListResultFinder.findAllByBoolean(datastoreClient)
+        def observable = Observable.just(new Person(name: 'Fred', active: true))
+
+        when:
+        // The boolean clause's own argument is not consumed from the array - it's hardcoded
+        // TRUE/FALSE by the grammar based on a Not-prefix, so only "Name"/"City" (Or'd, 1 arg each)
+        // need real arguments here.
+        def result = finder.invoke(Person, 'findAllActiveByNameOrCity', ['Fred', 'London'] as Object[])
+
+        then:
+        1 * datastoreClient.createQuery(Person) >> query
+        1 * query.add({ Query.Criterion it -> it instanceof Query.Conjunction && ((Query.Junction) it).criteria.size() == 2 })
+        1 * query.findAll() >> observable
+        0 * query.findAll(_)
+        result.is(observable)
+    }
+
+    private static class Book {
+        String title
+        String author
+    }
+
+    private static class Person {
+        String name
+        boolean active
+    }
+
+    private static abstract class RxCapableQuery extends Query implements RxQuery {
+        protected RxCapableQuery() {
+            super(null, null)
+        }
+    }
+}
diff --git a/grails-datamapping-rx/src/test/groovy/org/grails/gorm/rx/finders/RxSingleResultFinderSpec.groovy b/grails-datamapping-rx/src/test/groovy/org/grails/gorm/rx/finders/RxSingleResultFinderSpec.groovy
new file mode 100644
index 0000000000..11e856392b
--- /dev/null
+++ b/grails-datamapping-rx/src/test/groovy/org/grails/gorm/rx/finders/RxSingleResultFinderSpec.groovy
@@ -0,0 +1,325 @@
+/*
+ *  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.gorm.rx.finders
+
+import grails.gorm.rx.RxEntity
+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.grails.datastore.rx.RxDatastoreClient
+import org.springframework.core.convert.support.DefaultConversionService
+import rx.Observable
+import spock.lang.Specification
+
+/**
+ * Exercises {@link RxSingleResultFinder} - the rx-module mirror of {@link
+ * org.grails.datastore.gorm.finders.SingleResultFinder}, composing {@link
+ * org.grails.datastore.gorm.finders.DynamicFinder} rather than extending core's finder classes as
+ * the previous per-type rx finder classes (FindByFinder/FindByBooleanFinder/FindOrCreateByFinder/
+ * FindOrSaveByFinder) did. Unlike those classes' own specs, which hand-built a
+ * DynamicFinderInvocation and called the package-private doInvokeInternal directly, these tests go
+ * through the real public invoke() entry point with real method names - doInvokeInternal no longer
+ * exists as a separate seam, and this also means the method-name parsing itself is now exercised,
+ * not just the query-building/execution half.
+ */
+class RxSingleResultFinderSpec extends Specification {
+
+    RxDatastoreClient datastoreClient = Mock()
+    MappingContext mappingContext = Mock()
+    PersistentEntity entity = Mock()
+    Query query = Mock()
+
+    PersistentProperty titleProperty = Stub(PersistentProperty) {
+        getName() >> 'title'
+        getType() >> String
+    }
+    PersistentProperty nameProperty = Stub(PersistentProperty) {
+        getName() >> 'name'
+        getType() >> String
+    }
+    PersistentProperty activeProperty = Stub(PersistentProperty) {
+        getName() >> 'active'
+        getType() >> Boolean
+    }
+    PersistentProperty cityProperty = Stub(PersistentProperty) {
+        getName() >> 'city'
+        getType() >> String
+    }
+
+    def setup() {
+        entity.getMappingContext() >> mappingContext
+        mappingContext.getConversionService() >> new DefaultConversionService()
+        mappingContext.getPersistentEntity(_) >> entity
+        entity.getPropertyByName('title') >> titleProperty
+        entity.getPropertyByName('name') >> nameProperty
+        entity.getPropertyByName('city') >> cityProperty
+        entity.getPropertyByName('active') >> activeProperty
+        query.getEntity() >> entity
+        datastoreClient.getMappingContext() >> mappingContext
+    }
+
+    void "findBy obtains its mapping context from the datastore client when constructed"() {
+        when:
+        RxSingleResultFinder.findBy(datastoreClient)
+
+        then:
+        1 * datastoreClient.getMappingContext() >> mappingContext
+    }
+
+    void "findBy isMethodMatch matches findBy* method names"() {
+        expect:
+        RxSingleResultFinder.findBy(datastoreClient).isMethodMatch('findByTitle')
+        !RxSingleResultFinder.findBy(datastoreClient).isMethodMatch('somethingElse')
+    }
+
+    void "findBy finds by building and executing a query via the RX datastore client instead of a session"() {
+        given:
+        def finder = RxSingleResultFinder.findBy(datastoreClient)
+
+        when:
+        def result = finder.invoke(Book, 'findByTitle', ['Shogun'] as Object[])
+
+        then:
+        1 * datastoreClient.createQuery(Book) >> query
+        1 * query.add({ Query.Criterion it -> it instanceof Query.Junction })
+        1 * query.singleResult() >> new Book(title: 'Shogun')
+        result.title == 'Shogun'
+    }
+
+    void "findBy 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.
+        def finder = RxSingleResultFinder.findBy(datastoreClient)
+        def detachedCriteria = Stub(grails.gorm.DetachedCriteria) {
+            getFetchStrategies() >> [:]
+            getCriteria() >> [org.grails.datastore.mapping.query.Restrictions.eq('author', 'Clavell')]
+            getProjections() >> []
+            getOrders() >> []
+        }
+
+        when:
+        def result = finder.invoke(Book, 'findByTitle', detachedCriteria, ['Shogun'] as Object[])
+
+        then:
+        1 * datastoreClient.createQuery(Book) >> query
+        1 * query.add({ Query.Criterion it -> it instanceof Query.PropertyCriterion })
+        1 * query.add({ Query.Criterion it -> it instanceof Query.Junction })
+        1 * query.singleResult() >> new Book(title: 'Shogun')
+        result.title == 'Shogun'
+    }
+
+    void "findBy applies additional criteria to the query when present"() {
+        given:
+        def finder = RxSingleResultFinder.findBy(datastoreClient)
+        def session = Stub(Session) {
+            getMappingContext() >> mappingContext
+        }
+        query.getSession() >> session
+        entity.getJavaClass() >> Book
+
+        when:
+        finder.invoke(Book, 'findByTitle', { -> }, ['Shogun'] as Object[])
+
+        then:
+        1 * datastoreClient.createQuery(Book) >> query
+        1 * query.getSession() >> session
+        1 * query.singleResult()
+        noExceptionThrown()
+    }
+
+    void "findBy applies query arguments to the query when present"() {
+        given:
+        def finder = RxSingleResultFinder.findBy(datastoreClient)
+
+        when:
+        finder.invoke(Book, 'findByTitle', ['Shogun', [max: 5]] as Object[])
+
+        then:
+        1 * datastoreClient.createQuery(Book) >> query
+        1 * query.max(5)
+        1 * query.singleResult()
+    }
+
+    void "findByBoolean isMethodMatch matches boolean style method names"() {
+        expect:
+        RxSingleResultFinder.findByBoolean(datastoreClient).isMethodMatch('findActive')
+    }
+
+    void "findByBoolean queries by the boolean property, combining it as a required expression with the rest via Or"() {
+        given:
+        def finder = RxSingleResultFinder.findByBoolean(datastoreClient)
+
+        when:
+        def result = finder.invoke(Person, 'findActiveByNameOrCity', ['Fred', 'London'] as Object[])
+
+        then:
+        1 * datastoreClient.createQuery(Person) >> query
+        1 * query.add({ Query.Criterion it -> it instanceof Query.Conjunction && ((Query.Junction) it).criteria.size() == 2 })
+        1 * query.singleResult() >> new Person(name: 'Fred', active: true)
+        result.name == 'Fred'
+    }
+
+    void "findOrCreateBy isMethodMatch matches findOrCreateBy method names only"() {
+        expect:
+        RxSingleResultFinder.findOrCreateBy(datastoreClient).isMethodMatch('findOrCreateByName')
+        !RxSingleResultFinder.findOrCreateBy(datastoreClient).isMethodMatch('findByName')
+    }
+
+    void "findOrCreateBy returns the existing entity when the underlying query already finds a match"() {
+        given:
+        def finder = RxSingleResultFinder.findOrCreateBy(datastoreClient)
+        def existing = new Person(name: 'Fred')
+
+        when:
+        Observable observable = finder.invoke(Person, 'findOrCreateByName', ['Fred'] as Object[]) as Observable
+        Person result = observable.toBlocking().first() as Person
+
+        then:
+        1 * datastoreClient.createQuery(Person) >> query
+        1 * query.singleResult() >> Observable.just(existing)
+        result.is(existing)
+    }
+
+    void "findOrCreateBy creates a new instance from the query arguments when no match exists and saving is not required"() {
+        given:
+        def finder = RxSingleResultFinder.findOrCreateBy(datastoreClient)
+
+        when:
+        Observable observable = finder.invoke(Person, 'findOrCreateByName', ['Fred'] as Object[]) as Observable
+        Person result = observable.toBlocking().first() as Person
+
+        then:
+        1 * datastoreClient.createQuery(Person) >> query
+        1 * query.singleResult() >> Observable.empty()
+        result.name == 'Fred'
+    }
+
+    void "findOrCreateBy signals completion after emitting the new instance when saving is not required"() {
+        given:
+        def finder = RxSingleResultFinder.findOrCreateBy(datastoreClient)
+
+        when:
+        Observable observable = finder.invoke(Person, 'findOrCreateByName', ['Fred'] as Object[]) as Observable
+        def subscriber = new rx.observers.TestSubscriber()
+        observable.subscribe(subscriber)
+        subscriber.awaitTerminalEvent(5, java.util.concurrent.TimeUnit.SECONDS)
+
+        then:
+        1 * datastoreClient.createQuery(Person) >> query
+        1 * query.singleResult() >> Observable.empty()
+        subscriber.assertCompleted()
+        subscriber.assertNoErrors()
+        subscriber.onNextEvents.size() == 1
+        (subscriber.onNextEvents[0] as Person).name == 'Fred'
+    }
+
+    // NOTE: unlike SingleResultFinderSpec (core), there is deliberately no test here for a
+    // non-Equal expression reaching the empty-result construction path. In this async
+    // implementation that path runs inside a spawned Thread with no try/catch (preserved exactly
+    // from the original rx FindOrCreateByFinder - see the class Javadoc), so a thrown
+    // MissingMethodException never reaches the Observable's error channel at all: the thread dies
+    // silently and any blocking call on the Observable hangs forever. This is a genuine latent bug
+    // in the pre-existing rx implementation, not introduced by this refactor, and was never covered
+    // by the original test suite either - it is not safely testable without either fixing the
+    // production code (out of scope for this pass) or adding an artificial timeout that would not
+    // reflect real behavior.
+
+    void "findOrSaveBy isMethodMatch matches findOrSaveBy method names only"() {
+        expect:
+        RxSingleResultFinder.findOrSaveBy(datastoreClient).isMethodMatch('findOrSaveByName')
+        !RxSingleResultFinder.findOrSaveBy(datastoreClient).isMethodMatch('findOrCreateByName')
+    }
+
+    void "findOrSaveBy returns the existing entity when the underlying query already finds a match"() {
+        given:
+        def finder = RxSingleResultFinder.findOrSaveBy(datastoreClient)
+        def existing = new Person(name: 'Fred')
+
+        when:
+        Observable observable = finder.invoke(Person, 'findOrSaveByName', ['Fred'] as Object[]) as Observable
+        Person result = observable.toBlocking().first() as Person
+
+        then:
+        1 * datastoreClient.createQuery(Person) >> query
+        1 * query.singleResult() >> Observable.just(existing)
+        result.is(existing)
+    }
+
+    void "findOrSaveBy creates, saves and returns a new instance when no match exists"() {
+        given:
+        def finder = RxSingleResultFinder.findOrSaveBy(datastoreClient)
+        entity.getPropertyByName('name') >> Stub(PersistentProperty) { getName() >> 'name'; getType() >> String }
+
+        when:
+        Observable observable = finder.invoke(PersonThatSavesSuccessfully, 'findOrSaveByName', ['Fred'] as Object[]) as Observable
+        PersonThatSavesSuccessfully result = observable.toBlocking().first() as PersonThatSavesSuccessfully
+
+        then:
+        1 * datastoreClient.createQuery(PersonThatSavesSuccessfully) >> query
+        1 * query.singleResult() >> Observable.empty()
+        result.name == 'Fred'
+    }
+
+    void "findOrSaveBy propagates the error when saving the newly created instance fails"() {
+        given:
+        def finder = RxSingleResultFinder.findOrSaveBy(datastoreClient)
+
+        when:
+        Observable observable = finder.invoke(PersonThatFailsToSave, 'findOrSaveByName', ['Fred'] as Object[]) as Observable
+        observable.toBlocking().first()
+
+        then:
+        1 * datastoreClient.createQuery(PersonThatFailsToSave) >> query
+        1 * query.singleResult() >> Observable.empty()
+        def ex = thrown(IllegalStateException)
+        ex.message == 'save failed'
+    }
+
+    private static class Book {
+        String title
+        String author
+    }
+
+    private static class Person {
+        String name
+        boolean active
+    }
+
+    private static class PersonThatSavesSuccessfully implements RxEntity<PersonThatSavesSuccessfully> {
+        String name
+
+        @Override
+        Observable<PersonThatSavesSuccessfully> save(Map<String, Object> arguments) {
+            Observable.just(this)
+        }
+    }
+
+    private static class PersonThatFailsToSave implements RxEntity<PersonThatFailsToSave> {
+        String name
+
+        @Override
+        Observable<PersonThatFailsToSave> save(Map<String, Object> arguments) {
+            Observable.error(new IllegalStateException('save failed'))
+        }
+    }
+}