You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@isis.apache.org by da...@apache.org on 2021/02/09 22:35:26 UTC

[isis] 01/02: ISIS-2444: docs; moves nested classes out of ExceptionRecognizer so that they can be more easily referenced.

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

danhaywood pushed a commit to branch ISIS-2444
in repository https://gitbox.apache.org/repos/asf/isis.git

commit b36bef2b37df4297d8ae16718208c734a1884e47
Author: danhaywood <da...@haywood-associates.co.uk>
AuthorDate: Tue Feb 9 06:50:02 2021 +0000

    ISIS-2444: docs; moves nested classes out of ExceptionRecognizer so that they can be more easily referenced.
---
 .../hooks/examples_and_usage.adoc                  |   3 +
 .../hooks/examples_and_usage.adoc                  |   3 +
 .../EventBusService/hooks/examples_and_usage.adoc  |  18 ++--
 .../applib-svc/pages/ExceptionRecognizer.adoc      |  29 +-----
 .../hooks/examples_and_usage.adoc                  |   4 +
 .../isis/applib/services/command/Command.java      |  23 +++--
 .../applib/services/eventbus/EventBusService.java  |  11 ++
 .../isis/applib/services/exceprecog/Category.java  |  45 ++++++++
 .../services/exceprecog/ExceptionRecognizer.java   | 115 +++------------------
 .../exceprecog/ExceptionRecognizerService.java     |   1 -
 .../applib/services/exceprecog/Recognition.java    | 112 ++++++++++++++++++++
 .../publishing/spi/EntityChangesSubscriber.java    |  23 +++--
 .../spi/EntityPropertyChangeSubscriber.java        |  24 ++++-
 ...ExceptionRecognizerForRecoverableException.java |   5 +-
 .../viewer/wicket/ui/errors/ExceptionModel.java    |  22 ++--
 .../wicket/ui/panels/FormExecutorDefault.java      |  53 +++++-----
 .../viewer/integration/WebRequestCycleForIsis.java |  44 ++++----
 17 files changed, 314 insertions(+), 221 deletions(-)

diff --git a/api/applib/src/main/adoc/modules/applib-svc/pages/EntityChangesSubscriber/hooks/examples_and_usage.adoc b/api/applib/src/main/adoc/modules/applib-svc/pages/EntityChangesSubscriber/hooks/examples_and_usage.adoc
index 21f4ba4..0b7e8cd 100644
--- a/api/applib/src/main/adoc/modules/applib-svc/pages/EntityChangesSubscriber/hooks/examples_and_usage.adoc
+++ b/api/applib/src/main/adoc/modules/applib-svc/pages/EntityChangesSubscriber/hooks/examples_and_usage.adoc
@@ -2,3 +2,6 @@
 :Notice: 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 ag [...]
 
 
+== See Also
+
+* xref:system:generated:index/applib/services/publishing/spi/EntityChanges.adoc[EntityChanges]
diff --git a/api/applib/src/main/adoc/modules/applib-svc/pages/EntityPropertyChangeSubscriber/hooks/examples_and_usage.adoc b/api/applib/src/main/adoc/modules/applib-svc/pages/EntityPropertyChangeSubscriber/hooks/examples_and_usage.adoc
index 21f4ba4..9cf2be7 100644
--- a/api/applib/src/main/adoc/modules/applib-svc/pages/EntityPropertyChangeSubscriber/hooks/examples_and_usage.adoc
+++ b/api/applib/src/main/adoc/modules/applib-svc/pages/EntityPropertyChangeSubscriber/hooks/examples_and_usage.adoc
@@ -2,3 +2,6 @@
 :Notice: 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 ag [...]
 
 
+== See also
+
+* xref:system:generated:index/applib/services/publishing/spi/EntityPropertyChange.adoc[EntityPropertyChange]
diff --git a/api/applib/src/main/adoc/modules/applib-svc/pages/EventBusService/hooks/examples_and_usage.adoc b/api/applib/src/main/adoc/modules/applib-svc/pages/EventBusService/hooks/examples_and_usage.adoc
index d45696e..3f76ae1 100644
--- a/api/applib/src/main/adoc/modules/applib-svc/pages/EventBusService/hooks/examples_and_usage.adoc
+++ b/api/applib/src/main/adoc/modules/applib-svc/pages/EventBusService/hooks/examples_and_usage.adoc
@@ -19,9 +19,9 @@ For more on this topic, see xref:refguide:applib-ant:Action.adoc#domainEvent[`@A
 It is also possible for domain objects to programmatically generate domain events.
 However the events are published, the primary use case is to decoupling interactions from one module/package/namespace and another.
 
-== Annotating Members
+== Default Event Classes
 
-As discussed in the introduction, the framework will automatically emit domain events for all of the object members (actions, properties or collections) of an object whenever that object is rendered or (more generally) interacted with.
+The framework will automatically emit domain events for all of the object members (actions, properties or collections) of an object whenever that object is rendered or (more generally) interacted with.
 
 For example:
 
@@ -53,6 +53,8 @@ public class MySubscribingDomainService {
 }
 ----
 
+== Custom Event Classes
+
 More commonly though you will probably want to emit domain events of a specific subtype.
 As a slightly more interesting example, suppose in a library domain that a `LibraryMember` wants to leave the library.
 A letter should be sent out detailing any books that they still have out on loan:
@@ -82,7 +84,7 @@ import org.springframework.stereotype.Service;
 @Service
 public class BookRepository {
 
-    @EventListener(LbraryMemberLeaveEvent.class)
+    @EventListener(LibraryMemberLeaveEvent.class)
     public void onLibraryMemberLeaving(LibraryMemberLeaveEvent e) {
         LibraryMember lm = e.getLibraryMember();
         List<Book> lentBooks = findBooksOnLoanFor(lm);
@@ -190,7 +192,7 @@ This then allows for other classes - in particular domain services contributing
 
 == Programmatic posting
 
-To programmatically post an event, simply call `#post()`.
+To programmatically post an event, simply call xref:system:generated:index/applib/services/eventbus/EventBusService.adoc#post[EventBusService#post].
 
 The `LibraryMember` example described above could for example be rewritten into:
 
@@ -209,10 +211,14 @@ public class LibraryMember {
 
 In practice we suspect there will be few cases where the programmatic approach is required rather than the declarative approach afforded by xref:refguide:applib-ant:Action.adoc#domainEvent[`@Action#domainEvent()`] et al.
 
+
 == Using `WrapperFactory`
 
-An alternative way to cause events to be posted is through the xref:refguide:applib-svc:WrapperFactory.adoc[`WrapperFactory`].
-This is useful when you wish to enforce a (lack-of-) trust boundary between the caller and the callee.
+Using the declarative approach does require that the method to emit the event is an action called directly by the framework (rather than a helper method programmatically called by that action).
+However by using the xref:refguide:applib-svc:WrapperFactory.adoc[`WrapperFactory`] we can invoke that helper method "through" the framework, thereby allowing the framework to emit events.
+(It can also optionally perform validation checks and other concerns associated with the UI).
+
+Another use case for the `WrapperFactory` is when you wish to enforce a (lack-of-) trust boundary between the caller and the callee.
 
 For example, suppose that `Customer#placeOrder(...)` emits a `PlaceOrderEvent`, which is subscribed to by a `ReserveStockSubscriber`.
 This subscriber in turn calls `StockManagementService#reserveStock(...)`.
diff --git a/api/applib/src/main/adoc/modules/applib-svc/pages/ExceptionRecognizer.adoc b/api/applib/src/main/adoc/modules/applib-svc/pages/ExceptionRecognizer.adoc
index d91daa4..836064c 100644
--- a/api/applib/src/main/adoc/modules/applib-svc/pages/ExceptionRecognizer.adoc
+++ b/api/applib/src/main/adoc/modules/applib-svc/pages/ExceptionRecognizer.adoc
@@ -4,38 +4,13 @@
 :Notice: 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 ag [...]
 
 
-WARNING: TODO: this content has not yet been reviewed/updated for v2.0
-
-The `ExceptionRecognizer` SPI provides the mechanism for both the domain programmer and also for components to be able to recognize and handle certain exceptions that may be thrown by the system.
-Rather than display an obscure error to the end-user, the application can instead display a user-friendly message.
-
-For example, the JDO/DataNucleus Objectstore provides a set of recognizers to recognize and handle SQL constraint exceptions such as uniqueness violations.
-These can then be rendered back to the user as expected errors, rather than fatal stacktraces.
-
-
-
-
-== API
-
 include::system:generated:page$index/applib/services/exceprecog/ExceptionRecognizer.adoc[leveloffset=+2]
 
-TODO example migration
 
-.Deprecated Docs
-[WARNING]
-================================
-
-== SPI
-
-The SPI defined by this service is:
+include::ExceptionRecognizer/hooks/implementation.adoc[]
 
-[source,java]
-----
-include::refguide:applib-svc:example$services/exceprecog/ExceptionRecognizer.java[tags="refguide"]
-----
-<.> the main API, to attempt to recognize an exception
+include::ExceptionRecognizer/hooks/examples_and_usage.adoc[]
 
-where `Recognition` translates the recognized exception into a form that can be handled by the framework:
 
 [source,java]
 ----
diff --git a/api/applib/src/main/adoc/modules/applib-svc/pages/ExceptionRecognizer/hooks/examples_and_usage.adoc b/api/applib/src/main/adoc/modules/applib-svc/pages/ExceptionRecognizer/hooks/examples_and_usage.adoc
index 21f4ba4..fdc72ce 100644
--- a/api/applib/src/main/adoc/modules/applib-svc/pages/ExceptionRecognizer/hooks/examples_and_usage.adoc
+++ b/api/applib/src/main/adoc/modules/applib-svc/pages/ExceptionRecognizer/hooks/examples_and_usage.adoc
@@ -2,3 +2,7 @@
 :Notice: 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 ag [...]
 
 
+== See also
+
+* xref:system:generated:index/applib/services/exceprecog/Recognition.adoc[Recognition]
+* xref:system:generated:index/applib/services/exceprecog/Category.adoc[Category]
diff --git a/api/applib/src/main/java/org/apache/isis/applib/services/command/Command.java b/api/applib/src/main/java/org/apache/isis/applib/services/command/Command.java
index ce1edda..51f874c 100644
--- a/api/applib/src/main/java/org/apache/isis/applib/services/command/Command.java
+++ b/api/applib/src/main/java/org/apache/isis/applib/services/command/Command.java
@@ -26,6 +26,7 @@ import org.apache.isis.applib.jaxb.JavaSqlXMLGregorianCalendarMarshalling;
 import org.apache.isis.applib.services.bookmark.Bookmark;
 import org.apache.isis.applib.services.commanddto.HasCommandDto;
 import org.apache.isis.applib.services.iactn.Interaction;
+import org.apache.isis.applib.services.publishing.spi.CommandSubscriber;
 import org.apache.isis.applib.services.wrapper.WrapperFactory;
 import org.apache.isis.applib.services.wrapper.control.AsyncControl;
 import org.apache.isis.commons.functional.Result;
@@ -70,7 +71,7 @@ import lombok.extern.log4j.Log4j2;
  *     is created, and the originating {@link Command} is set to be its
  *     {@link Command#getParent() parent}.
  * </p>
- * 
+ *
  * @since 1.x {@index}
  */
 @RequiredArgsConstructor
@@ -88,7 +89,7 @@ public class Command implements HasUniqueId, HasUsername, HasCommandDto {
     @Getter
         (onMethod_ = {@Override})
     private final UUID uniqueId;
-    
+
     /**
      * The user that created the command.
      *
@@ -244,7 +245,7 @@ public class Command implements HasUniqueId, HasUsername, HasCommandDto {
 
 
     /**
-     * Whether this command has been enabled for dispatching, 
+     * Whether this command has been enabled for dispatching,
      * that is {@link CommandSubscriber}s will be notified when this Command completes.
      */
     @Getter
@@ -263,17 +264,17 @@ public class Command implements HasUniqueId, HasUsername, HasCommandDto {
          */
         public void setCommandDto(final CommandDto commandDto) {
             Command.this.commandDto = commandDto;
-            
+
             // even though redundant, but must ensure commandUniqueId == dtoUniqueId
-            val commandUniqueId = Command.this.getUniqueId().toString(); 
+            val commandUniqueId = Command.this.getUniqueId().toString();
             val dtoUniqueId = commandDto.getTransactionId();
-            
+
             if(!commandUniqueId.equals(dtoUniqueId)) {
                 log.warn("setting CommandDto on a Command has side-effects when "
-                        + "both their UniqueIds don't match"); 
-                commandDto.setTransactionId(commandUniqueId);    
+                        + "both their UniqueIds don't match");
+                commandDto.setTransactionId(commandUniqueId);
             }
-            
+
         }
         /**
          * <b>NOT API</b>: intended to be called only by the framework.
@@ -312,8 +313,8 @@ public class Command implements HasUniqueId, HasUsername, HasCommandDto {
          */
         @Override
         public void setResult(final Result<Bookmark> resultBookmark) {
-            Command.this.result = resultBookmark.getValue().orElse(null);
-            Command.this.exception = resultBookmark.getFailure().orElse(null);
+            Command.this.result = resultBookmark.value().orElse(null);
+            Command.this.exception = resultBookmark.failure().orElse(null);
         }
 
         /**
diff --git a/api/applib/src/main/java/org/apache/isis/applib/services/eventbus/EventBusService.java b/api/applib/src/main/java/org/apache/isis/applib/services/eventbus/EventBusService.java
index 335a573..9d4987f 100644
--- a/api/applib/src/main/java/org/apache/isis/applib/services/eventbus/EventBusService.java
+++ b/api/applib/src/main/java/org/apache/isis/applib/services/eventbus/EventBusService.java
@@ -30,6 +30,17 @@ package org.apache.isis.applib.services.eventbus;
  */
 public interface EventBusService {
 
+    /**
+     * Post an event (of any class) to the in-memory event bus.
+     *
+     * <p>
+     *     The event will be delivered synchronously (within the same
+     *     transactional boundary) to all subscribers of that event type
+     *     (with subscribers as domain services with public method annotated
+     *     using Spring's
+     *     {@link org.springframework.context.event.EventListener} annotation.
+     * </p>
+     */
     void post(Object event);
 
 }
diff --git a/api/applib/src/main/java/org/apache/isis/applib/services/exceprecog/Category.java b/api/applib/src/main/java/org/apache/isis/applib/services/exceprecog/Category.java
new file mode 100644
index 0000000..35531df
--- /dev/null
+++ b/api/applib/src/main/java/org/apache/isis/applib/services/exceprecog/Category.java
@@ -0,0 +1,45 @@
+package org.apache.isis.applib.services.exceprecog;
+
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+/**
+ * Categorises each exception that has been recognised, as per
+ * {@link Recognition#getCategory()}.
+ */
+@RequiredArgsConstructor
+public enum Category {
+    /**
+     * A violation of some declarative constraint (eg uniqueness or referential integrity) was detected.
+     */
+    CONSTRAINT_VIOLATION(
+            "violation of some declarative constraint"),
+    /**
+     * The object to be acted upon cannot be found (404)
+     */
+    NOT_FOUND(
+            "object not found"),
+    /**
+     * A concurrency exception, in other words some other user has changed this object.
+     */
+    CONCURRENCY(
+            "concurrent modification"),
+    /**
+     * Recognized, but for some other reason... 40x error
+     */
+    CLIENT_ERROR(
+            "client side error"),
+    /**
+     * 50x error
+     */
+    SERVER_ERROR(
+            "server side error"),
+    /**
+     * Recognized, but uncategorized (typically: a recognizer of the original ExceptionRecognizer API).
+     */
+    OTHER(
+            "other");
+
+    @Getter
+    private final String friendlyName;
+}
diff --git a/api/applib/src/main/java/org/apache/isis/applib/services/exceprecog/ExceptionRecognizer.java b/api/applib/src/main/java/org/apache/isis/applib/services/exceprecog/ExceptionRecognizer.java
index 9df2cee..286aed9 100644
--- a/api/applib/src/main/java/org/apache/isis/applib/services/exceprecog/ExceptionRecognizer.java
+++ b/api/applib/src/main/java/org/apache/isis/applib/services/exceprecog/ExceptionRecognizer.java
@@ -20,16 +20,6 @@ package org.apache.isis.applib.services.exceprecog;
 
 import java.util.Optional;
 
-import javax.annotation.Nullable;
-
-import org.apache.isis.applib.services.i18n.TranslationService;
-
-import lombok.Getter;
-import lombok.NonNull;
-import lombok.RequiredArgsConstructor;
-import lombok.Value;
-import lombok.val;
-
 /**
  * Domain service to (attempt) to recognize certain
  * exceptions, and return user-friendly messages instead.
@@ -40,14 +30,21 @@ import lombok.val;
  * as a regular validation message.
  *
  * <p>
+ * For example, a set of recognizers are provided for the JPA and JDO
+ * persistence mechanisms in order to recognize and handle SQL constraint
+ * exceptions such as uniqueness violations. These can then be rendered back to
+ * the user as expected errors, rather than fatal stacktraces.
+ *
+ * <p>
  * More than one implementation of {@link ExceptionRecognizer} can
- * be registered; they will all be consulted (in the order as specified by the @Order annotation) 
+ * be registered; they will all be consulted (in the order as specified by
+ * Spring's {@link org.springframework.core.annotation.Order} annotation)
  * to determine if they recognize the exception.
  * The message returned by the first service recognizing the exception is
  * used.
  *
  * <p>
- * The Isis framework also provides a default implementation of this
+ * The framework also provides a default implementation of this
  * service that recognizes any {@link org.apache.isis.applib.exceptions.RecoverableException}, simply returning
  * the exception's {@link org.apache.isis.applib.exceptions.RecoverableException#getMessage() message}.  This
  * allows any component or domain object to throw this exception with
@@ -56,7 +53,7 @@ import lombok.val;
  * <p>
  * Initially introduced for the Wicket viewer; check the documentation
  * of other viewers to determine whether they also support this service.
- * 
+ *
  * @since 1.x {@index}
  */
 public interface ExceptionRecognizer {
@@ -66,99 +63,11 @@ public interface ExceptionRecognizer {
      * message to render instead.
      *
      * @return optionally a
-     * {@link org.apache.isis.applib.services.exceprecog.ExceptionRecognizer.Recognition recognition} object,
+     * {@link Recognition recognition} object,
      * that describes both the
-     * {@link org.apache.isis.applib.services.exceprecog.ExceptionRecognizer.Category category}
+     * {@link Category category}
      * and reason that will be included with the user-friendly message.
      */
     Optional<Recognition> recognize(Throwable ex);
 
-    @RequiredArgsConstructor
-    enum Category {
-        /**
-         * A violation of some declarative constraint (eg uniqueness or referential integrity) was detected.
-         */
-        CONSTRAINT_VIOLATION(
-                "violation of some declarative constraint"),
-        /**
-         * The object to be acted upon cannot be found (404)
-         */
-        NOT_FOUND(
-                "object not found"),
-        /**
-         * A concurrency exception, in other words some other user has changed this object.
-         */
-        CONCURRENCY(
-                "concurrent modification"),
-        /**
-         * Recognized, but for some other reason... 40x error
-         */
-        CLIENT_ERROR(
-                "client side error"),
-        /**
-         * 50x error
-         */
-        SERVER_ERROR(
-                "server side error"),
-        /**
-         * Recognized, but uncategorized (typically: a recognizer of the original ExceptionRecognizer API).
-         */
-        OTHER(
-                "other")
-        ;
-
-        @Getter
-        private final String friendlyName;
-    }
-
-    @Value
-    class Recognition {
-
-        /**
-         * @return optionally a recognition of the specified type, based on a whether given reason is non-null
-         */
-        public static Optional<Recognition> of(
-                @Nullable final Category category,
-                @Nullable final String reason) {
-
-            if(reason==null) {
-                return Optional.empty();
-            }
-
-            val nonNullCategory = category!=null? category: Category.OTHER;
-            return Optional.of(new Recognition(nonNullCategory, reason));
-            // ...
-        }
-
-        @NonNull private final Category category;
-        @NonNull private final String reason;
-
-        public String toMessage(@Nullable TranslationService translationService) {
-
-            val categoryLiteral = translate(getCategory().getFriendlyName(), translationService);
-            val reasonLiteral = translate(getReason(), translationService);
-
-            return String.format("[%s]: %s", categoryLiteral, reasonLiteral);
-            // ...
-        }
-        
-        public String toMessageNoCategory(@Nullable TranslationService translationService) {
-
-            val reasonLiteral = translate(getReason(), translationService);
-            return String.format("%s", reasonLiteral);
-            // ...
-        }
-        
-        private static String translate(
-                @Nullable String x, 
-                @Nullable TranslationService translationService) {
-            if(x==null || translationService==null) {
-                return x;
-            }
-            return translationService.translate(
-                    ExceptionRecognizer.Recognition.class.getName(), x);
-        }
-        
-    }
-
 }
diff --git a/api/applib/src/main/java/org/apache/isis/applib/services/exceprecog/ExceptionRecognizerService.java b/api/applib/src/main/java/org/apache/isis/applib/services/exceprecog/ExceptionRecognizerService.java
index 36fe0ae..9ef0ced 100644
--- a/api/applib/src/main/java/org/apache/isis/applib/services/exceprecog/ExceptionRecognizerService.java
+++ b/api/applib/src/main/java/org/apache/isis/applib/services/exceprecog/ExceptionRecognizerService.java
@@ -20,7 +20,6 @@ package org.apache.isis.applib.services.exceprecog;
 
 import java.util.Optional;
 
-import org.apache.isis.applib.services.exceprecog.ExceptionRecognizer.Recognition;
 import org.apache.isis.commons.collections.Can;
 
 /**
diff --git a/api/applib/src/main/java/org/apache/isis/applib/services/exceprecog/Recognition.java b/api/applib/src/main/java/org/apache/isis/applib/services/exceprecog/Recognition.java
new file mode 100644
index 0000000..551d1e2
--- /dev/null
+++ b/api/applib/src/main/java/org/apache/isis/applib/services/exceprecog/Recognition.java
@@ -0,0 +1,112 @@
+package org.apache.isis.applib.services.exceprecog;
+
+import java.util.Optional;
+
+import javax.annotation.Nullable;
+
+import org.apache.isis.applib.services.i18n.TranslationService;
+
+import lombok.NonNull;
+import lombok.Value;
+import lombok.val;
+
+/**
+ * Represents a user-friendly representation of an exception that has been
+ * recognised by an available implementation of an {@link ExceptionRecognizer}.
+ *
+ * <p>
+ * Returned by {@link ExceptionRecognizer#recognize(Throwable)} when the
+ * exception recognizer has recognised the exception
+ */
+@Value
+public class Recognition {
+
+    /**
+     * @return optionally a recognition of the specified type, based on a whether given reason is non-null
+     */
+    public static Optional<Recognition> of(
+            @Nullable final Category category,
+            @Nullable final String reason) {
+
+        if (reason == null) {
+            return Optional.empty();
+        }
+
+        val nonNullCategory = category != null ? category : Category.OTHER;
+        return Optional.of(new Recognition(nonNullCategory, reason));
+        // ...
+    }
+
+    /**
+     * Categorises the exception as per {@link Category}.
+     *
+     * <p>
+     *     This category can also optionally be used in the translation of the
+     *     {@link #getReason() reason} for the exception.
+     * </p>
+     *
+     * @see #toMessage(TranslationService)
+     */
+    @NonNull
+    Category category;
+
+    /**
+     * The untranslated user-friendly reason for the exception.
+     *
+     * <p>
+     *     The reason can also be translated (prepended or not by the
+     *     translation of the {@link #getCategory() category} using
+     *     {@link #toMessage(TranslationService)} or
+     *     {@link #toMessageNoCategory(TranslationService)}.
+     *     .
+     * </p>
+     *
+     * @see #toMessage(TranslationService)
+     * @see #toMessageNoCategory(TranslationService)
+     */
+    @NonNull
+    String reason;
+
+    /**
+     * Translates the {@link #getReason() reason} and prepends with a
+     * translation of {@link #getCategory() category}, using the provided
+     * {@link TranslationService}..
+     *
+     * @param translationService
+     * @return
+     */
+    public String toMessage(@Nullable TranslationService translationService) {
+
+        val categoryLiteral = translate(getCategory().getFriendlyName(), translationService);
+        val reasonLiteral = translate(getReason(), translationService);
+
+        return String.format("[%s]: %s", categoryLiteral, reasonLiteral);
+        // ...
+    }
+
+    /**
+     * Translates the {@link #getReason() reason} alone (ignoring the
+     * {@link #getCategory() category}, using the provided
+     * {@link TranslationService}..
+     *
+     * @param translationService
+     * @return
+     */
+    public String toMessageNoCategory(@Nullable TranslationService translationService) {
+
+        val reasonLiteral = translate(getReason(), translationService);
+        return String.format("%s", reasonLiteral);
+        // ...
+    }
+
+    private static String translate(
+            @Nullable String x,
+            @Nullable TranslationService translationService) {
+        if (x == null || translationService == null) {
+            return x;
+        }
+        return translationService.translate(
+                Recognition.class.getName(), x);
+    }
+
+}
diff --git a/api/applib/src/main/java/org/apache/isis/applib/services/publishing/spi/EntityChangesSubscriber.java b/api/applib/src/main/java/org/apache/isis/applib/services/publishing/spi/EntityChangesSubscriber.java
index 10c82f7..6e40a23 100644
--- a/api/applib/src/main/java/org/apache/isis/applib/services/publishing/spi/EntityChangesSubscriber.java
+++ b/api/applib/src/main/java/org/apache/isis/applib/services/publishing/spi/EntityChangesSubscriber.java
@@ -18,20 +18,31 @@
  */
 package org.apache.isis.applib.services.publishing.spi;
 
+import org.apache.isis.applib.annotation.DomainObject;
 import org.apache.isis.commons.having.HasEnabling;
 
 /**
- * Part of the <i>Publishing SPI</i>. A component to receive the entire set of entities 
- * (with publishing enabled) that are about to change, serializable as ChangesDto.
- *  
- * 
+ * SPI to receive the entire set of entities that are about to change as the
+ * result of a transaction.
+ *
+ * <p>
+ *     Only those entities that have publishing enabled (using
+ *  * {@link DomainObject#entityChangePublishing()}) are included.
+ * </p>
+ *
  * @since 2.0 {@index}
  */
 public interface EntityChangesSubscriber extends HasEnabling {
 
     /**
-     * Receives all changing entities (with publishing enabled) at then end of the a 
-     * transaction during the pre-commit phase.
+     * Receives all changing entities (with publishing enabled using
+     * {@link DomainObject#entityChangePublishing()}) as an instance of
+     * {@link EntityChanges}.
+     *
+     * <p>
+     *     The callback is called at the end of the transaction, during the
+     *     pre-commit phase.
+     * </p>
      */
     void onChanging(EntityChanges entityChanges);
 }
diff --git a/api/applib/src/main/java/org/apache/isis/applib/services/publishing/spi/EntityPropertyChangeSubscriber.java b/api/applib/src/main/java/org/apache/isis/applib/services/publishing/spi/EntityPropertyChangeSubscriber.java
index 1e36de9..732d420 100644
--- a/api/applib/src/main/java/org/apache/isis/applib/services/publishing/spi/EntityPropertyChangeSubscriber.java
+++ b/api/applib/src/main/java/org/apache/isis/applib/services/publishing/spi/EntityPropertyChangeSubscriber.java
@@ -18,19 +18,33 @@
  */
 package org.apache.isis.applib.services.publishing.spi;
 
+import org.apache.isis.applib.annotation.DomainObject;
 import org.apache.isis.commons.having.HasEnabling;
 
 /**
- * Part of the <i>Publishing SPI</i>. A component to receive pre-post property values 
- * for each changed entity (with publishing enabled).
- *  
+ * SPI called for each pre-post change to a property of a domain entity during
+ * a result of the transaction.  The callback is therefore quite fine-grained
+ * and will be called  many (many) times for within any given transaction.
+ *
+ * <p>
+ *     Only those properties of entities that have publishing enabled (using
+ *  * {@link DomainObject#entityChangePublishing()}) are included.
+ * </p>
+ *
+ *
  * @since 2.0 {@index}
  */
 public interface EntityPropertyChangeSubscriber extends HasEnabling {
 
     /**
-     * Receives all pre-post property values for entities (with publishing enabled) 
-     * at then end of the transaction during the pre-commit phase.
+     * Receives a single property change event for changing entities (with
+     * publishing enabled using {@link DomainObject#entityChangePublishing()})
+     * as an instance of {@link EntityPropertyChange}.
+     *
+     * <p>
+     *     The callback is called (multiple times) at the end of the
+     *     transaction, during the pre-commit phase.
+     * </p>
      */
     void onChanging(EntityPropertyChange entityPropertyChange);
 
diff --git a/core/metamodel/src/main/java/org/apache/isis/core/metamodel/services/exceprecog/ExceptionRecognizerForRecoverableException.java b/core/metamodel/src/main/java/org/apache/isis/core/metamodel/services/exceprecog/ExceptionRecognizerForRecoverableException.java
index 3415c51..dde9044 100644
--- a/core/metamodel/src/main/java/org/apache/isis/core/metamodel/services/exceprecog/ExceptionRecognizerForRecoverableException.java
+++ b/core/metamodel/src/main/java/org/apache/isis/core/metamodel/services/exceprecog/ExceptionRecognizerForRecoverableException.java
@@ -28,12 +28,13 @@ import org.springframework.stereotype.Service;
 
 import org.apache.isis.applib.annotation.OrderPrecedence;
 import org.apache.isis.applib.exceptions.RecoverableException;
+import org.apache.isis.applib.services.exceprecog.Category;
 import org.apache.isis.applib.services.exceprecog.ExceptionRecognizer;
 import org.apache.isis.applib.services.exceprecog.ExceptionRecognizerForType;
 
 /**
  * Framework-provided implementation of {@link ExceptionRecognizer},
- * which will automatically recognize any 
+ * which will automatically recognize any
  * {@link org.apache.isis.applib.exceptions.RecoverableException}s.
  */
 @Service
@@ -42,7 +43,7 @@ import org.apache.isis.applib.services.exceprecog.ExceptionRecognizerForType;
 @Primary
 @Qualifier("Default")
 public class ExceptionRecognizerForRecoverableException extends ExceptionRecognizerForType {
-    
+
     public ExceptionRecognizerForRecoverableException() {
         super(Category.CLIENT_ERROR, RecoverableException.class);
     }
diff --git a/viewers/wicket/ui/src/main/java/org/apache/isis/viewer/wicket/ui/errors/ExceptionModel.java b/viewers/wicket/ui/src/main/java/org/apache/isis/viewer/wicket/ui/errors/ExceptionModel.java
index 72bb510..66a81a8 100644
--- a/viewers/wicket/ui/src/main/java/org/apache/isis/viewer/wicket/ui/errors/ExceptionModel.java
+++ b/viewers/wicket/ui/src/main/java/org/apache/isis/viewer/wicket/ui/errors/ExceptionModel.java
@@ -24,7 +24,7 @@ import java.util.Optional;
 import org.apache.isis.applib.exceptions.UnrecoverableException;
 import org.apache.isis.applib.services.error.ErrorReportingService;
 import org.apache.isis.applib.services.error.Ticket;
-import org.apache.isis.applib.services.exceprecog.ExceptionRecognizer.Recognition;
+import org.apache.isis.applib.services.exceprecog.Recognition;
 import org.apache.isis.commons.internal.base._Casts;
 import org.apache.isis.commons.internal.collections._Lists;
 import org.apache.isis.commons.internal.exceptions._Exceptions;
@@ -48,29 +48,29 @@ public class ExceptionModel extends ModelAbstract<List<StackTraceDetail>> {
     private final String mainMessage;
 
     public static ExceptionModel create(
-            IsisAppCommonContext commonContext, 
-            Optional<Recognition> recognition, 
+            IsisAppCommonContext commonContext,
+            Optional<Recognition> recognition,
             Exception ex) {
-        
+
         val translationService = commonContext.getTranslationService();
         val recognizedMessage = recognition.map($->$.toMessage(translationService));
-        
+
         return new ExceptionModel(commonContext, recognizedMessage.orElse(null), ex);
     }
 
     /**
      * Three cases: authorization exception, else recognized, else or not recognized.
-     * @param commonContext 
+     * @param commonContext
      * @param recognizedMessageIfAny
      * @param ex
      */
     private ExceptionModel(IsisAppCommonContext commonContext, String recognizedMessageIfAny, Exception ex) {
-        
+
         super(commonContext);
 
-        final ObjectMember.AuthorizationException authorizationException = 
+        final ObjectMember.AuthorizationException authorizationException =
                 causalChainOf(ex, ObjectMember.AuthorizationException.class);
-        
+
         if(authorizationException != null) {
             this.authorizationCause = true;
             this.mainMessage = authorizationException.getMessage();
@@ -83,7 +83,7 @@ public class ExceptionModel extends ModelAbstract<List<StackTraceDetail>> {
                 this.recognized =false;
 
                 // see if we can find a NonRecoverableException in the stack trace
-                
+
                 UnrecoverableException nonRecoverableException =
                 _Exceptions.streamCausalChain(ex)
                 .filter(UnrecoverableException.class::isInstance)
@@ -92,7 +92,7 @@ public class ExceptionModel extends ModelAbstract<List<StackTraceDetail>> {
                 .orElse(null);
 
                 this.mainMessage = nonRecoverableException != null
-                        ? nonRecoverableException.getMessage() 
+                        ? nonRecoverableException.getMessage()
                                 : MAIN_MESSAGE_IF_NOT_RECOGNIZED;
             }
         }
diff --git a/viewers/wicket/ui/src/main/java/org/apache/isis/viewer/wicket/ui/panels/FormExecutorDefault.java b/viewers/wicket/ui/src/main/java/org/apache/isis/viewer/wicket/ui/panels/FormExecutorDefault.java
index cddadad..0db6c5e 100644
--- a/viewers/wicket/ui/src/main/java/org/apache/isis/viewer/wicket/ui/panels/FormExecutorDefault.java
+++ b/viewers/wicket/ui/src/main/java/org/apache/isis/viewer/wicket/ui/panels/FormExecutorDefault.java
@@ -34,9 +34,8 @@ import org.apache.wicket.util.visit.IVisit;
 import org.apache.wicket.util.visit.IVisitor;
 
 import org.apache.isis.applib.services.command.Command;
-import org.apache.isis.applib.services.exceprecog.ExceptionRecognizer;
-import org.apache.isis.applib.services.exceprecog.ExceptionRecognizer.Category;
-import org.apache.isis.applib.services.exceprecog.ExceptionRecognizer.Recognition;
+import org.apache.isis.applib.services.exceprecog.Category;
+import org.apache.isis.applib.services.exceprecog.Recognition;
 import org.apache.isis.applib.services.exceprecog.ExceptionRecognizerService;
 import org.apache.isis.applib.services.i18n.TranslationService;
 import org.apache.isis.applib.services.message.MessageService;
@@ -89,7 +88,7 @@ implements FormExecutor {
      * @param feedbackFormIfAny
      * @param withinPrompt
      *
-     * @return <tt>false</tt> - if invalid args; 
+     * @return <tt>false</tt> - if invalid args;
      * <tt>true</tt> if redirecting to new page, or repainting all components
      */
     @Override
@@ -156,8 +155,8 @@ implements FormExecutor {
                 redirectFacet = actionModel.getMetaModel().getFacet(RedirectFacet.class);
             }
 
-            if (shouldRedirect(targetAdapter, resultAdapter, redirectFacet) 
-                    || hasBlobsOrClobs(page)                                       
+            if (shouldRedirect(targetAdapter, resultAdapter, redirectFacet)
+                    || hasBlobsOrClobs(page)
                     || targetIfAny == null) {
 
                 redirectTo(resultAdapter, targetIfAny);
@@ -179,9 +178,9 @@ implements FormExecutor {
 
                 currentMessageBroker().ifPresent(messageBorker->{
                     final String jGrowlCalls = JGrowlUtil.asJGrowlCalls(messageBorker);
-                    targetIfAny.appendJavaScript(jGrowlCalls);    
+                    targetIfAny.appendJavaScript(jGrowlCalls);
                 });
-                
+
             }
 
             return true;
@@ -198,9 +197,9 @@ implements FormExecutor {
             // if we did recognize the message, and not inline prompt, then display to user as a growl pop-up
             if (messageWhenRecognized.isPresent() && !withinPrompt) {
                 // ... display as growl pop-up
-                
+
                 currentMessageBroker().ifPresent(messageBroker->{
-                    messageBroker.setApplicationError(messageWhenRecognized.get());    
+                    messageBroker.setApplicationError(messageWhenRecognized.get());
                 });
 
                 //TODO [2089] hotfix to render the error on the same page instead of redirecting;
@@ -214,9 +213,9 @@ implements FormExecutor {
 
                 //TODO (dead code) should happen at a more fundamental level
                 // should not be a responsibility of the viewer
-                
+
                 command.updater().setResult(Result.failure(ex));
-                
+
                 //XXX legacy of
                 //command.internal().setException(Throwables.getStackTraceAsString(ex));
             }
@@ -303,7 +302,7 @@ implements FormExecutor {
 //        // this will not preserve the URL (because pageParameters are not copied over)
 //        // but trying to preserve them seems to cause the 302 redirect to be swallowed somehow
 //        val entityPage = new EntityPage(model.getCommonContext() , targetAdapter);
-//        
+//
 //        // force any changes in state etc to happen now prior to the redirect;
 //        // in the case of an object being returned, this should cause our page mementos
 //        // (eg EntityModel) to hold the correct state.  I hope.
@@ -314,9 +313,9 @@ implements FormExecutor {
 //        requestCycle.setResponsePage(entityPage);
 //    }
 
-    
+
     private static boolean shouldRedraw(final Component component) {
-        
+
         // hmm... this doesn't work, because I think that the components
         // get removed after they've been added to target.
         // so.. still getting WARN log messages from XmlPartialPageUpdate
@@ -358,7 +357,7 @@ implements FormExecutor {
             component.visitParents(MarkupContainer.class, (parent, visit) -> {
                 componentsToRedraw.remove(parent); // no-op if not in that list
             });
-            
+
             if(component instanceof MarkupContainer) {
                 val containerNotToRedraw = (MarkupContainer) component;
                 containerNotToRedraw.visitChildren((child, visit) -> {
@@ -384,7 +383,7 @@ implements FormExecutor {
     }
 
     private void debug(
-            final String title, 
+            final String title,
             final Collection<Component> list) {
         log.debug(">>> {}:", title);
         for (Component component : list) {
@@ -397,10 +396,10 @@ implements FormExecutor {
     }
 
     private Optional<Recognition> recognizeException(
-            final Throwable ex, 
-            final AjaxRequestTarget target, 
+            final Throwable ex,
+            final AjaxRequestTarget target,
             final Form<?> feedbackForm) {
-        
+
         val recognition = getExceptionRecognizerService().recognize(ex);
         recognition.ifPresent(recog->raiseWarning(target, feedbackForm, recog));
         return recognition;
@@ -413,7 +412,7 @@ implements FormExecutor {
 
         if(targetIfAny != null && feedbackFormIfAny != null) {
             //[ISIS-2419] for a consistent user experience with action dialog validation messages,
-            //be less verbose (suppress the category) if its a Category.CONSTRAINT_VIOLATION. 
+            //be less verbose (suppress the category) if its a Category.CONSTRAINT_VIOLATION.
             val errorMsg = recognition.getCategory()==Category.CONSTRAINT_VIOLATION
                     ? recognition.toMessageNoCategory(getTranslationService())
                     : recognition.toMessage(getTranslationService());
@@ -425,7 +424,7 @@ implements FormExecutor {
         }
     }
 
-    // -- DEPENDENCIES 
+    // -- DEPENDENCIES
 
     private IsisAppCommonContext getCommonContext() {
         return model.getCommonContext();
@@ -434,15 +433,15 @@ implements FormExecutor {
     protected ExceptionRecognizerService getExceptionRecognizerService() {
         return getServiceRegistry().lookupServiceElseFail(ExceptionRecognizerService.class);
     }
-    
+
     protected TranslationService getTranslationService() {
         return getServiceRegistry().lookupServiceElseFail(TranslationService.class);
     }
-    
+
     protected MessageService getMessageService() {
         return getServiceRegistry().lookupServiceElseFail(MessageService.class);
     }
-    
+
     protected ServiceRegistry getServiceRegistry() {
         return getCommonContext().getServiceRegistry();
     }
@@ -470,9 +469,9 @@ implements FormExecutor {
     }
 
     private Optional<Recognition> getReasonInvalidIfAny() {
-        val reason = formExecutorStrategy.getReasonInvalidIfAny(); 
+        val reason = formExecutorStrategy.getReasonInvalidIfAny();
         val category = Category.CONSTRAINT_VIOLATION;
-        return ExceptionRecognizer.Recognition.of(category, reason);
+        return Recognition.of(category, reason);
     }
 
     private void onExecuteAndProcessResults(final AjaxRequestTarget target) {
diff --git a/viewers/wicket/viewer/src/main/java/org/apache/isis/viewer/wicket/viewer/integration/WebRequestCycleForIsis.java b/viewers/wicket/viewer/src/main/java/org/apache/isis/viewer/wicket/viewer/integration/WebRequestCycleForIsis.java
index c4001fe..6fb9ec1 100644
--- a/viewers/wicket/viewer/src/main/java/org/apache/isis/viewer/wicket/viewer/integration/WebRequestCycleForIsis.java
+++ b/viewers/wicket/viewer/src/main/java/org/apache/isis/viewer/wicket/viewer/integration/WebRequestCycleForIsis.java
@@ -44,7 +44,7 @@ import org.apache.wicket.request.cycle.RequestCycle;
 import org.apache.wicket.request.mapper.parameter.PageParameters;
 
 import org.apache.isis.applib.services.exceprecog.ExceptionRecognizer;
-import org.apache.isis.applib.services.exceprecog.ExceptionRecognizer.Recognition;
+import org.apache.isis.applib.services.exceprecog.Recognition;
 import org.apache.isis.applib.services.exceprecog.ExceptionRecognizerForType;
 import org.apache.isis.applib.services.exceprecog.ExceptionRecognizerService;
 import org.apache.isis.commons.collections.Can;
@@ -72,18 +72,18 @@ import lombok.extern.log4j.Log4j2;
  * Isis-specific implementation of the Wicket's {@link RequestCycle},
  * automatically opening a {@link InteractionSession} at the beginning of the request
  * and committing the transaction and closing the session at the end.
- * 
+ *
  * @since 2.0
  */
 @Log4j2
 public class WebRequestCycleForIsis implements IRequestCycleListener {
 
-    public static final MetaDataKey<IsisRequestCycle> REQ_CYCLE_HANDLE_KEY = 
+    public static final MetaDataKey<IsisRequestCycle> REQ_CYCLE_HANDLE_KEY =
             new MetaDataKey<IsisRequestCycle>() {private static final long serialVersionUID = 1L; };
-    
+
     private PageClassRegistry pageClassRegistry;
     private IsisAppCommonContext commonContext;
-    
+
     @Override
     public synchronized void onBeginRequest(RequestCycle requestCycle) {
 
@@ -97,8 +97,8 @@ public class WebRequestCycleForIsis implements IRequestCycleListener {
 
         val commonContext = getCommonContext();
         val authentication = AuthenticatedWebSessionForIsis.get().getAuthentication();
-        
-        
+
+
         if (authentication == null) {
             log.debug("onBeginRequest out - session was not opened (because no authentication)");
             return;
@@ -106,7 +106,7 @@ public class WebRequestCycleForIsis implements IRequestCycleListener {
 
         val isisRequestCycle = IsisRequestCycle.next(
                 commonContext.lookupServiceElseFail(InteractionFactory.class));
-        
+
         requestCycle.setMetaData(REQ_CYCLE_HANDLE_KEY, isisRequestCycle);
 
         isisRequestCycle.onBeginRequest(authentication);
@@ -120,9 +120,9 @@ public class WebRequestCycleForIsis implements IRequestCycleListener {
         log.debug("onRequestHandlerResolved in");
 
         if(handler instanceof RenderPageRequestHandler) {
-            
+
             val validationResult = getCommonContext().getSpecificationLoader().getValidationResult();
-            
+
             if(validationResult.hasFailures()) {
                 RenderPageRequestHandler requestHandler = (RenderPageRequestHandler) handler;
                 final IRequestablePage nextPage = requestHandler.getPage();
@@ -199,7 +199,7 @@ public class WebRequestCycleForIsis implements IRequestCycleListener {
     public IRequestHandler onException(RequestCycle cycle, Exception ex) {
 
         log.debug("onException");
-        
+
         val validationResult = getCommonContext().getSpecificationLoader().getValidationResult();
         if(validationResult.hasFailures()) {
             val mmvErrorPage = new MmvErrorPage(validationResult.getMessages("[%d] %s"));
@@ -268,16 +268,16 @@ public class WebRequestCycleForIsis implements IRequestCycleListener {
     }
 
     private void addTranslatedMessage(final String translatedSuffixIfAny) {
-        
+
         getMessageBroker().ifPresent(broker->{
-        
+
             final String translatedPrefix = translate("Action no longer available");
             final String message = translatedSuffixIfAny != null
                     ? String.format("%s (%s)", translatedPrefix, translatedSuffixIfAny)
                     : translatedPrefix;
-            
+
             broker.addMessage(message);
-            
+
         });
     }
 
@@ -300,21 +300,21 @@ public class WebRequestCycleForIsis implements IRequestCycleListener {
     protected IRequestablePage errorPageFor(Exception ex) {
 
         final Optional<Recognition> recognition;
-        
+
         if(isInInteraction()) {
             val exceptionRecognizerService = getCommonContext().getServiceRegistry()
             .lookupServiceElseFail(ExceptionRecognizerService.class);
-            
+
             recognition = exceptionRecognizerService
                     .recognizeFromSelected(
                             Can.<ExceptionRecognizer>ofSingleton(pageExpiredExceptionRecognizer)
                             .addAll(exceptionRecognizerService.getExceptionRecognizers()),
                             ex);
-            
+
         } else {
-            
+
             recognition = Optional.empty();
-            
+
             val validationResult = getCommonContext().getSpecificationLoader().getValidationResult();
             if(validationResult.hasFailures()) {
                 return new MmvErrorPage(validationResult.getMessages("[%d] %s"));
@@ -377,11 +377,11 @@ public class WebRequestCycleForIsis implements IRequestCycleListener {
     }
 
     // -- DEPENDENCIES
-    
+
     public IsisAppCommonContext getCommonContext() {
         return commonContext = CommonContextUtils.computeIfAbsent(commonContext);
     }
-    
+
     private ExceptionRecognizerService getExceptionRecognizerService() {
         return getCommonContext().getServiceRegistry().lookupServiceElseFail(ExceptionRecognizerService.class);
     }