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

ahuber pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/causeway.git


The following commit(s) were added to refs/heads/main by this push:
     new 2fbe045e96e CAUSEWAY-3883: converts SyncControl and AsyncControl to 
records
2fbe045e96e is described below

commit 2fbe045e96e01913cc1acfe83084a87cae4b842d
Author: Andi Huber <[email protected]>
AuthorDate: Wed Jun 25 22:02:50 2025 +0200

    CAUSEWAY-3883: converts SyncControl and AsyncControl to records
---
 .../services/wrapper/control/AsyncControl.java     | 204 +++++++++++----------
 .../services/wrapper/control/AsyncLogger.java      |  55 ++++++
 .../services/wrapper/control/ControlAbstract.java  |  87 ---------
 .../services/wrapper/control/ExecutionMode.java    |  44 -----
 .../services/wrapper/control/SyncControl.java      | 103 ++++++-----
 .../wrapper/control/AsyncControl_Test.java         |  40 ++--
 .../services/wrapper/control/SyncControl_Test.java |  33 ++--
 .../runtime/wrap/WrapperInvocationHandler.java     |   5 +-
 .../wrapper/WrapperFactoryDefault.java             | 198 ++++++++++----------
 .../handlers/DomainObjectInvocationHandler.java    |  12 +-
 .../wrapper/WrapperFactoryDefaultTest.java         |   4 +-
 .../BackgroundService_IntegTestAbstract.java       |   6 +-
 .../testdomain/interact/CommandArgumentTest.java   |   2 +-
 13 files changed, 367 insertions(+), 426 deletions(-)

diff --git 
a/api/applib/src/main/java/org/apache/causeway/applib/services/wrapper/control/AsyncControl.java
 
b/api/applib/src/main/java/org/apache/causeway/applib/services/wrapper/control/AsyncControl.java
index c2054f52adb..e59fdeef199 100644
--- 
a/api/applib/src/main/java/org/apache/causeway/applib/services/wrapper/control/AsyncControl.java
+++ 
b/api/applib/src/main/java/org/apache/causeway/applib/services/wrapper/control/AsyncControl.java
@@ -21,10 +21,12 @@
 import java.time.ZoneId;
 import java.util.Locale;
 import java.util.concurrent.CancellationException;
+import java.util.concurrent.ExecutionException;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Future;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.TimeoutException;
+import java.util.concurrent.atomic.AtomicReference;
 
 import org.jspecify.annotations.NonNull;
 import org.jspecify.annotations.Nullable;
@@ -34,7 +36,6 @@
 import org.apache.causeway.applib.services.wrapper.WrapperFactory;
 import org.apache.causeway.commons.internal.assertions._Assert;
 
-import lombok.Getter;
 import lombok.SneakyThrows;
 import lombok.extern.log4j.Log4j2;
 
@@ -43,20 +44,49 @@
  * {@link org.apache.causeway.applib.services.wrapper.WrapperFactory} is 
actually
  * executed.
  *
- * <p>
- *     Executing in a separate thread means that the target and arguments are
- *     used in a new {@link 
org.apache.causeway.applib.services.iactn.Interaction}
- *     (and transaction).  If any of these are entities, they are retrieved
- *     from the database afresh; it isn't possible to pass domain entity
- *     references from the foreground calling thread to the background threads.
- * </p>
+ * <p> Executing in a separate thread means that the target and arguments are
+ * used in a new {@link org.apache.causeway.applib.services.iactn.Interaction}
+ * (and transaction).  If any of these are entities, they are retrieved
+ * from the database afresh; it isn't possible to pass domain entity
+ * references from the foreground calling thread to the background threads.
  *
  * @param <R> - return value.
  *
  * @since 2.0 {@index}
  */
 @Log4j2
-public class AsyncControl<R> extends ControlAbstract<AsyncControl<R>> {
+public record AsyncControl<R>(
+        Class<R> returnType,
+        SyncControl syncControl,
+        @Nullable ExecutorService executorService,
+
+        /**
+         * Defaults to the system clock, if not overridden
+         */
+        @Nullable VirtualClock clock,
+        /**
+         * Defaults to the system locale, if not overridden
+         */
+        @Nullable Locale locale,
+        /**
+         * Defaults to the system time zone, if not overridden
+         */
+        @Nullable ZoneId timeZone,
+        /**
+         * Specifies the user for the session used to execute the command
+         * asynchronously, in the background.
+         *
+         * <p>If not specified, then the user of the current foreground 
session is used.
+         */
+        @Nullable UserMemento user,
+        /**
+         * Contains the result of the invocation.
+         *
+         * <p> If an entity is returned, then the object is automatically 
detached
+         * because the persistence session within which it was obtained will 
have
+         * been closed already.
+         */
+        AtomicReference<Future<R>> futureRef) {
 
     /**
      * Factory method to instantiate a control instance for a void action
@@ -71,131 +101,104 @@ public static AsyncControl<Void> returningVoid() {
      * Factory method to instantiate for a control instance for an action
      * returning a value of `<R>` (where this value will be returned through
      * the `Future`).
-     *
-     * @param cls
-     * @param <X>
      */
     public static <X> AsyncControl<X> returning(final Class<X> cls) {
         return new AsyncControl<X>(cls);
     }
 
-    @Getter
-    private final Class<R> returnType;
-
+    // non canonical constructor
     private AsyncControl(final Class<R> returnType) {
-        this.returnType = returnType;
-        with(exception -> {
-            log.error(logMessage(), exception);
-            return null;
-        });
+        this(returnType,
+            SyncControl.control(),
+            /*executorService*/null, /*clock*/null, /*locale*/null, 
/*timeZone*/null, /*user*/null,
+            new AtomicReference<>());
+    }
+
+    /**
+     * Explicitly set the action to be executed.
+     */
+    public AsyncControl<R> withExecute() {
+        return new AsyncControl<>(returnType, syncControl.withExecute(), 
executorService, clock, locale, timeZone, user, futureRef);
+    }
+
+    /**
+     * Explicitly set the action to <i>not</i >be executed, in other words a
+     * &quot;dry run&quot;.
+     */
+    public AsyncControl<R> withNoExecute() {
+        return new AsyncControl<>(returnType, syncControl.withExecute(), 
executorService, clock, locale, timeZone, user, futureRef);
     }
 
     /**
      * Skip checking business rules (hide/disable/validate) before
      * executing the underlying property or action
      */
-    @Override
     public AsyncControl<R> withSkipRules() {
-        return super.withSkipRules();
+        return new AsyncControl<>(returnType, syncControl.withSkipRules(), 
executorService, clock, locale, timeZone, user, futureRef);
+    }
+
+    public AsyncControl<R> withCheckRules() {
+        return new AsyncControl<>(returnType, syncControl.withCheckRules(), 
executorService, clock, locale, timeZone, user, futureRef);
     }
 
     /**
-     * How to handle exceptions if they occur, using the provided
-     * {@link ExceptionHandler}.
+     * How to handle exceptions if they occur, using the provided {@link 
ExceptionHandler}.
      *
-     * <p>
-     *     The default behaviour is to rethrow the exception.
-     * </p>
+     * <p>The default behaviour is to rethrow the exception.
+     *
+     * <p>Changes are made in place, returning the same instance.
      */
-    @Override
-    public AsyncControl<R> with(final ExceptionHandler exceptionHandler) {
-        return super.with(exceptionHandler);
+    public AsyncControl<R> setExceptionHandler(final @NonNull ExceptionHandler 
exceptionHandler) {
+        syncControl.setExceptionHandler(exceptionHandler);
+        return this;
     }
 
-    @Getter @Nullable
-    private ExecutorService executorService = null;
-
     /**
      * Specifies the {@link ExecutorService} to use to obtain the thread
      * to invoke the action.
-     * <p>
-     * The default is {@code null}, indicating, that its the {@link 
WrapperFactory}'s
+     *
+     * <p>The default is {@code null}, indicating, that its the {@link 
WrapperFactory}'s
      * responsibility to provide a suitable {@link ExecutorService}.
      *
      * @param executorService - null-able
      */
     public AsyncControl<R> with(final ExecutorService executorService) {
-        this.executorService = executorService;
-        return this;
-        // ...
+        return new AsyncControl<>(returnType, syncControl, executorService, 
clock, locale, timeZone, user, futureRef);
     }
 
     /**
      * Defaults to the system clock, if not overridden
      */
-    @Getter
-    private VirtualClock clock;
     public AsyncControl<R> withClock(final @NonNull VirtualClock clock) {
-        this.clock = clock;
-        return this;
-        // ...
+        return new AsyncControl<>(returnType, syncControl, executorService, 
clock, locale, timeZone, user, futureRef);
     }
 
     /**
      * Defaults to the system locale, if not overridden
      */
-    @Getter
-    private Locale locale;
     public AsyncControl<R> withLocale(final @NonNull Locale locale) {
-        this.locale = locale;
-        return this;
-        // ...
+        return new AsyncControl<>(returnType, syncControl, executorService, 
clock, locale, timeZone, user, futureRef);
     }
 
     /**
      * Defaults to the system time zone, if not overridden
      */
-    @Getter
-    private ZoneId timeZone;
     public AsyncControl<R> withTimeZone(final @NonNull ZoneId timeZone) {
-        this.timeZone = timeZone;
-        return this;
-        // ...
+        return new AsyncControl<>(returnType, syncControl, executorService, 
clock, locale, timeZone, user, futureRef);
     }
 
-    @Getter
-    private UserMemento user;
     /**
      * Specifies the user for the session used to execute the command
      * asynchronously, in the background.
      *
-     * <p>
-     * If not specified, then the user of the current foreground session is 
used.
-     * </p>
+     * <p>If not specified, then the user of the current foreground session is 
used.
      */
     public AsyncControl<R> withUser(final @NonNull UserMemento user) {
-        this.user = user;
-        return this;
-        // ...
+        return new AsyncControl<>(returnType, syncControl, executorService, 
clock, locale, timeZone, user, futureRef);
     }
 
-    /**
-     * Contains the result of the invocation.
-     *
-     * <p>
-     * If an entity is returned, then the object is automatically detached
-     * because the persistence session within which it was obtained will have
-     * been closed already.
-     * </p>
-     */
-    @Getter
-    private Future<R> future;
-
-    /**
-     * For framework use only.
-     */
-    public void setFuture(final Future<R> future) {
-        this.future = future;
+    public Future<R> future() {
+        return futureRef.get();
     }
 
     /**
@@ -209,26 +212,39 @@ public void setFuture(final Future<R> future) {
      * @throws InterruptedException if the current thread was interrupted 
while waiting
      * @throws TimeoutException if the wait timed out
      */
+    @SuppressWarnings("javadoc")
     @SneakyThrows
     public R waitForResult(final long timeout, final TimeUnit unit) {
-        _Assert.assertNotNull(future,
+        _Assert.assertNotNull(future(),
                 ()->"detected call to waitForResult(..) before future was 
set");
-        return future.get(timeout, unit);
+        return future().get(timeout, unit);
     }
 
-    private String logMessage() {
-        StringBuilder buf = new StringBuilder("Failed to execute ");
-        if(getMethod() != null) {
-            buf.append(" ").append(getMethod().getName()).append(" ");
-            if(getBookmark() != null) {
-                buf.append(" on '")
-                        .append(getBookmark().logicalTypeName())
-                        .append(":")
-                        .append(getBookmark().identifier())
-                        .append("'");
-            }
-        }
-        return buf.toString();
-    }
+    // -- DEPRECATIONS
+
+    @Deprecated public Class<R> getReturnType() { return returnType(); }
+    @Deprecated public ExecutorService getExecutorService() { return 
executorService(); }
+
+    /**
+     * Defaults to the system clock, if not overridden
+     */
+    @Deprecated public VirtualClock getClock() { return clock(); }
+    /**
+     * Defaults to the system locale, if not overridden
+     */
+    @Deprecated public Locale getLocale() { return locale(); }
+    /**
+     * Defaults to the system time zone, if not overridden
+     */
+    @Deprecated public ZoneId getTimeZone() { return timeZone(); }
+    /**
+     * Specifies the user for the session used to execute the command
+     * asynchronously, in the background.
+     *
+     * <p>If not specified, then the user of the current foreground session is 
used.
+     */
+    @Deprecated public UserMemento getUser() { return user(); }
+
+    @Deprecated public Future<R> getFuture() { return future(); }
 
 }
diff --git 
a/api/applib/src/main/java/org/apache/causeway/applib/services/wrapper/control/AsyncLogger.java
 
b/api/applib/src/main/java/org/apache/causeway/applib/services/wrapper/control/AsyncLogger.java
new file mode 100644
index 00000000000..e736f03c591
--- /dev/null
+++ 
b/api/applib/src/main/java/org/apache/causeway/applib/services/wrapper/control/AsyncLogger.java
@@ -0,0 +1,55 @@
+/*
+ *  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
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.causeway.applib.services.wrapper.control;
+
+import java.lang.reflect.Method;
+
+import org.apache.logging.log4j.Logger;
+
+import org.apache.causeway.applib.services.bookmark.Bookmark;
+
+/**
+ * used for exception logging
+ */
+public record AsyncLogger(ExceptionHandler rootExceptionHandler, Method 
method, Bookmark bookmark) implements ExceptionHandler {
+
+    static Logger log = 
org.apache.logging.log4j.LogManager.getLogger(AsyncControl.class);
+
+    @Override
+    public Object handle(Exception ex) throws Exception {
+        log(ex);
+        return rootExceptionHandler.handle(ex);
+    }
+
+    void log(Exception ex) {
+        var buf = new StringBuilder("Failed to execute ");
+        if(method() != null) {
+            buf.append(" ").append(method().getName()).append(" ");
+            if(bookmark() != null) {
+                buf.append(" on '")
+                        .append(bookmark().logicalTypeName())
+                        .append(":")
+                        .append(bookmark().identifier())
+                        .append("'");
+            }
+        }
+        log.error(buf.toString(), ex);
+    }
+
+}
diff --git 
a/api/applib/src/main/java/org/apache/causeway/applib/services/wrapper/control/ControlAbstract.java
 
b/api/applib/src/main/java/org/apache/causeway/applib/services/wrapper/control/ControlAbstract.java
deleted file mode 100644
index 7d886e5a02a..00000000000
--- 
a/api/applib/src/main/java/org/apache/causeway/applib/services/wrapper/control/ControlAbstract.java
+++ /dev/null
@@ -1,87 +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
- *
- *        http://www.apache.org/licenses/LICENSE-2.0
- *
- *  Unless required by applicable law or agreed to in writing,
- *  software distributed under the License is distributed on an
- *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- *  KIND, either express or implied.  See the License for the
- *  specific language governing permissions and limitations
- *  under the License.
- */
-package org.apache.causeway.applib.services.wrapper.control;
-
-import java.lang.reflect.Method;
-import java.util.EnumSet;
-import java.util.Optional;
-
-import org.apache.causeway.applib.services.bookmark.Bookmark;
-import org.apache.causeway.commons.collections.ImmutableEnumSet;
-import org.apache.causeway.commons.internal.base._Casts;
-
-import lombok.AccessLevel;
-import lombok.Getter;
-import lombok.Setter;
-
-/**
- *
- * @since 2.0 {@index}
- */
-public class ControlAbstract<T extends ControlAbstract<T>> {
-
-    protected ControlAbstract() {
-    }
-
-    /**
-     * Set by framework; simply used for logging purposes.
-     */
-    @Getter(AccessLevel.PACKAGE) @Setter
-    private Method method;
-
-    /**
-     * Set by framework; simply used for logging purposes.
-     */
-    @Getter(AccessLevel.PACKAGE) @Setter
-    private Bookmark bookmark;
-
-    @Getter
-    private boolean checkRules = true;
-    public T withCheckRules() {
-        checkRules = true;
-        return _Casts.uncheckedCast(this);
-    }
-    public T withSkipRules() {
-        checkRules = false;
-        return _Casts.uncheckedCast(this);
-    }
-
-    private ExceptionHandler exceptionHandler;
-
-    public Optional<ExceptionHandler> getExceptionHandler() {
-        return Optional.ofNullable(exceptionHandler);
-    }
-
-    public T with(final ExceptionHandler exceptionHandler) {
-        this.exceptionHandler = exceptionHandler;
-        return _Casts.uncheckedCast(this);
-    }
-
-    /**
-     * Not API.
-     */
-    public ImmutableEnumSet<ExecutionMode> getExecutionModes() {
-        EnumSet<ExecutionMode> modes = EnumSet.noneOf(ExecutionMode.class);
-        if(!checkRules) {
-            modes.add(ExecutionMode.SKIP_RULE_VALIDATION);
-        }
-        return ImmutableEnumSet.from(modes);
-    }
-
-}
diff --git 
a/api/applib/src/main/java/org/apache/causeway/applib/services/wrapper/control/ExecutionMode.java
 
b/api/applib/src/main/java/org/apache/causeway/applib/services/wrapper/control/ExecutionMode.java
deleted file mode 100644
index 92c53b66eb6..00000000000
--- 
a/api/applib/src/main/java/org/apache/causeway/applib/services/wrapper/control/ExecutionMode.java
+++ /dev/null
@@ -1,44 +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
- *
- *        http://www.apache.org/licenses/LICENSE-2.0
- *
- *  Unless required by applicable law or agreed to in writing,
- *  software distributed under the License is distributed on an
- *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- *  KIND, either express or implied.  See the License for the
- *  specific language governing permissions and limitations
- *  under the License.
- */
-package org.apache.causeway.applib.services.wrapper.control;
-
-import org.apache.causeway.applib.services.wrapper.WrapperFactory;
-
-/**
- * Whether interactions with the wrapper are actually passed onto the
- * underlying domain object.
- *
- * @see WrapperFactory#wrap(Object, 
org.apache.causeway.applib.services.wrapper.control.SyncControl)
- *
- * @since 2.0 {@index}
- */
-public enum ExecutionMode {
-    /**
-     * Skip all business rules.
-     */
-    SKIP_RULE_VALIDATION,
-    /**
-     * Skip actual execution.
-     *
-     * <p>
-     * This is not supported for {@link WrapperFactory#asyncWrap(Object, 
AsyncControl)};
-     * instead just invoke {@link WrapperFactory#wrap(Object, SyncControl)}.
-     */
-    SKIP_EXECUTION,
-}
diff --git 
a/api/applib/src/main/java/org/apache/causeway/applib/services/wrapper/control/SyncControl.java
 
b/api/applib/src/main/java/org/apache/causeway/applib/services/wrapper/control/SyncControl.java
index 0dafa7a8d65..38d3f235a10 100644
--- 
a/api/applib/src/main/java/org/apache/causeway/applib/services/wrapper/control/SyncControl.java
+++ 
b/api/applib/src/main/java/org/apache/causeway/applib/services/wrapper/control/SyncControl.java
@@ -18,78 +18,99 @@
  */
 package org.apache.causeway.applib.services.wrapper.control;
 
-import java.util.EnumSet;
+import java.util.concurrent.atomic.AtomicReference;
 
-import org.apache.causeway.commons.collections.ImmutableEnumSet;
+import org.jspecify.annotations.Nullable;
+
+import lombok.NonNull;
 
 /**
  * Controls the way that a (synchronous) wrapper works.
  *
- * @since 2.0 {@index}
+ * @since 2.0 revised for 3.4 {@index}
  */
-public class SyncControl extends ControlAbstract<SyncControl> {
+public record SyncControl(
+        /**
+         * How to handle exceptions if they occur, using the provided
+         * {@link ExceptionHandler}.
+         *
+         * <p>The default behaviour is to rethrow the exception.
+         */
+        AtomicReference<ExceptionHandler> exceptionHandlerRef,
+        boolean isSkipExecute,
+        /**
+         * Skip checking business rules (hide/disable/validate) before
+         * executing the underlying property or action
+         */
+        boolean isSkipRules) {
 
     public static SyncControl control() {
-        return new SyncControl();
+        return new SyncControl(null, false, false);
     }
 
-    private SyncControl() {
-        with(exception -> {
-            throw exception;
-        });
+    public SyncControl(
+            @Nullable AtomicReference<ExceptionHandler> exceptionHandlerRef,
+            boolean isSkipExecute,
+            boolean isSkipRules) {
+        this.exceptionHandlerRef = exceptionHandlerRef!=null
+                ? exceptionHandlerRef
+                : new AtomicReference<>();
+        this.isSkipExecute = isSkipExecute;
+        this.isSkipRules = isSkipRules;
+        if(this.exceptionHandlerRef.get()==null) {
+            this.exceptionHandlerRef.set(exception -> { throw exception; });
+        }
     }
 
     /**
-     * Skip checking business rules (hide/disable/validate) before
-     * executing the underlying property or action
+     * Explicitly set the action to be executed.
      */
-    @Override
-    public SyncControl withSkipRules() {
-        return super.withSkipRules();
+    public SyncControl withExecute() {
+        return new SyncControl(exceptionHandlerRef, false, isSkipRules);
     }
 
     /**
-     * How to handle exceptions if they occur, using the provided
-     * {@link ExceptionHandler}.
-     *
-     * <p>
-     *     The default behaviour is to rethrow the exception.
-     * </p>
+     * Explicitly set the action to <i>not</i >be executed, in other words a
+     * &quot;dry run&quot;.
      */
-    @Override
-    public SyncControl with(final ExceptionHandler exceptionHandler) {
-        return super.with(exceptionHandler);
+    public SyncControl withNoExecute() {
+        return new SyncControl(exceptionHandlerRef, true, isSkipRules);
     }
 
-    private boolean execute = true;
-
     /**
-     * Explicitly set the action to be executed.
+     * Skip checking business rules (hide/disable/validate) before
+     * executing the underlying property or action
      */
-    public SyncControl withExecute() {
-        execute = true;
-        return this;
+    public SyncControl withSkipRules() {
+        return new SyncControl(exceptionHandlerRef, isSkipExecute, true);
+    }
+
+    public SyncControl withCheckRules() {
+        return new SyncControl(exceptionHandlerRef, isSkipExecute, false);
     }
 
     /**
-     * Explicitly set the action to <i>not</i >be executed, in other words a
-     * &quot;dry run&quot;.
+     * How to handle exceptions if they occur, using the provided {@link 
ExceptionHandler}.
+     *
+     * <p>The default behaviour is to rethrow the exception.
+     *
+     * <p>Changes are made in place, returning the same instance.
      */
-    public SyncControl withNoExecute() {
-        execute = false;
+    public SyncControl setExceptionHandler(final @NonNull ExceptionHandler 
exceptionHandler) {
+        exceptionHandlerRef.set(exceptionHandler);
         return this;
     }
 
+    public ExceptionHandler exceptionHandler() {
+        return exceptionHandlerRef.get();
+    }
+
     /**
-     * Not API.
+     * @return whether this and other share the same execution mode, ignoring 
exceptionHandling
      */
-    @Override
-    public ImmutableEnumSet<ExecutionMode> getExecutionModes() {
-        EnumSet<ExecutionMode> modes = 
EnumSet.copyOf(super.getExecutionModes().toEnumSet());
-        if(!execute) {
-            modes.add(ExecutionMode.SKIP_EXECUTION);
-        }
-        return ImmutableEnumSet.from(modes);
+    public boolean isEquivalent(SyncControl other) {
+        return this.isSkipExecute == other.isSkipExecute
+                && this.isSkipRules == other.isSkipRules;
     }
 
 }
diff --git 
a/api/applib/src/test/java/org/apache/causeway/applib/services/wrapper/control/AsyncControl_Test.java
 
b/api/applib/src/test/java/org/apache/causeway/applib/services/wrapper/control/AsyncControl_Test.java
index cad3b32b0c2..6b6d5635864 100644
--- 
a/api/applib/src/test/java/org/apache/causeway/applib/services/wrapper/control/AsyncControl_Test.java
+++ 
b/api/applib/src/test/java/org/apache/causeway/applib/services/wrapper/control/AsyncControl_Test.java
@@ -37,7 +37,8 @@ public void defaults() throws Exception {
         var control = AsyncControl.returningVoid();
 
         // then
-        Assertions.assertThat(control.getExecutionModes()).isEmpty();
+        
Assertions.assertThat(control.syncControl().isSkipExecute()).isEqualTo(false);
+        
Assertions.assertThat(control.syncControl().isSkipRules()).isEqualTo(false);
     }
 
     @Test
@@ -46,10 +47,11 @@ public void check_rules() throws Exception {
         var control = AsyncControl.returningVoid();
 
         // when
-        control.withCheckRules();
+        control = control.withCheckRules();
 
         // then
-        Assertions.assertThat(control.getExecutionModes()).isEmpty();
+        
Assertions.assertThat(control.syncControl().isSkipExecute()).isEqualTo(false);
+        
Assertions.assertThat(control.syncControl().isSkipRules()).isEqualTo(false);
     }
 
     @Test
@@ -59,10 +61,11 @@ public void skip_rules() throws Exception {
         var control = AsyncControl.returningVoid();
 
         // when
-        control.withSkipRules();
+        control = control.withSkipRules();
 
         // then
-        
Assertions.assertThat(control.getExecutionModes()).contains(ExecutionMode.SKIP_RULE_VALIDATION);
+        
Assertions.assertThat(control.syncControl().isSkipExecute()).isEqualTo(false);
+        
Assertions.assertThat(control.syncControl().isSkipRules()).isEqualTo(true);
     }
 
     @Test
@@ -72,10 +75,10 @@ public void user() throws Exception {
         var control = AsyncControl.returningVoid();
 
         // when
-        control.withUser(UserMemento.ofName("fred"));
+        control = control.withUser(UserMemento.ofName("fred"));
 
         // then
-        Assertions.assertThat(control.getUser().name()).isEqualTo("fred");
+        Assertions.assertThat(control.user().name()).isEqualTo("fred");
     }
 
     @Test
@@ -85,32 +88,29 @@ public void roles() throws Exception {
         var control = AsyncControl.returningVoid();
 
         // when
-        control.withUser(UserMemento.ofNameAndRoleNames("fred", "role-1", 
"role-2"));
+        control = control.withUser(UserMemento.ofNameAndRoleNames("fred", 
"role-1", "role-2"));
 
         // then
-        
Assertions.assertThat(control.getUser().streamRoleNames().collect(Collectors.toList()))
+        
Assertions.assertThat(control.user().streamRoleNames().collect(Collectors.toList()))
         .containsExactlyInAnyOrder("role-1", "role-2");
     }
 
     @Test
     public void chaining() throws Exception {
 
-        var executorService = new ExecutorServiceAdapter(new 
TaskExecutorAdapter(command -> {
-        }));
-        ExceptionHandler exceptionHandler = ex -> null;
+        var executorService = new ExecutorServiceAdapter(new 
TaskExecutorAdapter(command -> {}));
+        var exceptionHandler = (ExceptionHandler) ex -> null;
 
         var control = AsyncControl.returning(String.class)
                 .withSkipRules()
                 .withUser(UserMemento.ofNameAndRoleNames("fred", "role-1", 
"role-2"))
                 .with(executorService)
-                .with(exceptionHandler);
-
-        Assertions.assertThat(control.getExecutionModes())
-                .containsExactlyInAnyOrder(ExecutionMode.SKIP_RULE_VALIDATION);
-        Assertions.assertThat(control.getExecutorService())
-                .isSameAs(executorService);
-        Assertions.assertThat(control.getExceptionHandler().orElse(null))
-                .isSameAs(exceptionHandler);
+                .setExceptionHandler(exceptionHandler);
+
+        
Assertions.assertThat(control.syncControl().isSkipExecute()).isEqualTo(false);
+        
Assertions.assertThat(control.syncControl().isSkipRules()).isEqualTo(true);
+        
Assertions.assertThat(control.executorService()).isSameAs(executorService);
+        
Assertions.assertThat(control.syncControl().exceptionHandler()).isSameAs(exceptionHandler);
     }
 
 }
\ No newline at end of file
diff --git 
a/api/applib/src/test/java/org/apache/causeway/applib/services/wrapper/control/SyncControl_Test.java
 
b/api/applib/src/test/java/org/apache/causeway/applib/services/wrapper/control/SyncControl_Test.java
index 7de51582617..fc98b02e18f 100644
--- 
a/api/applib/src/test/java/org/apache/causeway/applib/services/wrapper/control/SyncControl_Test.java
+++ 
b/api/applib/src/test/java/org/apache/causeway/applib/services/wrapper/control/SyncControl_Test.java
@@ -20,10 +20,9 @@
 
 import org.junit.jupiter.api.Test;
 
+import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
-import org.apache.causeway.commons.internal.base._NullSafe;
-
 class SyncControl_Test {
 
     @Test
@@ -33,7 +32,8 @@ public void defaults() throws Exception {
         var control = SyncControl.control();
 
         // then
-        assertTrue(_NullSafe.isEmpty(control.getExecutionModes()));
+        assertFalse(control.isSkipExecute());
+        assertFalse(control.isSkipRules());
     }
 
     @Test
@@ -42,10 +42,11 @@ public void check_rules() throws Exception {
         var control = SyncControl.control();
 
         // when
-        control.withCheckRules();
+        control = control.withCheckRules();
 
         // then
-        assertTrue(_NullSafe.isEmpty(control.getExecutionModes()));
+        assertFalse(control.isSkipExecute());
+        assertFalse(control.isSkipRules());
     }
 
     @Test
@@ -55,10 +56,11 @@ public void skip_rules() throws Exception {
         var control = SyncControl.control();
 
         // when
-        control.withSkipRules();
+        control = control.withSkipRules();
 
         // then
-        
assertTrue(control.getExecutionModes().contains(ExecutionMode.SKIP_RULE_VALIDATION));
+        assertFalse(control.isSkipExecute());
+        assertTrue(control.isSkipRules());
     }
 
     @Test
@@ -68,10 +70,11 @@ public void execute() throws Exception {
         var control = SyncControl.control();
 
         // when
-        control.withExecute();
+        control = control.withExecute();
 
         // then
-        assertTrue(_NullSafe.isEmpty(control.getExecutionModes()));
+        assertFalse(control.isSkipExecute());
+        assertFalse(control.isSkipRules());
     }
 
     @Test
@@ -81,10 +84,11 @@ public void no_execute() throws Exception {
         var control = SyncControl.control();
 
         // when
-        control.withNoExecute();
+        control = control.withNoExecute();
 
         // then
-        
assertTrue(control.getExecutionModes().contains(ExecutionMode.SKIP_EXECUTION));
+        assertTrue(control.isSkipExecute());
+        assertFalse(control.isSkipRules());
     }
 
     @Test
@@ -95,11 +99,10 @@ public void chaining() throws Exception {
         var control = SyncControl.control()
                 .withNoExecute()
                 .withSkipRules()
-                .with(exceptionHandler);
+                .setExceptionHandler(exceptionHandler);
 
-        assertTrue(control.getExecutionModes().size()==2);
-        
assertTrue(control.getExecutionModes().contains(ExecutionMode.SKIP_RULE_VALIDATION));
-        
assertTrue(control.getExecutionModes().contains(ExecutionMode.SKIP_EXECUTION));
+        assertTrue(control.isSkipExecute());
+        assertTrue(control.isSkipRules());
     }
 
 }
\ No newline at end of file
diff --git 
a/core/runtime/src/main/java/org/apache/causeway/core/runtime/wrap/WrapperInvocationHandler.java
 
b/core/runtime/src/main/java/org/apache/causeway/core/runtime/wrap/WrapperInvocationHandler.java
index fbe759c1843..90fd46f407a 100644
--- 
a/core/runtime/src/main/java/org/apache/causeway/core/runtime/wrap/WrapperInvocationHandler.java
+++ 
b/core/runtime/src/main/java/org/apache/causeway/core/runtime/wrap/WrapperInvocationHandler.java
@@ -24,7 +24,6 @@
 import org.jspecify.annotations.NonNull;
 import org.jspecify.annotations.Nullable;
 
-import org.apache.causeway.applib.services.wrapper.control.ExecutionMode;
 import org.apache.causeway.commons.internal._Constants;
 import org.apache.causeway.commons.internal.base._Lazy;
 import org.apache.causeway.commons.internal.proxy.CachableInvocationHandler;
@@ -119,11 +118,11 @@ static WrapperInvocation of(Object target, Method method, 
Object[] args) {
         }
 
         public boolean shouldEnforceRules() {
-            return 
!origin().syncControl().getExecutionModes().contains(ExecutionMode.SKIP_RULE_VALIDATION);
+            return !origin().syncControl().isSkipRules();
         }
 
         public boolean shouldExecute() {
-            return 
!origin().syncControl().getExecutionModes().contains(ExecutionMode.SKIP_EXECUTION);
+            return !origin().syncControl().isSkipExecute();
         }
     }
 
diff --git 
a/core/runtimeservices/src/main/java/org/apache/causeway/core/runtimeservices/wrapper/WrapperFactoryDefault.java
 
b/core/runtimeservices/src/main/java/org/apache/causeway/core/runtimeservices/wrapper/WrapperFactoryDefault.java
index 84015204d17..b155151ec7c 100644
--- 
a/core/runtimeservices/src/main/java/org/apache/causeway/core/runtimeservices/wrapper/WrapperFactoryDefault.java
+++ 
b/core/runtimeservices/src/main/java/org/apache/causeway/core/runtimeservices/wrapper/WrapperFactoryDefault.java
@@ -22,7 +22,6 @@
 import java.lang.reflect.Method;
 import java.util.ArrayList;
 import java.util.Collections;
-import java.util.EnumSet;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -61,7 +60,7 @@
 import org.apache.causeway.applib.services.wrapper.WrapperFactory;
 import org.apache.causeway.applib.services.wrapper.callable.AsyncCallable;
 import org.apache.causeway.applib.services.wrapper.control.AsyncControl;
-import org.apache.causeway.applib.services.wrapper.control.ExecutionMode;
+import org.apache.causeway.applib.services.wrapper.control.AsyncLogger;
 import org.apache.causeway.applib.services.wrapper.control.SyncControl;
 import org.apache.causeway.applib.services.wrapper.events.ActionArgumentEvent;
 import 
org.apache.causeway.applib.services.wrapper.events.ActionInvocationEvent;
@@ -80,7 +79,6 @@
 import 
org.apache.causeway.applib.services.wrapper.events.PropertyVisibilityEvent;
 import 
org.apache.causeway.applib.services.wrapper.listeners.InteractionListener;
 import org.apache.causeway.applib.services.xactn.TransactionService;
-import org.apache.causeway.commons.collections.ImmutableEnumSet;
 import org.apache.causeway.commons.internal.base._Casts;
 import org.apache.causeway.commons.internal.collections._Lists;
 import org.apache.causeway.commons.internal.exceptions._Exceptions;
@@ -109,7 +107,6 @@
 
 import static 
org.apache.causeway.applib.services.wrapper.control.SyncControl.control;
 
-import lombok.Data;
 import lombok.Getter;
 import lombok.RequiredArgsConstructor;
 
@@ -200,7 +197,7 @@ public <T> T wrap(
         if (isWrapper(domainObject)) {
             var wrapperObject = (WrappingObject) domainObject;
             var origin = wrapperObject.__causeway_origin();
-            if(equivalent(origin.syncControl().getExecutionModes(), 
syncControl.getExecutionModes())) {
+            if(origin.syncControl().isEquivalent(syncControl)) {
                 return domainObject;
             }
             var underlyingDomainObject = 
wrapperObject.__causeway_origin().pojo();
@@ -209,14 +206,6 @@ public <T> T wrap(
         return createProxy(domainObject, syncControl);
     }
 
-    private static boolean equivalent(final ImmutableEnumSet<ExecutionMode> 
first, final ImmutableEnumSet<ExecutionMode> second) {
-        return equivalent(first.toEnumSet(), second.toEnumSet());
-    }
-
-    private static boolean equivalent(final EnumSet<ExecutionMode> first, 
final EnumSet<ExecutionMode> second) {
-        return first.containsAll(second) && second.containsAll(first);
-    }
-
     @Override
     public <T> T wrapMixin(
             final @NonNull Class<T> mixinClass,
@@ -240,7 +229,7 @@ public <T> T wrapMixin(
 
             getServiceInjector().injectServicesInto(underlyingMixee);
 
-            if(equivalent(origin.syncControl().getExecutionModes(), 
syncControl.getExecutionModes())) {
+            if(origin.syncControl().isEquivalent(syncControl)) {
                 return mixin;
             }
             return _Casts.uncheckedCast(createMixinProxy(underlyingMixee, 
mixin, syncControl));
@@ -279,7 +268,7 @@ public <T> T unwrap(final T possibleWrappedDomainObject) {
     // -- ASYNC WRAPPING
 
     @Override
-    public <T,R> T asyncWrap(
+    public <T, R> T asyncWrap(
             final @NonNull T domainObject,
             final AsyncControl<R> asyncControl) {
 
@@ -289,7 +278,7 @@ public <T,R> T asyncWrap(
                     + "use WrapperFactory.asyncWrapMixin(...) instead");
         }
 
-        final InvocationHandler handler = (proxy, method, args) -> {
+        var handler = (InvocationHandler) (proxy, method, args) -> {
             var resolvedMethod = _GenericResolver.resolveMethod(method, 
domainObject.getClass())
                     .orElseThrow(); // fail early on attempt to invoke method 
that is not part of the meta-model
 
@@ -297,17 +286,19 @@ public <T,R> T asyncWrap(
                 return method.invoke(domainObject, args);
             }
 
-            if (asyncControl.isCheckRules()) {
+            if (!asyncControl.syncControl().isSkipRules()) {
                 var doih = proxyGenerator.handler(targetAdapter.objSpec());
-                doih.invoke(domainObject, method, args);
+                var origin = WrappingObject.Origin.fallback(domainObject);
+                doih.invoke(new WrapperInvocation(origin, method, args));
             }
 
-            var memberAndTarget = memberAndTargetForRegular(resolvedMethod, 
targetAdapter);
-            if( ! memberAndTarget.isMemberFound()) {
+            var memberAndTarget = MemberAndTarget.forRegular(resolvedMethod, 
targetAdapter);
+            if(!memberAndTarget.isMemberFound()) {
                 return method.invoke(domainObject, args);
             }
 
-            return submitAsync(memberAndTarget, args, asyncControl);
+            submitAsync(memberAndTarget, args, asyncControl);
+            return null;
         };
 
         @SuppressWarnings("unchecked")
@@ -332,7 +323,7 @@ public <T, R> T asyncWrapMixin(
         var mixinConstructor = 
MixinConstructor.PUBLIC_SINGLE_ARG_RECEIVING_MIXEE
                 .getConstructorElseFail(mixinClass, mixee.getClass());
 
-        final InvocationHandler handler = (proxy, method, args) -> {
+        var handler = (InvocationHandler) (proxy, method, args) -> {
             var resolvedMethod = _GenericResolver.resolveMethod(method, 
mixinClass)
                     .orElseThrow(); // fail early on attempt to invoke method 
that is not part of the meta-model
 
@@ -340,18 +331,19 @@ public <T, R> T asyncWrapMixin(
                 return method.invoke(mixin, args);
             }
 
-            if (asyncControl.isCheckRules()) {
+            if (!asyncControl.syncControl().isSkipRules()) {
                 var doih = proxyGenerator.handler(managedMixin.objSpec());
                 var origin = WrappingObject.Origin.fallbackMixin(mixin, 
managedMixee);
                 doih.invoke(new WrapperInvocation(origin, method, args));
             }
 
-            var actionAndTarget = memberAndTargetForMixin(resolvedMethod, 
mixee, managedMixin);
-            if (! actionAndTarget.isMemberFound()) {
+            var actionAndTarget = MemberAndTarget.forMixin(resolvedMethod, 
mixee, managedMixin);
+            if (!actionAndTarget.isMemberFound()) {
                 return method.invoke(mixin, args);
             }
 
-            return submitAsync(actionAndTarget, args, asyncControl);
+            submitAsync(actionAndTarget, args, asyncControl);
+            return null;
         };
 
         var proxyClass = proxyFactoryService
@@ -367,7 +359,7 @@ private boolean isInheritedFromJavaLangObject(final Method 
method) {
         return method.getDeclaringClass().equals(Object.class);
     }
 
-    private <R> Object submitAsync(
+    private <R> void submitAsync(
             final MemberAndTarget memberAndTarget,
             final Object[] args,
             final AsyncControl<R> asyncControl) {
@@ -379,96 +371,46 @@ private <R> Object submitAsync(
         var parentCommand = 
getInteractionService().currentInteractionElseFail().getCommand();
         var parentInteractionId = parentCommand.getInteractionId();
 
-        var targetAdapter = memberAndTarget.getTarget();
-        var method = memberAndTarget.getMethod();
+        var targetAdapter = memberAndTarget.target();
+        var method = memberAndTarget.method();
 
         var head = InteractionHead.regular(targetAdapter);
 
         var childInteractionId = interactionIdGenerator.interactionId();
         CommandDto childCommandDto;
-        switch (memberAndTarget.getType()) {
+        switch (memberAndTarget.type()) {
             case ACTION:
-                var action = memberAndTarget.getAction();
+                var action = memberAndTarget.action();
                 var argAdapters = 
ManagedObject.adaptParameters(action.getParameters(), _Lists.ofArray(args));
                 childCommandDto = commandDtoFactory
                         .asCommandDto(childInteractionId, head, action, 
argAdapters);
                 break;
             case PROPERTY:
-                var property = memberAndTarget.getProperty();
+                var property = memberAndTarget.property();
                 var propertyValueAdapter = 
ManagedObject.adaptProperty(property, args[0]);
                 childCommandDto = commandDtoFactory
                         .asCommandDto(childInteractionId, head, property, 
propertyValueAdapter);
                 break;
             default:
                 // shouldn't happen, already catered for this case previously
-                return null;
+                return;
         }
         var oidDto = childCommandDto.getTargets().getOid().get(0);
 
-        asyncControl.setMethod(method);
-        asyncControl.setBookmark(Bookmark.forOidDto(oidDto));
+        var rootExceptionHandler = 
asyncControl.syncControl().exceptionHandler();
+        asyncControl.setExceptionHandler(new AsyncLogger(rootExceptionHandler, 
method, Bookmark.forOidDto(oidDto)));
 
-        var executorService = 
Optional.ofNullable(asyncControl.getExecutorService())
+        var executorService = 
Optional.ofNullable(asyncControl.executorService())
                 .orElse(commonExecutorService);
         var asyncTask = getServiceInjector().injectServicesInto(new 
AsyncTask<R>(
             asyncInteractionContext,
             Propagation.REQUIRES_NEW,
             childCommandDto,
-            asyncControl.getReturnType(),
+            asyncControl.returnType(),
             parentInteractionId)); // this command becomes the parent of child 
command
 
         var future = executorService.submit(asyncTask);
-        asyncControl.setFuture(future);
-
-        return null;
-    }
-
-    private MemberAndTarget memberAndTargetForRegular(
-            final ResolvedMethod method,
-            final ManagedObject targetAdapter) {
-
-        var objectMember = 
targetAdapter.objSpec().getMember(method).orElse(null);
-        if(objectMember == null) {
-            return MemberAndTarget.notFound();
-        }
-
-        if (objectMember instanceof OneToOneAssociation) {
-            return MemberAndTarget.foundProperty((OneToOneAssociation) 
objectMember, targetAdapter, method.method());
-        }
-        if (objectMember instanceof ObjectAction) {
-            return MemberAndTarget.foundAction((ObjectAction) objectMember, 
targetAdapter, method.method());
-        }
-
-        throw new UnsupportedOperationException(
-                "Only properties and actions can be executed in the background 
"
-                        + "(method " + method.name() + " represents a " + 
objectMember.getFeatureType().name() + "')");
-    }
-
-    private <T> MemberAndTarget memberAndTargetForMixin(
-            final ResolvedMethod method,
-            final T mixee,
-            final ManagedObject mixinAdapter) {
-
-        var mixinMember = 
mixinAdapter.objSpec().getMember(method).orElse(null);
-        if (mixinMember == null) {
-            return MemberAndTarget.notFound();
-        }
-
-        // find corresponding action of the mixee (this is the 'real' target, 
the target usable for invocation).
-        var mixeeClass = mixee.getClass();
-
-        // don't care about anything other than actions
-        // (contributed properties and collections are read-only).
-        final ObjectAction targetAction = 
getSpecificationLoader().specForType(mixeeClass)
-        .flatMap(mixeeSpec->mixeeSpec.streamAnyActions(MixedIn.ONLY)
-                .filter(act -> 
((MixedInMember)act).hasMixinAction((ObjectAction) mixinMember))
-                .findFirst()
-        )
-        .orElseThrow(()->new UnsupportedOperationException(String.format(
-                "Could not locate objectAction delegating to mixinAction 
id='%s' on mixee class '%s'",
-                mixinMember.getId(), mixeeClass.getName())));
-
-        return MemberAndTarget.foundAction(targetAction, 
getObjectManager().adapt(mixee), method.method());
+        asyncControl.futureRef().set(future);
     }
 
     private static <R> InteractionContext interactionContextFrom(
@@ -476,15 +418,25 @@ private static <R> InteractionContext 
interactionContextFrom(
             final InteractionContext interactionContext) {
 
         return InteractionContext.builder()
-            
.clock(Optional.ofNullable(asyncControl.getClock()).orElseGet(interactionContext::getClock))
-            
.locale(Optional.ofNullable(asyncControl.getLocale()).map(UserLocale::valueOf).orElse(null))
 // if not set in asyncControl use defaults (set override to null)
-            
.timeZone(Optional.ofNullable(asyncControl.getTimeZone()).orElseGet(interactionContext::getTimeZone))
-            
.user(Optional.ofNullable(asyncControl.getUser()).orElseGet(interactionContext::getUser))
+            
.clock(Optional.ofNullable(asyncControl.clock()).orElseGet(interactionContext::getClock))
+            
.locale(Optional.ofNullable(asyncControl.locale()).map(UserLocale::valueOf).orElse(null))
 // if not set in asyncControl use defaults (set override to null)
+            
.timeZone(Optional.ofNullable(asyncControl.timeZone()).orElseGet(interactionContext::getTimeZone))
+            
.user(Optional.ofNullable(asyncControl.user()).orElseGet(interactionContext::getUser))
             .build();
     }
 
-    @Data
-    static class MemberAndTarget {
+    record MemberAndTarget(
+            Type type,
+            /**
+             * Populated if and only if {@link #type} is {@link Type#ACTION}.
+             */
+            ObjectAction action,
+            /**
+             * Populated if and only if {@link #type} is {@link Type#PROPERTY}.
+             */
+            OneToOneAssociation property,
+            ManagedObject target,
+            Method method) {
         static MemberAndTarget notFound() {
             return new MemberAndTarget(Type.NONE, null, null, null, null);
         }
@@ -494,6 +446,53 @@ static MemberAndTarget foundAction(final ObjectAction 
action, final ManagedObjec
         static MemberAndTarget foundProperty(final OneToOneAssociation 
property, final ManagedObject target, final Method method) {
             return new MemberAndTarget(Type.PROPERTY, null, property, target, 
method);
         }
+        static MemberAndTarget forRegular(
+                final ResolvedMethod method,
+                final ManagedObject targetAdapter) {
+
+            var objectMember = 
targetAdapter.objSpec().getMember(method).orElse(null);
+            if(objectMember == null) {
+                return MemberAndTarget.notFound();
+            }
+            if (objectMember instanceof OneToOneAssociation) {
+                return MemberAndTarget.foundProperty((OneToOneAssociation) 
objectMember, targetAdapter, method.method());
+            }
+            if (objectMember instanceof ObjectAction) {
+                return MemberAndTarget.foundAction((ObjectAction) 
objectMember, targetAdapter, method.method());
+            }
+
+            throw new UnsupportedOperationException(
+                    "Only properties and actions can be executed in the 
background "
+                            + "(method " + method.name() + " represents a " + 
objectMember.getFeatureType().name() + "')");
+        }
+        static <T> MemberAndTarget forMixin(
+                final ResolvedMethod method,
+                final T mixee,
+                final ManagedObject mixinAdapter) {
+
+            var mixinMember = 
mixinAdapter.objSpec().getMember(method).orElse(null);
+            if (mixinMember == null) {
+                return MemberAndTarget.notFound();
+            }
+
+            var mmc = mixinAdapter.getMetaModelContext();
+
+            // find corresponding action of the mixee (this is the 'real' 
target, the target usable for invocation).
+            var mixeeClass = mixee.getClass();
+
+            // don't care about anything other than actions
+            // (contributed properties and collections are read-only).
+            final ObjectAction targetAction = 
mmc.getSpecificationLoader().specForType(mixeeClass)
+            .flatMap(mixeeSpec->mixeeSpec.streamAnyActions(MixedIn.ONLY)
+                    .filter(act -> 
((MixedInMember)act).hasMixinAction((ObjectAction) mixinMember))
+                    .findFirst()
+            )
+            .orElseThrow(()->new UnsupportedOperationException(String.format(
+                    "Could not locate objectAction delegating to mixinAction 
id='%s' on mixee class '%s'",
+                    mixinMember.getId(), mixeeClass.getName())));
+
+            return MemberAndTarget.foundAction(targetAction, 
mmc.getObjectManager().adapt(mixee), method.method());
+        }
 
         public boolean isMemberFound() {
             return type != Type.NONE;
@@ -504,17 +503,6 @@ enum Type {
             PROPERTY,
             NONE
         }
-        private final Type type;
-        /**
-         * Populated if and only if {@link #type} is {@link Type#ACTION}.
-         */
-        private final ObjectAction action;
-        /**
-         * Populated if and only if {@link #type} is {@link Type#PROPERTY}.
-         */
-        private final OneToOneAssociation property;
-        private final ManagedObject target;
-        private final Method method;
     }
 
     // -- LISTENERS
diff --git 
a/core/runtimeservices/src/main/java/org/apache/causeway/core/runtimeservices/wrapper/handlers/DomainObjectInvocationHandler.java
 
b/core/runtimeservices/src/main/java/org/apache/causeway/core/runtimeservices/wrapper/handlers/DomainObjectInvocationHandler.java
index d9dc132084f..3c39d7c97a1 100644
--- 
a/core/runtimeservices/src/main/java/org/apache/causeway/core/runtimeservices/wrapper/handlers/DomainObjectInvocationHandler.java
+++ 
b/core/runtimeservices/src/main/java/org/apache/causeway/core/runtimeservices/wrapper/handlers/DomainObjectInvocationHandler.java
@@ -534,16 +534,8 @@ private <X> X runExecutionTask(final WrapperInvocation 
wrapperInvocation, final
 
     @SneakyThrows
     private Object handleException(WrapperInvocation wrapperInvocation, final 
Exception ex) {
-        var exceptionHandler = 
wrapperInvocation.origin().syncControl().getExceptionHandler()
-                .orElse(null);
-
-        if(exceptionHandler==null) {
-            log.warn("No ExceptionHandler was setup to handle this Exception", 
ex);
-        }
-
-        return exceptionHandler!=null
-                ? exceptionHandler.handle(ex)
-                : null;
+        var exceptionHandler = 
wrapperInvocation.origin().syncControl().exceptionHandler();
+        return exceptionHandler.handle(ex);
     }
 
     private Object singleArgUnderlyingElseNull(final Object[] args, final 
String name) {
diff --git 
a/core/runtimeservices/src/test/java/org/apache/causeway/core/runtimeservices/wrapper/WrapperFactoryDefaultTest.java
 
b/core/runtimeservices/src/test/java/org/apache/causeway/core/runtimeservices/wrapper/WrapperFactoryDefaultTest.java
index 3c78be6e899..a7489e78312 100644
--- 
a/core/runtimeservices/src/test/java/org/apache/causeway/core/runtimeservices/wrapper/WrapperFactoryDefaultTest.java
+++ 
b/core/runtimeservices/src/test/java/org/apache/causeway/core/runtimeservices/wrapper/WrapperFactoryDefaultTest.java
@@ -26,9 +26,7 @@
 import static org.hamcrest.CoreMatchers.not;
 import static org.hamcrest.CoreMatchers.nullValue;
 import static org.hamcrest.MatcherAssert.assertThat;
-import static org.hamcrest.Matchers.contains;
 
-import org.apache.causeway.applib.services.wrapper.control.ExecutionMode;
 import org.apache.causeway.applib.services.wrapper.control.SyncControl;
 import org.apache.causeway.commons.internal.proxy.ProxyFactoryService;
 import org.apache.causeway.core.metamodel._testing.MetaModelContext_forTesting;
@@ -124,7 +122,7 @@ public void 
wrap_ofWrapped_differentMode_delegates_to_createProxy() throws Excep
         // then
         assertThat(wrappingObject, is(not(domainObject)));
         assertThat(createProxyCalledWithDomainObject, is(wrappedObject));
-        assertThat(createProxyCalledWithSyncControl.getExecutionModes(), 
contains(ExecutionMode.SKIP_RULE_VALIDATION));
+        assertThat(createProxyCalledWithSyncControl.isSkipRules(), is(true));
     }
 
 }
diff --git 
a/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/integtest/BackgroundService_IntegTestAbstract.java
 
b/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/integtest/BackgroundService_IntegTestAbstract.java
index 40d9ed4ff1a..6ec96cd451a 100644
--- 
a/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/integtest/BackgroundService_IntegTestAbstract.java
+++ 
b/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/integtest/BackgroundService_IntegTestAbstract.java
@@ -107,8 +107,8 @@ void async_using_default_executor_service() {
             var control = AsyncControl.returning(Counter.class);
             wrapperFactory.asyncWrap(counter, 
control).bumpUsingDeclaredAction();
 
-            // wait til done
-            control.getFuture().get();
+            // wait till done
+            control.future().get();
         }).ifFailureFail();
 
         // then
@@ -127,7 +127,7 @@ void async_using_default_executor_service() {
             wrapperFactory.asyncWrapMixin(Counter_bumpUsingMixin.class, 
counter, control).act();
 
             // wait til done
-            control.getFuture().get();
+            control.future().get();
         }).ifFailureFail();
 
         // then
diff --git 
a/regressiontests/interact/src/test/java/org/apache/causeway/testdomain/interact/CommandArgumentTest.java
 
b/regressiontests/interact/src/test/java/org/apache/causeway/testdomain/interact/CommandArgumentTest.java
index 2ce6655761e..3887ffacd97 100644
--- 
a/regressiontests/interact/src/test/java/org/apache/causeway/testdomain/interact/CommandArgumentTest.java
+++ 
b/regressiontests/interact/src/test/java/org/apache/causeway/testdomain/interact/CommandArgumentTest.java
@@ -128,7 +128,7 @@ void listParam_shouldAllowAsyncInvocation() throws 
InterruptedException, Executi
         wrapperFactory.asyncWrap(commandArgDemo, control)
         .list(Arrays.asList(1L, 2L, 3L));
 
-        var stringified = control.getFuture().get(3L, 
TimeUnit.DAYS).getResultAsString();
+        var stringified = control.future().get(3L, 
TimeUnit.DAYS).getResultAsString();
 
         assertEquals("[1, 2, 3]", stringified);
     }

Reply via email to