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/10/07 19:16:14 UTC

[isis] 01/05: ISIS-2783: petclinic updated to 2.0.0-M6

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

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

commit 121e3c11de0fa4dc9c681a0ba56a1e90d6bea1f6
Author: danhaywood <da...@haywood-associates.co.uk>
AuthorDate: Thu Oct 7 08:31:52 2021 +0100

    ISIS-2783: petclinic updated to 2.0.0-M6
---
 .run/SimpleApp.run.xml                             |   2 +-
 .../partials/UNUSED/faster-turnaround-times.adoc   |   2 +-
 antora/components/tutorials/antora.yml             |  25 +
 antora/components/tutorials/modules/ROOT/nav.adoc  |   3 +
 .../tutorials/modules/ROOT/pages/about.adoc        |  11 +
 .../petclinic/images/03-03/refactor-updateName.png | Bin 0 -> 41437 bytes
 .../modules/petclinic/images/03-10/fieldsets.png   | Bin 0 -> 27298 bytes
 .../petclinic/images/04-07/download-layout-xml.png | Bin 0 -> 17163 bytes
 .../images/Dashboard-overdue-ui-hints.png          | Bin 0 -> 42774 bytes
 .../modules/petclinic/images/Dashboard-overdue.png | Bin 0 -> 49707 bytes
 .../modules/petclinic/images/HelloWorldObject.png  | Bin 0 -> 44720 bytes
 .../modules/petclinic/images/HelloWorldObjects.png | Bin 0 -> 20802 bytes
 .../petclinic/images/Owner-name-updated.png        | Bin 0 -> 5008 bytes
 .../modules/petclinic/images/Owner-name.png        | Bin 0 -> 3766 bytes
 .../modules/petclinic/images/Owner-pets.png        | Bin 0 -> 25561 bytes
 .../petclinic/images/Owner-updateName-prompt.png   | Bin 0 -> 6542 bytes
 .../images/Owner-with-contact-details.png          | Bin 0 -> 8719 bytes
 .../images/Owners-create-with-phoneNumber.png      | Bin 0 -> 15426 bytes
 .../images/Pet-bookVisit-prompt-with-default.png   | Bin 0 -> 45252 bytes
 .../images/Pet-bookVisit-prompt-with-validate.png  | Bin 0 -> 39819 bytes
 .../petclinic/images/Pet-bookVisit-prompt.png      | Bin 0 -> 37767 bytes
 .../modules/petclinic/images/Pet-icons.png         | Bin 0 -> 31150 bytes
 .../petclinic/images/Pet-visits-collection.png     | Bin 0 -> 34249 bytes
 .../tutorials/modules/petclinic/images/Pet.png     | Bin 0 -> 25482 bytes
 .../petclinic/images/Visit-enterOutcome.png        | Bin 0 -> 39463 bytes
 .../tutorials/modules/petclinic/images/Visit.png   | Bin 0 -> 26923 bytes
 .../petclinic/images/Visits-paid-strikethrough.png | Bin 0 -> 52925 bytes
 .../modules/petclinic/images/dashboard.png         | Bin 0 -> 19084 bytes
 .../images/extended-manifest-run-configuration.png | Bin 0 -> 47592 bytes
 .../tutorials/modules/petclinic/images/hand.png    | Bin 0 -> 326 bytes
 .../petclinic/images/hello-world-objects-menu.png  | Bin 0 -> 7578 bytes
 .../modules/petclinic/images/home-page.png         | Bin 0 -> 43943 bytes
 .../tutorials/modules/petclinic/images/index.png   | Bin 0 -> 73486 bytes
 .../images/intellij-java-compiler-parameters.png   | Bin 0 -> 52596 bytes
 ...iguration-before-launch-datanucleus-enhance.png | Bin 0 -> 7541 bytes
 .../intellij-run-configuration-before-launch.png   | Bin 0 -> 7276 bytes
 .../images/intellij-run-configuration.png          | Bin 0 -> 46429 bytes
 .../tutorials/modules/petclinic/images/login.png   | Bin 0 -> 19956 bytes
 .../modules/petclinic/images/object-a.png          | Bin 0 -> 25310 bytes
 .../petclinic/images/owner-newPet-prompt.png       | Bin 0 -> 7634 bytes
 .../modules/petclinic/images/owner-newPet.png      | Bin 0 -> 9643 bytes
 .../images/project-loaded-into-intellij.png        | Bin 0 -> 31799 bytes
 .../modules/petclinic/images/prototyping-menu.png  | Bin 0 -> 14260 bytes
 .../images/run-fixture-script-menu-item.png        | Bin 0 -> 15882 bytes
 .../petclinic/images/run-fixture-script-prompt.png | Bin 0 -> 10836 bytes
 .../petclinic/images/run-fixture-script-result.png | Bin 0 -> 16521 bytes
 .../modules/petclinic/images/swagger-ui.png        | Bin 0 -> 59554 bytes
 .../modules/petclinic/images/tertiary-menu.png     | Bin 0 -> 4687 bytes
 .../tutorials/modules/petclinic/nav.adoc           |  77 ++
 .../petclinic/pages/010-getting-started.adoc       | 231 +++++
 .../petclinic/pages/020-the-petclinic-domain.adoc  |  64 ++
 .../petclinic/pages/030-petowner-entity.adoc       | 954 ++++++++++++++++++++
 .../modules/petclinic/pages/040-pet-entity.adoc    | 989 +++++++++++++++++++++
 .../modules/petclinic/pages/050-visit-entity.adoc  | 317 +++++++
 .../modules/petclinic/pages/060-unit-testing.adoc  | 112 +++
 .../modules/petclinic/pages/070-modularity.adoc    | 158 ++++
 .../modules/petclinic/pages/080-view-models.adoc   | 297 +++++++
 .../petclinic/pages/090-integration-testing.adoc   | 206 +++++
 ...ing-further-business-logic-worked-examples.adoc | 707 +++++++++++++++
 .../tutorials/modules/petclinic/pages/about.adoc   |   9 +
 .../petclinic/pages/architecture-rules.adoc        |   6 +
 .../tutorials/modules/petclinic/pages/i18n.adoc    |   6 +
 .../modules/petclinic/partials/domain.adoc         |  60 ++
 .../modules/petclinic/partials/skinparam.adoc      |  11 +
 .../hints-and-tips/transactions-and-errors.adoc    |   2 +-
 .../modules/fun/pages/ui/object-layout.adoc        |   1 +
 antora/playbooks/site-tutorials.yml                |  86 ++
 antora/playbooks/site.yml                          |   3 +
 .../apache/isis/core/config/IsisConfiguration.java |   2 +-
 .../adoc/modules/starters/pages/helloworld.adoc    |   4 +-
 .../adoc/modules/starters/pages/simpleapp.adoc     |   9 +-
 71 files changed, 4344 insertions(+), 10 deletions(-)

diff --git a/.run/SimpleApp.run.xml b/.run/SimpleApp.run.xml
index e9d41a7..0a4dea2 100644
--- a/.run/SimpleApp.run.xml
+++ b/.run/SimpleApp.run.xml
@@ -1,6 +1,6 @@
 <component name="ProjectRunConfigurationManager">
   <configuration default="false" name="SimpleApp (JPA)" type="Application" factoryName="Application">
-    <option name="MAIN_CLASS_NAME" value="domainapp.webapp.SimpleApp" />
+    <option name="MAIN_CLASS_NAME" value="domainapp.webapp.PetClinicApp" />
     <module name="simpleapp-jpa-webapp" />
     <shortenClasspath name="ARGS_FILE" />
     <extension name="coverage">
diff --git a/antora/components/setupguide/modules/intellij/partials/UNUSED/faster-turnaround-times.adoc b/antora/components/setupguide/modules/intellij/partials/UNUSED/faster-turnaround-times.adoc
index 665d718..a8988b9 100644
--- a/antora/components/setupguide/modules/intellij/partials/UNUSED/faster-turnaround-times.adoc
+++ b/antora/components/setupguide/modules/intellij/partials/UNUSED/faster-turnaround-times.adoc
@@ -74,7 +74,7 @@ Similarly, gradle can be run to reduce the turn-around time when tweaking the UI
 xref:userguide:fun:ui.adoc#object-layout[*.layout.xml] file for each domain class), when the app is running.
 
 The framework will automatically notice any changes to `.layout.xml` files, but these are read from the classpath (the `target/classes` directory), not the source path.
-With IntelliJ these can be copied over manually by invoking `Run > Reload Changed Classes`.
+With IntelliJ these can be copied over manually by invoking menu:Run[Debugging Actions > Reload Changed Classes].
 Once the browser is refreshed, the new layout will be rendered.
 
 [NOTE]
diff --git a/antora/components/tutorials/antora.yml b/antora/components/tutorials/antora.yml
new file mode 100644
index 0000000..0352027
--- /dev/null
+++ b/antora/components/tutorials/antora.yml
@@ -0,0 +1,25 @@
+#  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.
+
+name: tutorials
+title: "Tutorials"
+version: latest
+start_page: ROOT:about.adoc
+nav:
+  - modules/ROOT/nav.adoc
+  - modules/petclinic/nav.adoc
+
diff --git a/antora/components/tutorials/modules/ROOT/nav.adoc b/antora/components/tutorials/modules/ROOT/nav.adoc
new file mode 100644
index 0000000..9e0d1b7
--- /dev/null
+++ b/antora/components/tutorials/modules/ROOT/nav.adoc
@@ -0,0 +1,3 @@
+
+: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 [...]
+
diff --git a/antora/components/tutorials/modules/ROOT/pages/about.adoc b/antora/components/tutorials/modules/ROOT/pages/about.adoc
new file mode 100644
index 0000000..6605d17
--- /dev/null
+++ b/antora/components/tutorials/modules/ROOT/pages/about.adoc
@@ -0,0 +1,11 @@
+= Tutorials
+:page-role: -toc
+
+: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 [...]
+
+
+This page catalogues the tutorials available to help you learn Apache Isis.
+
+* xref:petclinic:about.adoc[Petclinic]
++
+A take on the classic petclinic application
diff --git a/antora/components/tutorials/modules/petclinic/images/03-03/refactor-updateName.png b/antora/components/tutorials/modules/petclinic/images/03-03/refactor-updateName.png
new file mode 100644
index 0000000..fcbf5fb
Binary files /dev/null and b/antora/components/tutorials/modules/petclinic/images/03-03/refactor-updateName.png differ
diff --git a/antora/components/tutorials/modules/petclinic/images/03-10/fieldsets.png b/antora/components/tutorials/modules/petclinic/images/03-10/fieldsets.png
new file mode 100644
index 0000000..d520e3c
Binary files /dev/null and b/antora/components/tutorials/modules/petclinic/images/03-10/fieldsets.png differ
diff --git a/antora/components/tutorials/modules/petclinic/images/04-07/download-layout-xml.png b/antora/components/tutorials/modules/petclinic/images/04-07/download-layout-xml.png
new file mode 100644
index 0000000..dc052b3
Binary files /dev/null and b/antora/components/tutorials/modules/petclinic/images/04-07/download-layout-xml.png differ
diff --git a/antora/components/tutorials/modules/petclinic/images/Dashboard-overdue-ui-hints.png b/antora/components/tutorials/modules/petclinic/images/Dashboard-overdue-ui-hints.png
new file mode 100644
index 0000000..1887eb6
Binary files /dev/null and b/antora/components/tutorials/modules/petclinic/images/Dashboard-overdue-ui-hints.png differ
diff --git a/antora/components/tutorials/modules/petclinic/images/Dashboard-overdue.png b/antora/components/tutorials/modules/petclinic/images/Dashboard-overdue.png
new file mode 100644
index 0000000..fe5e320
Binary files /dev/null and b/antora/components/tutorials/modules/petclinic/images/Dashboard-overdue.png differ
diff --git a/antora/components/tutorials/modules/petclinic/images/HelloWorldObject.png b/antora/components/tutorials/modules/petclinic/images/HelloWorldObject.png
new file mode 100644
index 0000000..633e72f
Binary files /dev/null and b/antora/components/tutorials/modules/petclinic/images/HelloWorldObject.png differ
diff --git a/antora/components/tutorials/modules/petclinic/images/HelloWorldObjects.png b/antora/components/tutorials/modules/petclinic/images/HelloWorldObjects.png
new file mode 100644
index 0000000..834a2e8
Binary files /dev/null and b/antora/components/tutorials/modules/petclinic/images/HelloWorldObjects.png differ
diff --git a/antora/components/tutorials/modules/petclinic/images/Owner-name-updated.png b/antora/components/tutorials/modules/petclinic/images/Owner-name-updated.png
new file mode 100644
index 0000000..6ed762c
Binary files /dev/null and b/antora/components/tutorials/modules/petclinic/images/Owner-name-updated.png differ
diff --git a/antora/components/tutorials/modules/petclinic/images/Owner-name.png b/antora/components/tutorials/modules/petclinic/images/Owner-name.png
new file mode 100644
index 0000000..4c1f283
Binary files /dev/null and b/antora/components/tutorials/modules/petclinic/images/Owner-name.png differ
diff --git a/antora/components/tutorials/modules/petclinic/images/Owner-pets.png b/antora/components/tutorials/modules/petclinic/images/Owner-pets.png
new file mode 100644
index 0000000..652e7d8
Binary files /dev/null and b/antora/components/tutorials/modules/petclinic/images/Owner-pets.png differ
diff --git a/antora/components/tutorials/modules/petclinic/images/Owner-updateName-prompt.png b/antora/components/tutorials/modules/petclinic/images/Owner-updateName-prompt.png
new file mode 100644
index 0000000..d8dcdbf
Binary files /dev/null and b/antora/components/tutorials/modules/petclinic/images/Owner-updateName-prompt.png differ
diff --git a/antora/components/tutorials/modules/petclinic/images/Owner-with-contact-details.png b/antora/components/tutorials/modules/petclinic/images/Owner-with-contact-details.png
new file mode 100644
index 0000000..29267b5
Binary files /dev/null and b/antora/components/tutorials/modules/petclinic/images/Owner-with-contact-details.png differ
diff --git a/antora/components/tutorials/modules/petclinic/images/Owners-create-with-phoneNumber.png b/antora/components/tutorials/modules/petclinic/images/Owners-create-with-phoneNumber.png
new file mode 100644
index 0000000..1273e3e
Binary files /dev/null and b/antora/components/tutorials/modules/petclinic/images/Owners-create-with-phoneNumber.png differ
diff --git a/antora/components/tutorials/modules/petclinic/images/Pet-bookVisit-prompt-with-default.png b/antora/components/tutorials/modules/petclinic/images/Pet-bookVisit-prompt-with-default.png
new file mode 100644
index 0000000..c6176f3
Binary files /dev/null and b/antora/components/tutorials/modules/petclinic/images/Pet-bookVisit-prompt-with-default.png differ
diff --git a/antora/components/tutorials/modules/petclinic/images/Pet-bookVisit-prompt-with-validate.png b/antora/components/tutorials/modules/petclinic/images/Pet-bookVisit-prompt-with-validate.png
new file mode 100644
index 0000000..985b05a
Binary files /dev/null and b/antora/components/tutorials/modules/petclinic/images/Pet-bookVisit-prompt-with-validate.png differ
diff --git a/antora/components/tutorials/modules/petclinic/images/Pet-bookVisit-prompt.png b/antora/components/tutorials/modules/petclinic/images/Pet-bookVisit-prompt.png
new file mode 100644
index 0000000..8726a88
Binary files /dev/null and b/antora/components/tutorials/modules/petclinic/images/Pet-bookVisit-prompt.png differ
diff --git a/antora/components/tutorials/modules/petclinic/images/Pet-icons.png b/antora/components/tutorials/modules/petclinic/images/Pet-icons.png
new file mode 100644
index 0000000..8969d8f
Binary files /dev/null and b/antora/components/tutorials/modules/petclinic/images/Pet-icons.png differ
diff --git a/antora/components/tutorials/modules/petclinic/images/Pet-visits-collection.png b/antora/components/tutorials/modules/petclinic/images/Pet-visits-collection.png
new file mode 100644
index 0000000..4d06e5c
Binary files /dev/null and b/antora/components/tutorials/modules/petclinic/images/Pet-visits-collection.png differ
diff --git a/antora/components/tutorials/modules/petclinic/images/Pet.png b/antora/components/tutorials/modules/petclinic/images/Pet.png
new file mode 100644
index 0000000..ffd8d7a
Binary files /dev/null and b/antora/components/tutorials/modules/petclinic/images/Pet.png differ
diff --git a/antora/components/tutorials/modules/petclinic/images/Visit-enterOutcome.png b/antora/components/tutorials/modules/petclinic/images/Visit-enterOutcome.png
new file mode 100644
index 0000000..7b913d6
Binary files /dev/null and b/antora/components/tutorials/modules/petclinic/images/Visit-enterOutcome.png differ
diff --git a/antora/components/tutorials/modules/petclinic/images/Visit.png b/antora/components/tutorials/modules/petclinic/images/Visit.png
new file mode 100644
index 0000000..8aedd6f
Binary files /dev/null and b/antora/components/tutorials/modules/petclinic/images/Visit.png differ
diff --git a/antora/components/tutorials/modules/petclinic/images/Visits-paid-strikethrough.png b/antora/components/tutorials/modules/petclinic/images/Visits-paid-strikethrough.png
new file mode 100644
index 0000000..0546da0
Binary files /dev/null and b/antora/components/tutorials/modules/petclinic/images/Visits-paid-strikethrough.png differ
diff --git a/antora/components/tutorials/modules/petclinic/images/dashboard.png b/antora/components/tutorials/modules/petclinic/images/dashboard.png
new file mode 100644
index 0000000..a13bf5a
Binary files /dev/null and b/antora/components/tutorials/modules/petclinic/images/dashboard.png differ
diff --git a/antora/components/tutorials/modules/petclinic/images/extended-manifest-run-configuration.png b/antora/components/tutorials/modules/petclinic/images/extended-manifest-run-configuration.png
new file mode 100644
index 0000000..b580d75
Binary files /dev/null and b/antora/components/tutorials/modules/petclinic/images/extended-manifest-run-configuration.png differ
diff --git a/antora/components/tutorials/modules/petclinic/images/hand.png b/antora/components/tutorials/modules/petclinic/images/hand.png
new file mode 100644
index 0000000..97cd875
Binary files /dev/null and b/antora/components/tutorials/modules/petclinic/images/hand.png differ
diff --git a/antora/components/tutorials/modules/petclinic/images/hello-world-objects-menu.png b/antora/components/tutorials/modules/petclinic/images/hello-world-objects-menu.png
new file mode 100644
index 0000000..c9a45f4
Binary files /dev/null and b/antora/components/tutorials/modules/petclinic/images/hello-world-objects-menu.png differ
diff --git a/antora/components/tutorials/modules/petclinic/images/home-page.png b/antora/components/tutorials/modules/petclinic/images/home-page.png
new file mode 100644
index 0000000..5763ab7
Binary files /dev/null and b/antora/components/tutorials/modules/petclinic/images/home-page.png differ
diff --git a/antora/components/tutorials/modules/petclinic/images/index.png b/antora/components/tutorials/modules/petclinic/images/index.png
new file mode 100644
index 0000000..a84a0a5
Binary files /dev/null and b/antora/components/tutorials/modules/petclinic/images/index.png differ
diff --git a/antora/components/tutorials/modules/petclinic/images/intellij-java-compiler-parameters.png b/antora/components/tutorials/modules/petclinic/images/intellij-java-compiler-parameters.png
new file mode 100644
index 0000000..9a2d43e
Binary files /dev/null and b/antora/components/tutorials/modules/petclinic/images/intellij-java-compiler-parameters.png differ
diff --git a/antora/components/tutorials/modules/petclinic/images/intellij-run-configuration-before-launch-datanucleus-enhance.png b/antora/components/tutorials/modules/petclinic/images/intellij-run-configuration-before-launch-datanucleus-enhance.png
new file mode 100644
index 0000000..548d2a9
Binary files /dev/null and b/antora/components/tutorials/modules/petclinic/images/intellij-run-configuration-before-launch-datanucleus-enhance.png differ
diff --git a/antora/components/tutorials/modules/petclinic/images/intellij-run-configuration-before-launch.png b/antora/components/tutorials/modules/petclinic/images/intellij-run-configuration-before-launch.png
new file mode 100644
index 0000000..8cd9f74
Binary files /dev/null and b/antora/components/tutorials/modules/petclinic/images/intellij-run-configuration-before-launch.png differ
diff --git a/antora/components/tutorials/modules/petclinic/images/intellij-run-configuration.png b/antora/components/tutorials/modules/petclinic/images/intellij-run-configuration.png
new file mode 100644
index 0000000..066e994
Binary files /dev/null and b/antora/components/tutorials/modules/petclinic/images/intellij-run-configuration.png differ
diff --git a/antora/components/tutorials/modules/petclinic/images/login.png b/antora/components/tutorials/modules/petclinic/images/login.png
new file mode 100644
index 0000000..a2d3618
Binary files /dev/null and b/antora/components/tutorials/modules/petclinic/images/login.png differ
diff --git a/antora/components/tutorials/modules/petclinic/images/object-a.png b/antora/components/tutorials/modules/petclinic/images/object-a.png
new file mode 100644
index 0000000..23f65de
Binary files /dev/null and b/antora/components/tutorials/modules/petclinic/images/object-a.png differ
diff --git a/antora/components/tutorials/modules/petclinic/images/owner-newPet-prompt.png b/antora/components/tutorials/modules/petclinic/images/owner-newPet-prompt.png
new file mode 100644
index 0000000..8e1b179
Binary files /dev/null and b/antora/components/tutorials/modules/petclinic/images/owner-newPet-prompt.png differ
diff --git a/antora/components/tutorials/modules/petclinic/images/owner-newPet.png b/antora/components/tutorials/modules/petclinic/images/owner-newPet.png
new file mode 100644
index 0000000..a3b459c
Binary files /dev/null and b/antora/components/tutorials/modules/petclinic/images/owner-newPet.png differ
diff --git a/antora/components/tutorials/modules/petclinic/images/project-loaded-into-intellij.png b/antora/components/tutorials/modules/petclinic/images/project-loaded-into-intellij.png
new file mode 100644
index 0000000..a00b632
Binary files /dev/null and b/antora/components/tutorials/modules/petclinic/images/project-loaded-into-intellij.png differ
diff --git a/antora/components/tutorials/modules/petclinic/images/prototyping-menu.png b/antora/components/tutorials/modules/petclinic/images/prototyping-menu.png
new file mode 100644
index 0000000..a903a93
Binary files /dev/null and b/antora/components/tutorials/modules/petclinic/images/prototyping-menu.png differ
diff --git a/antora/components/tutorials/modules/petclinic/images/run-fixture-script-menu-item.png b/antora/components/tutorials/modules/petclinic/images/run-fixture-script-menu-item.png
new file mode 100644
index 0000000..5cb55e9
Binary files /dev/null and b/antora/components/tutorials/modules/petclinic/images/run-fixture-script-menu-item.png differ
diff --git a/antora/components/tutorials/modules/petclinic/images/run-fixture-script-prompt.png b/antora/components/tutorials/modules/petclinic/images/run-fixture-script-prompt.png
new file mode 100644
index 0000000..024e200
Binary files /dev/null and b/antora/components/tutorials/modules/petclinic/images/run-fixture-script-prompt.png differ
diff --git a/antora/components/tutorials/modules/petclinic/images/run-fixture-script-result.png b/antora/components/tutorials/modules/petclinic/images/run-fixture-script-result.png
new file mode 100644
index 0000000..4ad7117
Binary files /dev/null and b/antora/components/tutorials/modules/petclinic/images/run-fixture-script-result.png differ
diff --git a/antora/components/tutorials/modules/petclinic/images/swagger-ui.png b/antora/components/tutorials/modules/petclinic/images/swagger-ui.png
new file mode 100644
index 0000000..897d2e0
Binary files /dev/null and b/antora/components/tutorials/modules/petclinic/images/swagger-ui.png differ
diff --git a/antora/components/tutorials/modules/petclinic/images/tertiary-menu.png b/antora/components/tutorials/modules/petclinic/images/tertiary-menu.png
new file mode 100644
index 0000000..4f6d2ca
Binary files /dev/null and b/antora/components/tutorials/modules/petclinic/images/tertiary-menu.png differ
diff --git a/antora/components/tutorials/modules/petclinic/nav.adoc b/antora/components/tutorials/modules/petclinic/nav.adoc
new file mode 100644
index 0000000..c027dee
--- /dev/null
+++ b/antora/components/tutorials/modules/petclinic/nav.adoc
@@ -0,0 +1,77 @@
+
+* xref:about.adoc[Introduction]
+
+* xref:010-getting-started.adoc[Getting Started]
+** xref:010-getting-started.adoc#prereqs[Prereqs]
+** xref:010-getting-started.adoc#exercise-1-1-starter-apps-clone-the-repo[image:hand.png[] *1.1*: Starter apps / clone the repo]
+** xref:010-getting-started.adoc#exercise-1-2-explore-the-simple-app[1.2: Explore the Simple App]
+** xref:010-getting-started.adoc#exercise-1-3-running-from-the-ide[1.3: Running from the IDE]
+** xref:010-getting-started.adoc#exercise-1-4-naked-objects-pattern[1.4: Naked Objects pattern]
+** xref:010-getting-started.adoc#exercise-1-5-ui-hints[1.5: UI Hints]
+
+* xref:020-the-petclinic-domain.adoc[The PetClinic Domain]
+** xref:020-the-petclinic-domain.adoc#exercise-2-1-refactor-simpleobject-to-petowner[image:hand.png[] *2.1*: Refactor SimpleObject to PetOwner]
+
+* xref:030-petowner-entity.adoc[PetOwner entity]
+** xref:030-petowner-entity.adoc#exercise-3-1-rename-petowners-name-property[image:hand.png[] *3.1*: Rename PetOwner's name property]
+** xref:030-petowner-entity.adoc#exercise-3-2-add-petowners-firstname-property[image:hand.png[] *3.2*: Add PetOwner's firstName property]
+** xref:030-petowner-entity.adoc#exercise-3-3-modify-petowners-updatename-action[image:hand.png[] *3.3*: Modify PetOwner's updateName action]
+** xref:030-petowner-entity.adoc#exercise-3-4-modify-the-menu-action-to-create-petowners[image:hand.png[] *3.4*: Modify the menu action to create PetOwners]
+** xref:030-petowner-entity.adoc#exercise-3-5-initial-fixture-script[image:hand.png[] *3.5*: Initial Fixture Script]
+** xref:030-petowner-entity.adoc#exercise-3-6-prompt-styles[image:hand.png[] *3.6*: Prompt styles]
+** xref:030-petowner-entity.adoc#exercise-3-7-derived-name-property[image:hand.png[] *3.7*: Derived name property]
+** xref:030-petowner-entity.adoc#exercise-3-8-add-other-properties-for-petowner[image:hand.png[] *3.8*: Add other properties for PetOwner]
+** xref:030-petowner-entity.adoc#exercise-3-9-validation[image:hand.png[] *3.9*: Validation]
+** xref:030-petowner-entity.adoc#exercise-3-10-field-layout[image:hand.png[] *3.10*: Field layout]
+** xref:030-petowner-entity.adoc#exercise-3-11-column-orders[image:hand.png[] *3.11*: Column Orders]
+
+
+* xref:040-pet-entity.adoc[Pet entity]
+** xref:040-pet-entity.adoc#exercise-4-1-pet-entitys-key-properties[image:hand.png[] *4.1*: Pet entity's key properties]
+** xref:040-pet-entity.adoc#exercise-4-2-add-petrepository[image:hand.png[] *4.2*: Add PetRepository]
+** xref:040-pet-entity.adoc#exercise-4-3-add-petowners-collection-of-pets[image:hand.png[] *4.3*: Add PetOwner's collection of Pets]
+** xref:040-pet-entity.adoc#exercise-4-4-add-pets-remaining-properties[image:hand.png[] *4.4*: Add Pet's remaining properties]
+** xref:040-pet-entity.adoc#exercise-4-5-digression-clean-up-casing-of-database-schema[image:hand.png[] *4.5*: Digression: clean-up casing of database schema]
+** xref:040-pet-entity.adoc#exercise-4-6-add-petowner-action-to-add-pets[image:hand.png[] *4.6*: Add PetOwner action to add Pets]
+** xref:040-pet-entity.adoc#exercise-4-7-add-pets-ui-customisation[image:hand.png[] *4.7*: Add Pet's UI customisation]
+** xref:040-pet-entity.adoc#exercise-4-8-update-fixture-script-using-pet-personas[image:hand.png[] *4.8*: Update fixture script using Pet personas]
+** xref:040-pet-entity.adoc#exercise-4-9-add-petowner-action-to-delete-a-pet[image:hand.png[] *4.9*: Add PetOwner action to delete a Pet]
+** xref:040-pet-entity.adoc#exercise-4-10-cleanup[image:hand.png[] *4.10*: Cleanup]
+
+* xref:050-visit-entity.adoc[Visit module and entity]
+** xref:050-visit-entity.adoc#exercise-5-1-the-visits-module[image:hand.png[] *5.1*: The visits module]
+** xref:050-visit-entity.adoc#exercise-5-2-visit-entitys-key-properties[image:hand.png[] *5.2*: Visit entity's key properties]
+** xref:050-visit-entity.adoc#exercise-5-3-book-visit-action[image:hand.png[] *5.3*: "Book Visit" action]
+
+
+* xref:060-unit-testing.adoc[Unit Testing]
+** xref:060-unit-testing.adoc#exercise-6-1-unit-test-the-default-time-when-booking-visits[image:hand.png[] *6.1*: Unit test the default time when booking visits]
+
+* xref:070-modularity.adoc[Modularity (domain events)]
+** xref:070-modularity.adoc#exercise-7-1-refactor-petowners-delete-action[image:hand.png[] *7.1*: refactor PetOwner's delete action]
+
+
+* xref:080-view-models.adoc[View Models]
+** xref:080-view-models.adoc#exercise-8-1-extend-the-home-page[image:hand.png[] *8.1*: Extend the Home Page.]
+** xref:080-view-models.adoc#exercise-8-2-add-a-convenience-action[image:hand.png[] *8.2*: Add a convenience action]
+** xref:080-view-models.adoc#exercise-8-3-using-a-view-model-as-a-projection-of-an-entity[image:hand.png[] *8.3*: Using a view model as a projection of an entity]
+
+
+* xref:090-integration-testing.adoc[(Integration) Testing]
+** xref:090-integration-testing.adoc#_an_improved_fixture_script[image:hand.png[] *270*: An improved Fixture Script]
+** xref:090-integration-testing.adoc#_writing_integration_tests[image:hand.png[] *280*: Writing Integration Tests]
+** xref:090-integration-testing.adoc#_factor_out_abstract_integration_test[image:hand.png[] *290*: Factor out abstract integration test]
+** xref:090-integration-testing.adoc#_move_teardowns_to_modules[image:hand.png[] *300*: Move teardowns to modules]
+** xref:090-integration-testing.adoc#_fake_data_service[image:hand.png[] *310*: Fake Data Service]
+** xref:090-integration-testing.adoc#_extend_the_fixture_script_to_set_up_visits[image:hand.png[] *320*: Extend the Fixture script to set up visits]
+
+* xref:110-adding-further-business-logic-worked-examples.adoc[Further business logic]
+** xref:110-adding-further-business-logic-worked-examples.adoc#_enter_an_outcome[image:hand.png[] *330*: Enter an outcome]
+** xref:110-adding-further-business-logic-worked-examples.adoc#_pay_for_a_visit[image:hand.png[] *340*: Pay for a visit]
+** xref:110-adding-further-business-logic-worked-examples.adoc#_prevent_payment_for_a_visit_twice[image:hand.png[] *350*: Prevent payment for a visit twice ("see it? use it? do it?")]
+** xref:110-adding-further-business-logic-worked-examples.adoc#_find_code_visit_code_s_not_yet_paid_and_overdue[image:hand.png[] *360*: Find Visits not yet paid and overdue]
+** xref:110-adding-further-business-logic-worked-examples.adoc#_digression_hiding_columns_in_tables[image:hand.png[] *370*: Digression: Hiding Columns in Tables]
+** xref:110-adding-further-business-logic-worked-examples.adoc#_another_digression_icons_and_css[image:hand.png[] *380*: Another Digression: Icons and CSS]
+** xref:110-adding-further-business-logic-worked-examples.adoc#_delete_an_code_owner_code_provided_no_unpaid_code_visit_code_s[image:hand.png[] *390*: Delete an Owner provided no unpaid Visits]
+
+//* xref:i18n.adoc[i18n]
diff --git a/antora/components/tutorials/modules/petclinic/pages/010-getting-started.adoc b/antora/components/tutorials/modules/petclinic/pages/010-getting-started.adoc
new file mode 100644
index 0000000..b84deea
--- /dev/null
+++ b/antora/components/tutorials/modules/petclinic/pages/010-getting-started.adoc
@@ -0,0 +1,231 @@
+= Getting Started
+
+: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 [...]
+
+
+[#prereqs]
+== Prereqs
+
+You'll need:
+
+* Java 11
++
+Apache Isis should work up to at least Java 15, but stick with Java 11 for now.
+
+* Maven 3.6.x
++
+Maven 3.6.x or later is recommended.
+
+* git
++
+The tutorial includes worked example/solution, provided in a github-hosted repo.
+This has multiple tags for the various checkpoints so you can pick up the tutorial at any point.
+
+* a Java IDE with support for Maven.
++
+The Apache Isis website has detailed documentation for setting up to use xref:setupguide:intellij:about.adoc[IntelliJ] or xref:setupguide:eclipse:about.adoc[Eclipse].
++
+For this tutorial, make sure that your IDE is configured to support Lombok.
+
+
+
+[#exercise-1-1-starter-apps-clone-the-repo]
+== Exercise 1.1: Starter apps / clone the repo
+
+Apache Isis provides a two starter apps, xref:docs:starters:helloworld.adoc[HelloWorld] and xref:docs:starters:simpleapp.adoc[SimpleApp].
+These are identical in terms of functionality, but the simpleapp provides more structure and includes example tests.
+
+NOTE: there are JPA and JDO variants of the starter apps.
+This tutorial uses JPA as it is the more commonly used persistence mechanism.
+
+We start the tutorial simply with a copy of xref:docs:starters:simpleapp.adoc[SimpleApp]; in subsequent exercises we'll refactor and build upon it to morph it into the petclinic domain.
+
+
+* Clone the repo:
++
+[source,bash]
+----
+git clone https://github.com/apache/isis-app-demo
+----
+
+* (optional) Load the git repo into a GUI tool such as SourceTree or GitKraken
++
+This will make it easier to inspect differences between different tags.
+
+* Checkout the first tag, and build:
++
+[source,bash]
+----
+git checkout tags/01-01-starter-app
+mvn clean install
+----
+
+* run the app:
++
+[source,bash]
+----
+mvn -pl webapp spring-boot:run
+----
+
+
+
+
+[#exercise-1-2-explore-the-simple-app]
+== Exercise 1.2: Explore the Simple App
+
+Although we'll be refactoring the codebase in the next exercise, take a few minutes to familiarize yourself with the functionality of the simpleapp.
+
+Check your understanding by using the app to:
+
+* create new objects
+* search by name
+* list all objects
+
+* Use the menu:Prototyping[Fixture Scripts] menu to run in the "DomainAppDemo" fixture script.
++
+This will create some sample data.
+
+
+[#exercise-1-3-running-from-the-ide]
+== Exercise 1.3: Running from the IDE
+
+Running from the command line isn't ideal, so
+
+* load the project into your IDE as a Maven project, build and run.
+
+* The app is a Spring boot application, so locate the class with a main, and run.
+
+* alternatively, your IDE might also have specialised support for Spring Boot apps, so run the app that way if you wish.
+
+* with the IDE load
+
+If you want to go deeper, open up the xref:docs:starters:simpleapp.adoc[page describing the SimpleApp] and start to explore the xref:docs:starters:simpleapp.adoc#structure-of-the-app[structure of the app] files.
+
+
+
+[#exercise-1-4-naked-objects-pattern]
+== Exercise 1.4: Naked Objects pattern
+
+Apache Isis is an implementation of the _naked objects pattern_, which means that entities (and later, as we'll see view models) are automatically exposed in the UI.
+
+[TIP]
+====
+An ORM such as JPA (EclipseLink or Hibernate) maps domain objects into an RDBMS or other datastore.
+You can think of Apache Isis (and naked objects) similarly, but it's an OIM - an object _interface_ mapper.
+It maps to the UI layer rather than the persistence layer.
+
+Common to both ORMs and OIMs is an internal metamodel; this is where much of the power comes from.
+====
+
+We can explore this by looking at the classes provided by the starter app:
+
+* locate the `SimpleObjects` domain service, and notice the methods annotated with `@Action`.
++
+Map these to the "Simple Objects" menu.
+
+
+* locate the `SimpleObject` entity, and notice the methods annotated with `@Property` and `@Action`.
++
+Map these onto the fields of the "simple object" entity, and the action buttons (eg to "change name").
+
+
+It's common for each entity (or more precisely, aggregate root) to have a corresponding domain service, acting as its repository.
+This abstraction hides the details of interacting with the persistence data store.
+Domain services are automatically injected wherever they are required, using `@javax.inject.Inject`.
+
+Apache Isis applications therefore generally follow the _hexagonal architecture_ (aka the _ports and adapters_ architecture).
+
+As well as writing our own domain services, there are also many framework-provided domain services, for example `RepositoryService` (to persist objects).
+See the xref:refguide:applib-svc:about.adoc[Reference Guide: Domain Services] docs for the full list.
+
+
+[#exercise-1-5-ui-hints]
+== Exercise 1.5: UI Hints
+
+The framework derives as much of the UI as possible from the domain objects' intrinsic structure and behaviour, but there are some supporting structures and conventions that are there primarily to improve the UI.
+
+
+=== Titles
+
+A title is the identifier of a domain object for the end-user.
+
+For `SimpleObject`, this is defined declaratively:
+
+[source,java]
+.SimpleObject.java
+----
+@Title
+// ... other annotations omitted ...
+private String name;
+----
+
+It can also be specified imperatively using either the `title()` or `toString()` method.
+
+Each domain object is also associated with an icon.
+Typically this is static and in the same package as the class; see `SimpleObject.png`.
+
+
+*Mini-Exercise*:
+
+(No solution is provided for this exercise).
+
+* replace the `@Title` annotation with a `title()` method:
++
+[source,java]
+.SimpleObject.java
+----
+public String title() {
+    return getName();
+}
+----
+
+You can learn more about UI Hint Methods in the reference guide, xref:refguide:applib-methods:ui-hints.adoc[here].
+
+
+
+=== Object layout
+
+Frameworks that implement the _naked objects pattern_ automatically provide a default representation of domain objects.
+In many cases the details of that representation can be inferred directly from the domain members.
+For example the label of a field for an object's property (eg `SimpleObject#name`) can be derived directly from the name of the object property itself (`getName()`).
+
+In the absence of other metadata, Apache Isis will render a domain object with its properties to the left-hand side and its collections (if any) to the right.
+The order of these properties and collections can be specified using the `@PropertyLayout` annotation and the `@CollectionLayout` annotation.
+There are other annotations to group properties together and to associate action buttons with either properties or collections.
+
+The downside of using annotations is that changing the layout requires that the application be restarted, and certain more complex UIs, such as multi-columns or tab groups are difficult or impossible to express.
+
+Therefore Apache Isis also allows the layout of domain objects to be specified using a complementary layout file, eg `SimpleObject.layout.xml`.
+This is modelled upon bootstrap and so supports arbitrary rows and columns as well as tab groups and tabs.
+
+*Mini-Exercise*:
+
+* locate the `SimpleObject.layout.xml` file
+* compare the structure of the layout file to that of the rendered object
+* change the file, eg the relative widths of the columns
+* use the IDE to copy over the file to the classpath; the new version will be picked up automatically
+** for example, with IntelliJ use menu:Run[Debugging Actions > Reload Changed Classes].
+
+
+You can learn more about file-based layouts in the fundamentals guide describing at xref:userguide:fun:ui.adoc#object-layout[Object Layout]s.
+
+It's also possible to change the order of columns at runtime, using the `SimpleObject.columnOrder.txt` file.
+For more on this topic, see the section of the fundamentals guide describing xref:userguide:fun:ui.adoc#table-columns[Table Columns].
+
+
+
+=== menubars.layout.xml
+
+In a similar fashion, the actions of the various domain services are grouped into menus using the `menubars.layout.xml` file.
+
+*Mini-Exercise*:
+
+* locate the `menubars.layout.xml` file
+* compare the structure of the layout file to that of the rendered menu bar
+* change the file, eg reorder menu items or create new menus
+* again, use the IDE to copy over the file to the classpath
+** for example, with IntelliJ use menu:Run[Debugging Actions > Reload Changed Classes]/
+
+
+To learn more, see the section of the fundamentals guide describing xref:userguide:fun:ui.adoc#file-based-menus[file-based] menu bar layout.
+
diff --git a/antora/components/tutorials/modules/petclinic/pages/020-the-petclinic-domain.adoc b/antora/components/tutorials/modules/petclinic/pages/020-the-petclinic-domain.adoc
new file mode 100644
index 0000000..388a185
--- /dev/null
+++ b/antora/components/tutorials/modules/petclinic/pages/020-the-petclinic-domain.adoc
@@ -0,0 +1,64 @@
+: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 [...]
+
+= An Example Domain
+
+By now you should understand the basics of what Apache Isis does, but there's only so much we can learn from a single domain class.
+Let's therefore evolve the app into a slightly more interesting domain and explore other features of the framework.
+
+The domain we're going to work on is a version of the venerable "Pet Clinic" app.
+Here's a sketch of (our version of) its domain:
+
+include::partial$domain.adoc[]
+
+[TIP]
+====
+The colours used are from "Java: Modeling in Color" book by Coad et al.
+====
+
+
+Some of the use cases we might want to support include:
+
+* create a `PetOwner`
+* add and remove ``Pet``s for said `PetOwner`
+* book a `Pet` in for a `Visit`
+* enter an `outcome` and `cost` of a `Visit`
+* allow a `PetOwner` to pay for a `Visit`
+* find ``Visit``s not yet paid and overdue
+* delete a `PetOwner` and its ``Pet``s and ``Visit``s, so long as there are no unpaid ``Visit``s.
+
+This tutorial has worked solutions for all of these.
+
+[#exercise-2-1-refactor-simpleobject-to-petowner]
+== Exercise 2.1: Refactor `SimpleObject` to `PetOwner`
+
+To start with, let's rename the `SimpleObject` entity to `PetOwner`
+
+=== Solution
+
+[source,bash]
+----
+git checkout tags/02-01-renames-SimpleObject-to-PetOwner
+mvn clean install
+mvn -pl spring-boot:run
+----
+
+=== Tasks
+
+Just checkout the solution above and review the git history to see the changes that have already been made.
+These include:
+
+* Domain classes renamed (along with corresponding tests)
+
+** `SimpleObject` entity -> `PetOwner`
+** `SimpleObjects` domain service -> `PetOwners`
+** `SimpleObjectRepository` repository service -> `PetOwnerRepository`
+
+** Infrastructure classes renamed
+
+** `SimpleModule` -> `PetsModule`
+** `SimpleApp` -> `PetClinicApp`
+
+
+Build and run the application (note that the main class has changed) to make sure it still runs fine.
+
+
diff --git a/antora/components/tutorials/modules/petclinic/pages/030-petowner-entity.adoc b/antora/components/tutorials/modules/petclinic/pages/030-petowner-entity.adoc
new file mode 100644
index 0000000..06e438c
--- /dev/null
+++ b/antora/components/tutorials/modules/petclinic/pages/030-petowner-entity.adoc
@@ -0,0 +1,954 @@
+= PetOwner entity
+
+: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 [...]
+
+
+In this set of exercises we'll just focus on the `PetOwner` entity.
+
+[#exercise-3-1-rename-petowners-name-property]
+== Exercise 3.1: Rename PetOwner's name property
+
+In the domain we are working on, `PetOwner` has a `firstName` and a `lastName` property, not a single `name` property.
+
+In this exercise, we'll rename ``PetOwner``'s `name` property to be `lastName`, and change the fixture script that sets up data to something more realistic.
+
+
+
+=== Solution
+
+[source,bash]
+----
+git checkout tags/03-01-renames-PetOwner-name-property
+mvn clean install
+mvn -pl spring-boot:run
+----
+
+Remember you can use the menu:Prototyping[Fixture Scripts] menu to setup some example data.
+
+
+=== Tasks
+
+Checkout the solution above and review the git history to see the changes that have already been made.
+These include:
+
+* property `PetOwner#name` -> `PetOwner#lastName` renamed
+* JPA mappings updated:
+** the corresponding JPQL named queries
+** the method names of `PetOwnerRepository`
++
+This is a Spring Data repository, which uses a link:https://www.baeldung.com/spring-data-derived-queries[naming convention] to infer the queries
+
+** uniqueness constraint for `PetOwner`
+
+* the action method names of `PetOwners` domain service renamed
++
+This also requires updating the `menubars.layout.xml`, which references these action names.
+
+* updating the xref:refguide:applib:index/annotation/ActionLayout.adoc#associateWith[@ActionLayout] of the `updateName` and `delete` action methods in `PetOwner`
++
+In the UI, the buttons for these actions are located close to the renamed "lastName" property
+
+* renames `@Name` meta-annotation to `@LastName`.
++
+Meta-annotations are a useful way of eliminating duplication where the same value type appears in multiple locations, for example as both an entity property and in action parameters.
+
+Build and run the application to make sure it still runs fine.
+
+
+
+[#exercise-3-2-add-petowners-firstname-property]
+== Exercise 3.2: Add PetOwner's firstName property
+
+Now that `PetOwner` has a `lastName` property, let's also add a `firstName` property.
+We'll also update our fixture script (which sets up ``PetOwner``s) so that it is more descriptive.
+
+
+=== Solution
+
+[source,bash]
+----
+git checkout tags/03-02-adds-PetOwner-firstName-property
+mvn clean install
+mvn -pl spring-boot:run
+----
+
+=== Tasks
+
+* copy `@LastName` meta-annotation to create `@FirstName`:
++
+[source,java]
+.FirstName.java
+----
+@Property(maxLength = FirstName.MAX_LEN, optionality = Optionality.OPTIONAL)
+@Parameter(maxLength = FirstName.MAX_LEN, optionality = Optionality.OPTIONAL)
+@ParameterLayout(named = "First Name")
+@Target({ ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER, ElementType.ANNOTATION_TYPE })
+@Retention(RetentionPolicy.RUNTIME)
+public @interface FirstName {
+
+    int MAX_LEN = 40;
+}
+----
++
+Note that this property/parameter is optional.
+Its parameter name has also been updated.
+
+
+* add a new (JPA nullable) property `firstName` to `PetOwner`:
++
+[source,java]
+----
+@FirstName
+@Column(length = FirstName.MAX_LEN, nullable = true)
+@Getter @Setter @ToString.Include
+@PropertyLayout(fieldSetId = "name", sequence = "2")
+private String firstName;
+----
+
+* add a new factory method to accept a `firstName`, and refactor the existing one:
++
+[source,java]
+.PetOwner.java
+----
+public static PetOwner withName(String name) {
+    return withName(name, null);
+}
+
+public static PetOwner withName(String lastName, String firstName) {
+    val simpleObject = new PetOwner();
+    simpleObject.setLastName(lastName);
+    simpleObject.setFirstName(firstName);
+    return simpleObject;
+}
+----
+
+
+* remove `@Title` annotation from `lastName` property, and add a `title()` method to derive from both properties:
++
+[source,java]
+.PetOwner.java
+----
+public String title() {
+    return getLastName() + (getFirstName() != null ? ", " + getFirstName() : "");
+}
+----
+
+
+* Update the `PetOwner_persona` enum with more realistically last names (family names).
++
+Learn more about fixture scripts xref:testing:fixtures:about.adoc[here].
+
+
+
+[#exercise-3-3-modify-petowners-updatename-action]
+== Exercise 3.3: Modify PetOwner's updateName action
+
+Although we've added a `firstName` property, currently it can't be edited.
+In this exercise we'll modify the `updateName` action to also allow the `firstName` to be changed.
+
+=== Solution
+
+[source,bash]
+----
+git checkout tags/03-03-modifies-PetOwner-updateName-action
+mvn clean install
+mvn -pl spring-boot:run
+----
+
+=== Tasks
+
+* update `PetOwner#updateName` to also accept a new `firstName` parameter:
++
+image::03-03/refactor-updateName.png[width=800px]
++
+[source,java]
+.PetOwner.java
+----
+@Action(semantics = IDEMPOTENT, commandPublishing = Publishing.ENABLED, executionPublishing = Publishing.ENABLED)
+@ActionLayout(associateWith = "lastName", promptStyle = PromptStyle.INLINE)
+public PetOwner updateName(
+        @LastName final String lastName,
+        @FirstName String firstName) {
+    setLastName(lastName);
+    setFirstName(firstName);
+    return this;
+}
+public String default0UpdateName() {
+    return getLastName();
+}
+public String default1UpdateName() {
+    return getFirstName();
+}
+----
+
+* add in a "default" supporting method for the new parameter.
++
+[source,java]
+.PetOwner.java
+----
+public String default1UpdateName() {
+    return getFirstName();
+}
+----
++
+The "default" supporting methods are called when the action prompt is rendered, providing the default for the "Nth" parameter of the corresponding action.
+
+
+
+
+[#exercise-3-4-modify-the-menu-action-to-create-petowners]
+== Exercise 3.4: Modify the menu action to create PetOwners
+
+If we want to create a new `PetOwner` and provide their `firstName`, at the moment it's a two stage process: create the `PetOwner` (using `PetOwners#create` action from the menu), then update their name (using the `updateName` action that we just looked at).
+
+In this exercise we'll simplify that workflow by allowing the `firstName` to optionally be specified during the initial create.
+
+=== Solution
+
+[source,bash]
+----
+git checkout tags/03-04-modifies-PetOwners-create-action
+mvn clean install
+mvn -pl spring-boot:run
+----
+
+=== Tasks
+
+* update `Orders#create` action, so that the end user can specify a `firstName` when creating a new `PetOwner`:
++
+[source,java]
+.PetOwners.java
+----
+@Action(semantics = SemanticsOf.NON_IDEMPOTENT)
+@ActionLayout(promptStyle = PromptStyle.DIALOG_SIDEBAR)
+public PetOwner create(
+        @LastName final String lastName,
+        @FirstName final String firstName) {
+    return repositoryService.persist(PetOwner.withName(lastName, firstName));
+}
+----
+
+
+=== Optional exercise
+
+NOTE: If you decide to do this optional exercise, make the changes on a git branch so that you can resume with the main flow of exercises later.
+
+It would be nice if the `PetOwner` were identified by both their `firstName` and their `lastName`; at the moment every `PetOwner` must have a unique `lastName`.
+Or, even better would be to introduce some sort of "customerNumber" and use this as the unique identifier.
+
+
+
+
+[#exercise-3-5-initial-fixture-script]
+== Exercise 3.5: Initial Fixture Script
+
+As we prototype with an in-memory database, it means that we need to setup the database each time we restart the application.
+Using the menu:Prototyping[Fixture Scripts] menu to setup data saves some time, but it would nicer still if that script could be run automatically.
+We can do that by specifying a configuration property.
+
+We can also leverage link:https://docs.spring.io/spring-boot/docs/current/reference/html/features.html#features.profiles[Spring Boot profiles] to keep this configuration separate.
+
+
+=== Solution
+
+[source,bash]
+----
+git checkout tags/03-05-initial-fixture-script
+mvn clean install
+mvn -pl spring-boot:run
+----
+
+
+=== Tasks
+
+* create the following file in `src/main/resources` of the webapp (alongside the existing `application.yml` file):
++
+[source,yaml]
+.application-dev.yaml
+----
+isis:
+  testing:
+    fixtures:
+      initial-script: petclinic.webapp.application.fixture.scenarios.PetClinicDemo
+----
+
+* modify the startup of your application to enable this profile, using this system prpoerty:
++
+[source]
+----
+-Dspring.profiles.active=dev
+----
+
+When you run the application you should now find that there are 10 `PetOwner` objects already created.
+
+
+
+
+[#exercise-3-6-prompt-styles]
+== Exercise 3.6: Prompt styles
+
+The framework provides many ways to customise the UI, either through the layout files or using the `@XxxLayout` annotations.
+Default UI conventions can also be specified using the `application.yml` configuration file.
+
+In this exercise we'll change the prompt style for both a service (menu) action, ie `PetOwners#create`, and an object action, ie `PetOwner#updateName`.
+
+
+=== Solution
+
+[source,bash]
+----
+git checkout tags/03-06-prompt-styles
+mvn clean install
+mvn -pl spring-boot:run
+----
+
+
+=== Tasks
+
+* Service (menu) actions are always shown in a dialog, of which there are two styles: modal prompt, or sidebar.
+If not specified explicitly, they will default to dialog modal.
++
+Therefore remove the `@ActionLayout(promptStyle)` for `PetOwners#create` and confirm that the dialog is now shown as a modal prompt.
+
+* Object actions can be shown either inline or in a dialog, but default to inline.
+If forced to use a dialog, then they default to a sidebar prompt rather than a modal prompt.
++
+Therefore remove the `@ActionLayout(promptStyle)` for `PetOwner#updateName` and confirm that prompt is still inline.
+
+* Using a configuration property we can change the default for object actions to use a dialog rather than inline.
++
+using the Spring boot profile trick from before:
++
+[source,yaml]
+.application-custom.yaml
+----
+isis:
+  viewer:
+    wicket:
+      prompt-style: dialog
+----
++
+Remember to activate this new profile (`-Dspring.profiles.active=dev,custom`) and confirm that the `updateName` prompt now uses a sidebar dialog.
+
+* We can overide the default dialog style for both service and object actions using further configuration properties.
++
+Switch the defaults so that service actions prefer to use a sidebar dialog, while object actions would use a modal dialog:
++
+[source,yaml]
+.application-custom.yaml
+----
+isis:
+  viewer:
+    wicket:
+      prompt-style: dialog
+      dialog-mode: modal
+      dialog-mode-for-menu: sidebar
+----
+
+* Optional: now use `@ActionLayout(promptStyle=...)` to override these defaults.
++
+Be aware that "inline" makes no sense/is not supported for service actions.
+
+* Finish off the exercises by setting up these defaults to retain the original behaviour:
++
+[source,yaml]
+.application-custom.yaml
+----
+isis:
+  viewer:
+    wicket:
+      prompt-style: inline
+      #dialog-mode: modal   # unused if prompt-style is inline
+      dialog-mode-for-menu: sidebar
+----
+
+
+
+
+
+[#exercise-3-7-derived-name-property]
+== Exercise 3.7: Derived name property
+
+The ``PetOwner``'s `firstName` and `lastName` properties are updated using the `updateName` action, but when the action's button is invoked, it only "replaces" the `lastName` property:
+
+image::Owner-updateName-prompt.png[width="400px",link="_images/Owner-updateName-prompt.png"]
+
+In this exercise we'll improve the UI by introducing a derived `name` property and then hiding the `firstName` and `lastName`:
+
+image::Owner-name.png[width="400px",link="_images/Owner-name.png"]
+
+When `PetOwner#updateName` is invoked, the prompt we'll want see is:
+
+image::Owner-name-updated.png[width="400px",link="_images/Owner-name-updated.png"]
+
+
+=== Solution
+
+[source,bash]
+----
+git checkout tags/03-07-derived-PetOwner-name
+mvn clean install
+mvn -pl spring-boot:run
+----
+
+
+=== Tasks
+
+
+* Add `getName()` as the derived `name` property:
++
+[source,java]
+.PetOwner.java
+----
+@Transient
+@PropertyLayout(fieldSetId = "name", sequence = "1")
+public String getName() {
+    return getFirstName() + " " + getLastName();
+}
+----
+
+* Hide the `lastName` and `firstName` properties, using `@Property(hidden=...)`.
+We can also remove the `@PropertyLayout` annotation.
++
+[source,java]
+.PetOwner.java
+----
+@LastName
+@Column(length = LastName.MAX_LEN, nullable = false)
+@Getter @Setter @ToString.Include
+@Property(hidden = Where.EVERYWHERE)
+private String lastName;
+
+@FirstName
+@Column(length = FirstName.MAX_LEN, nullable = true)
+@Getter @Setter @ToString.Include
+@Property(hidden = Where.EVERYWHERE)
+private String firstName;
+----
+
+
+* Update the `PetOwner#updateName` to associate with the new `name` property:
++
+[source,xml]
+----
+@ActionLayout(associateWith = "name",)
+public PetOwner updateName( ... ) {}
+----
+
+
+Run the application and check that it behaves as you expect.
+
+However, if you now try to build the app (`mvn clean install`) then you'll hit test errors, because we have changed the visibility of the `lastName` and `firstName` properties.
+
+We will be looking at tests later on, so if you want to just comment out the failing tests, then do that.
+Alternatively, here are the changes that need to be made:
+
+* update the `PetOwner_IntegTest#name` nested static test class:
++
+[source,java]
+.PetOwner_IntegTest.java
+----
+@Nested
+public static class name extends PetOwner_IntegTest {
+
+    @Test
+    public void accessible() {
+        // when
+        final String name = wrap(petOwner).getName();   // <.>
+
+        // then
+        assertThat(name).isEqualTo(petOwner.getLastName());
+    }
+
+    // <.>
+}
+----
+<.> change this line from `getLastName()` to `getName()`
+<.> delete the 'editable' test
+
+* add a new `PetOwner_IntegTest#lastName` nested static test class to check that the `lastName` property can no longer be viewed:
++
+[source,java]
+.PetOwner_IntegTest.java
+----
+@Nested
+public static class lastName extends PetOwner_IntegTest {
+
+    @Test
+    public void not_accessible() {
+        // expect
+        assertThrows(HiddenException.class, ()->{
+
+            // when
+            wrap(petOwner).getLastName();
+        });
+    }
+}
+----
++
+This asserts that the `lastName` property cannot be viewed.
+
+* add a new `PetOwner_IntegTest#firstName` nested static test class to check that the `firstName` property can no longer be viewed.
++
+[source,java]
+.PetOwner_IntegTest.java
+----
+@Nested
+public static class firstName extends PetOwner_IntegTest {
+
+    @Test
+    public void not_accessible() {
+        // expect
+        assertThrows(HiddenException.class, ()->{
+
+            // when
+            wrap(petOwner).getFirstName();
+        });
+    }
+}
+----
+
+* update the `PetOwner_IntegTest#updateName` nested static test class, specifically the assertion:
++
+[source,java]
+.PetOwner_IntegTest.java
+----
+@Nested
+public static class updateName extends PetOwner_IntegTest {
+
+
+    @Test
+    public void can_be_updated_directly() {
+
+        // when
+        wrap(petOwner).updateName("McAdam", "Adam");                // <.>
+        transactionService.flushTransaction();
+
+        // then
+        assertThat(petOwner.getLastName()).isEqualTo("McAdam");     // <.>
+        assertThat(petOwner.getFirstName()).isEqualTo("Adam");      // <2>
+    }
+    //...
+}
+----
+<.> provide both `lastName` and `firstName` parameters
+<.> assert on both properties.
+Note that the `petOwner` object cannot be "wrapped".
+
+In case you are wondering, the wrap method is a call to xref:refguide:applib:index/services/wrapper/WrapperFactory.adoc[WrapperFactory], which provides a proxy to the object.
+This proxy emulates the UI, in this case enforcing the "hidden" rule by throwing an exception if it would not be visible.
+For this test, we _want_ to peek under the covers to check the direct state of the entity, therefore we don't wrap the object.
+
+* also update the `Smoke_IntegTest`:
++
+[source,java]
+.Smoke_IntegTest.java
+----
+...
+assertThat(wrap(fred).getName()).isEqualTo("Freddy"); // <.>
+...
+----
+<.> previously was "wrap(fred).getLastName().
+
+
+
+[#exercise-3-8-add-other-properties-for-petowner]
+== Exercise 3.8: Add other properties for PetOwner
+
+Let's add the two remaining properties for `PetOwner`:
+
+[plantuml]
+----
+hide empty members
+hide methods
+
+class Owner {
+    +id
+    ..
+    #lastName
+    #firstName
+    ..
+    -phoneNumber
+    -emailAddress
+}
+----
+
+They are `phoneNumber` and `emailAddress`.
+
+=== Solution
+
+[source,bash]
+----
+git checkout tags/03-08-add-remaining-PetOwner-properties
+mvn clean install
+mvn -pl spring-boot:run
+----
+
+=== Task
+
+* Create a `@PhoneNumber` meta-annotation, defined to be an editable property:
++
+[source,java]
+.PhoneNumber.java
+----
+@Property(
+        editing = Editing.ENABLED,  // <.>
+        maxLength = PhoneNumber.MAX_LEN,
+        optionality = Optionality.OPTIONAL
+)
+@Parameter(maxLength = PhoneNumber.MAX_LEN, optionality = Optionality.OPTIONAL)
+@Target({ ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER, ElementType.ANNOTATION_TYPE })
+@Retention(RetentionPolicy.RUNTIME)
+public @interface PhoneNumber {
+
+    int MAX_LEN = 30;
+}
+----
+<.> any properties annotated with this meta-annotation will be editable by default
+
+* Similarly, create an `@EmailAddress` meta-annotation, defined to be an editable property:
++
+[source,java]
+.EmailAddress.java
+----
+@Property(
+        editing = Editing.ENABLED,
+        maxLength = EmailAddress.MAX_LEN,
+        optionality = Optionality.OPTIONAL
+)
+@PropertyLayout(named = "E-mail")   // <.>
+@Parameter(maxLength = EmailAddress.MAX_LEN, optionality = Optionality.OPTIONAL)
+@ParameterLayout(named = "E-mail")  // <.>
+@Target({ ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER, ElementType.ANNOTATION_TYPE })
+@Retention(RetentionPolicy.RUNTIME)
+public @interface EmailAddress {
+
+    int MAX_LEN = 100;
+}
+----
+<.> xref:refguide:applib:index/annotation/PropertyLayout.adoc#named[@PropertyLayout#named] allows characters to be used that are not valid Java identifiers.
+<.> xref:refguide:applib:index/annotation/ParameterLayout.adoc#named[@ParameterLayout#named] - ditto.
+
+* add properties to `PetOwner`:
++
+[source,java]
+.PetOwner.java
+----
+@PhoneNumber
+@Column(length = PhoneNumber.MAX_LEN, nullable = true)
+@PropertyLayout(fieldSetId = "name", sequence = "1.5")
+@Getter @Setter
+private String phoneNumber;
+
+@EmailAddress
+@Column(length = EmailAddress.MAX_LEN, nullable = true)
+@PropertyLayout(fieldSetId = "name", sequence = "1.6")
+@Getter @Setter
+private String emailAddress;
+----
+
+
+
+
+[#exercise-3-9-validation]
+== Exercise 3.9: Validation
+
+At the moment there are no constraints for the format of `phoneNumber` or `emailAddress` properties.
+We can fix this by adding rules to their respective meta-annotations.
+
+[source,bash]
+----
+git checkout tags/03-09-validation-rules-using-metaannotations
+mvn clean install
+mvn -pl spring-boot:run
+----
+
+=== Task
+
+
+* Update the `@Property` annotation of the `@PhoneNumber` meta-annotation:
++
+[source,java]
+.PhoneNumber.java
+----
+@Property(
+        editing = Editing.ENABLED,
+        maxLength = PhoneNumber.MAX_LEN,
+        optionality = Optionality.OPTIONAL,
+        regexPattern = "[+]?[0-9 ]+",       // <.>
+        regexPatternReplacement =           // <.>
+            "Specify only numbers and spaces, optionally prefixed with '+'.  " +
+            "For example, '+353 1 555 1234', or '07123 456789'"
+)
+@Parameter(maxLength = PhoneNumber.MAX_LEN, optionality = Optionality.OPTIONAL)
+@Target({ ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER, ElementType.ANNOTATION_TYPE })
+@Retention(RetentionPolicy.RUNTIME)
+public @interface PhoneNumber {
+
+    int MAX_LEN = 30;
+}
+----
+<.> regex constraint
+<.> validation message if the constraint is not met
+
+* Similarly, update `@EmailAddress`:
++
+[source,java]
+.EmailAddress.java
+----
+@Property(
+        editing = Editing.ENABLED,
+        maxLength = EmailAddress.MAX_LEN,
+        optionality = Optionality.OPTIONAL,
+        regexPattern = "[^@]+@[^@]+[.][^@]+",                   // <.>
+        regexPatternReplacement = "Invalid email address"       // <.>
+)
+@PropertyLayout(named = "E-mail")
+@Parameter(maxLength = EmailAddress.MAX_LEN, optionality = Optionality.OPTIONAL)
+@ParameterLayout(named = "E-mail")
+@Target({ ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER, ElementType.ANNOTATION_TYPE })
+@Retention(RetentionPolicy.RUNTIME)
+public @interface EmailAddress {
+
+    int MAX_LEN = 100;
+}
+----
+<.> regex constraint.
+(Should really use a more comprehensive regex, eg see https://emailregex.com).
+<.> validation message if the constraint is not met
+
+Try out the application and check that these rules are applied.
+
+The `updateName` action also has a validation rule, applied directly to the method:
+
+[source,java]
+.PetOwner.java
+----
+public String validate0UpdateName(String newName) {             // <.>
+    for (char prohibitedCharacter : "&%$!".toCharArray()) {
+        if( newName.contains(""+prohibitedCharacter)) {
+            return "Character '" + prohibitedCharacter + "' is not allowed.";
+        }
+    }
+    return null;
+}
+----
+<.> validates the "0^th^" parameter of `updateName`.
+More details on the validate supporting method can be found xref:refguide:applib-methods:prefixes.adoc#validate[here].
+
+We can Move this constraint onto the `@LastName` meta-annotation instead:
+
+*  Update the `@LastName` meta-annotation using a xref:refguide:applib-classes:spec.adoc#specification[Specification]:
++
+[source,java]
+.LastName.java
+----
+@Property(maxLength = LastName.MAX_LEN, mustSatisfy = LastName.Spec.class)  // <.>
+@Parameter(maxLength = LastName.MAX_LEN, mustSatisfy = LastName.Spec.class) // <1>
+@ParameterLayout(named = "Last Name")
+@Target({ ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER, ElementType.ANNOTATION_TYPE })
+@Retention(RetentionPolicy.RUNTIME)
+public @interface LastName {
+
+    int MAX_LEN = 40;
+
+    class Spec extends AbstractSpecification<String> {                      // <.>
+        @Override public String satisfiesSafely(String candidate) {
+            for (char prohibitedCharacter : "&%$!".toCharArray()) {
+                if( candidate.contains(""+prohibitedCharacter)) {
+                    return "Character '" + prohibitedCharacter + "' is not allowed.";
+                }
+            }
+            return null;
+        }
+    }
+}
+----
+<.> indicates that the property or parameter value must satisfy the specification below
+<.> defines the specification definition, where a non-null value is the reason why the specification is not satisfied.
+
+* Remove the `validate0UpdateName` from `PetOwner`.
+
+Test the app once more.
+
+=== Optional exercise
+
+NOTE: If you decide to do this optional exercise, make the changes on a git branch so that you can resume with the main flow of exercises later.
+
+As well as validating the `lastName`, it would be nice to also validate `firstName` with the same rule.
+As the logic is shared, create a new meta-(meta-)annotation called `@Name`, move the specification (and anything else that is common between lastName and firstName) to that new meta annotation, and then meta-annotate `@LastName` and `@FirstName` with `@Name`.
+
+
+
+
+
+[#exercise-3-10-field-layout]
+== Exercise 3.10: Field layout
+
+At the moment all the properties of `PetOwner` are grouped into a single fieldset.
+The UI would be improved by grouping properties according to their nature, for example the "phoneNumber" and "emailAddress" in a "Contact Details" fieldset.
+
+We do this using the associated `PetOwner.layout.xml` file (which defines the positioning of the fieldsets), and also using the annotations within `PetOwner` (which associate the properties to those fieldsets).
+
+=== Solution
+
+[source,bash]
+----
+git checkout tags/03-10-PetOwner-fieldsets
+mvn clean install
+mvn -pl spring-boot:run
+----
+
+=== Task
+
+* modify the `PetOwner.layout.xml`, adding two new `fieldSet` definitions after the first `tabGroup`:
++
+[source,xml]
+.PetOwner.layout.xml
+----
+<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<bs3:grid>
+    <bs3:row>
+        <!-- ... -->
+    </bs3:row>
+    <bs3:row>
+        <bs3:col span="6">
+            <bs3:tabGroup>
+                <!-- ... -->
+            </bs3:tabGroup>
+            <c:fieldSet id="contactDetails" name="Contact Details"/> <!--.-->
+            <c:fieldSet id="notes" name="Notes"/>                    <!--.-->
+        </bs3:col>
+        <bs3:col span="6">
+            <!-- ... -->
+        </bs3:col>
+    </bs3:row>
+</bs3:grid>
+----
+<.> fieldSet for contact details
+<.> fieldset for the notes
+
+* modify the `@PropertyLayout` annotation for the properties to associate with these fieldsets:
++
+[source,java]
+.PetOwner.java
+----
+// ...
+@PropertyLayout(fieldSetId = "contactDetails", sequence = "1")    // <.>
+private String phoneNumber;
+
+// ...
+@PropertyLayout(fieldSetId = "contactDetails", sequence = "2")    // <.>
+private String emailAddress;
+
+// ...
+@PropertyLayout(fieldSetId = "notes", sequence = "1")               // <.>
+private String notes;
+----
+<.> associates as the 1^st^ property in the "contact details" fieldset
+<.> associates as the 2^nd^ property in the "contact details" fieldset
+<.> associates with the "notes" fieldset
+
+Run the application; the layout should look like:
+
+image::03-10/fieldsets.png[width=800]
+
+
+The layout file can be reloaded dynamically (on IntelliJ, menu:Run[Debugging Actions > Reload Changed Classes]), so you can inspect any updates without having to restart the app.
+Experiment with this by moving a fieldset into a tab group, or change the width of a column).
+
+
+
+=== Optional Exercise
+
+NOTE: If you decide to do this optional exercise, make the changes on a git branch so that you can resume with the main flow of exercises later.
+
+It is also possible to associate the properties to fieldsets using only the `.layout.xml` file.
+In fact, pretty much all of the metadata in the `@XxxLayout` annotations can be specified in the layout file.
+
+[source,xml]
+.PetOwner.layout.xml
+----
+<c:fieldSet id="contactDetails" name="Contact Details">
+    <c:property id="phoneNumber"/>
+    <c:property id="emailAddress"/>
+</c:fieldSet>
+<c:fieldSet id="notes" name="Notes">
+    <c:property id="notes"/>
+</c:fieldSet>
+----
+
+The `@PropertyLayout` annotations could then be removed.
+
+Using the layout file to specify individual properties provides even more fine-grained control when dynamically reloading, so you could for example switch the order of properties in a fieldset and inspect the changes immediately without having to restart the app.
+You might find though that the main benefit of the layout file is to declare how the different "regions" of the UI fit together in terms of rows, columns, tabs and fieldsets, and then use annotations to slot the properties/actions into those regions.
+It really is a matter of personal preference which approach you use.
+
+
+
+[#exercise-3-11-column-orders]
+== Exercise 3.11: Column Orders
+
+The home page of the webapp shows a list of all `PetOwner`s (inherited from the original simple app).
+We also see a list of `PetOwner`s if we invoke menu:Pet Owners[List All].
+
+The first is a "parented" collection (it is parented by the home page view model), the second is a standalone collection (it is returned from an action).
+
+The properties that are shown as columns that are shown is based on two different mechanisms.
+The first is whether the property is visible at all in any tables, which can be specified using `@PropertyLayout(hidden=...)` (see xref:refguide:applib:index/annotation/PropertyLayout.adoc#hidden[@PropertyLayout#hidden]).
+The second is to use a "columnOrder" file.
+
+In this exercise, we'll use the latter approach.
+
+=== Solution
+
+[source,bash]
+----
+git checkout tags/03-11-PetOwner-columnOrder
+mvn clean install
+mvn -pl spring-boot:run
+----
+
+=== Task
+
+* Declare the `id` field of `PetOwner` as a property by adding a getter and other annotations:
++
+[source,java]
+.PetOwner.java
+----
+@Id
+@GeneratedValue(strategy = GenerationType.AUTO)
+@Column(name = "id", nullable = false)
+@Getter @Setter                                             // <.>
+@PropertyLayout(fieldSetId = "metadata", sequence = "1")    // <.>
+private Long id;
+----
+<.> makes field available as a property
+<.> positions property in the metadata fieldset (before `version`).
+
+* update the columnOrder for standalone collections of `PetOrder`:
++
+[source,java]
+.PetOwner.columnOrder.txt
+----
+name
+id
+#version
+----
++
+This will show only `name` and `id`; none of the other properties will be visible as columns.
+
+* create a new file `HomePageViewModel#objects.columnOrder.txt` (in the same package as `HomePageViewModel`) to define the columns visible in the `objects` collection of that view model:
++
+[source,java]
+.HomePageViewModel#objects.columnOrder.txt
+----
+name
+id
+#version
+----
+
+* delete the (unused) `PetOwner#others.columnOrder.txt` file.
+
+Run the application and confirm the columns are as expected.
+You should also be able to update the files and reload changes (on IntelliJ, menu:Run[Debugging Actions > Reload Changed Classes]) and inspect the updates without having to restart the app.
diff --git a/antora/components/tutorials/modules/petclinic/pages/040-pet-entity.adoc b/antora/components/tutorials/modules/petclinic/pages/040-pet-entity.adoc
new file mode 100644
index 0000000..bcb09ad
--- /dev/null
+++ b/antora/components/tutorials/modules/petclinic/pages/040-pet-entity.adoc
@@ -0,0 +1,989 @@
+= Pet entity
+
+: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 [...]
+
+
+Right now our domain model still only consists of the single domain class, `PetOwner`.
+We still have the `Pet` and `Visit` entities to add, along with the `PetSpecies`  enum.
+
+include::partial$domain.adoc[]
+
+In this set of exercises we'll focus on the `Pet` entity and its relationship with `PetOwner`.
+Each `PetOwner` will hold a collection of their ``Pet``s, with actions to add or remove `Pet` instances for that collection.
+
+[#exercise-4-1-pet-entitys-key-properties]
+== Exercise 4.1: Pet entity's key properties
+
+In this exercise we'll just create the outline of the `Pet` entity, and ensure it is mapped to the database correctly.
+
+
+=== Solution
+
+[source,bash]
+----
+git checkout tags/04-01-pet-entity-key-properties
+mvn clean install
+mvn -pl spring-boot:run
+----
+
+
+
+=== Tasks
+
+* create a meta-annotation `@PetName` for the Pet's name:
++
+[source,java]
+.PetName.java
+----
+@Property(maxLength = PetName.MAX_LEN, optionality = Optionality.MANDATORY)
+@Parameter(maxLength = PetName.MAX_LEN, optionality = Optionality.MANDATORY)
+@Target({ ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER, ElementType.ANNOTATION_TYPE })
+@Retention(RetentionPolicy.RUNTIME)
+public @interface PetName {
+
+    int MAX_LEN = 60;
+}
+----
+
+* create the `Pet` entity, using the `@PetName` meta-annotation for the `name` property:
++
+[source,java]
+.Pet.java
+----
+@Entity
+@Table(
+    schema="pets",
+    uniqueConstraints = {
+        @UniqueConstraint(name = "Pet__owner_name__UNQ", columnNames = {"owner_id, name"})
+    }
+)
+@EntityListeners(IsisEntityListener.class)
+@DomainObject(logicalTypeName = "pets.Pet", entityChangePublishing = Publishing.ENABLED)
+@DomainObjectLayout()
+@NoArgsConstructor(access = AccessLevel.PUBLIC)
+@XmlJavaTypeAdapter(PersistentEntityAdapter.class)
+@ToString(onlyExplicitlyIncluded = true)
+public class Pet implements Comparable<Pet> {
+
+    @Id
+    @GeneratedValue(strategy = GenerationType.AUTO)
+    @Column(name = "id", nullable = false)
+    @Getter @Setter
+    @PropertyLayout(fieldSetId = "metadata", sequence = "1")
+    private Long id;
+
+    @Version
+    @Column(name = "version", nullable = false)
+    @PropertyLayout(fieldSetId = "metadata", sequence = "999")
+    @Getter @Setter
+    private long version;
+
+
+    Pet(PetOwner petOwner, String name) {
+        this.petOwner = petOwner;
+        this.name = name;
+    }
+
+
+    @ManyToOne(optional = false)
+    @JoinColumn(name = "owner_id")
+    @PropertyLayout(fieldSetId = "name", sequence = "1")
+    @Getter @Setter
+    private PetOwner petOwner;
+
+    @PetName
+    @Column(name = "name", length = FirstName.MAX_LEN, nullable = false)
+    @Getter @Setter
+    @PropertyLayout(fieldSetId = "name", sequence = "2")
+    private String name;
+
+
+    private final static Comparator<Pet> comparator =
+            Comparator.comparing(Pet::getPetOwner).thenComparing(Pet::getName);
+
+    @Override
+    public int compareTo(final Pet other) {
+        return comparator.compare(this, other);
+    }
+
+}
+----
+
+Run the application, and confirm that the table is created correctly using menu:Prototyping[H2 Console].
+
+
+
+[#exercise-4-2-add-petrepository]
+== Exercise 4.2: Add PetRepository
+
+We will need to find the ``Pet``s belonging to a `PetOwner`.
+We do this by introducing a `PetRepository`, implemented as a link:https://docs.spring.io/spring-data/jpa/docs/current/reference/html/#repositories.definition[Spring Data repository].
+
+=== Solution
+
+[source,bash]
+----
+git checkout tags/04-02-PetRepository
+mvn clean install
+mvn -pl spring-boot:run
+----
+
+
+
+=== Tasks
+
+* create the `PetRepository`, extending Spring Data's `org.springframework.data.repository.Repository` interface:
++
+[source,java]
+.PetRepository.java
+----
+import org.springframework.data.repository.Repository;
+
+public interface PetRepository extends Repository<Pet, Long> {
+
+    List<Pet> findByPetOwner(PetOwner petOwner);
+}
+----
+
+Confirm the application still runs
+
+
+
+[#exercise-4-3-add-petowners-collection-of-pets]
+== Exercise 4.3: Add PetOwner's collection of Pets
+
+In this next exercise we'll add the ``PetOwner``'s collection of ``Pet``s, using a xref:userguide:fun:mixins.adoc[mixin].
+
+[plantuml]
+----
+include::partial$skinparam.adoc[]
+
+package pets {
+
+    class Pet <<ppt>> {
+        +id
+        ..
+        #petOwner
+        #name
+        ..
+        version
+    }
+
+    class PetOwner <<role>> {
+        +id
+        ..
+        #lastName
+        #firstName
+        ..
+        -phoneNumber
+        -emailAddress
+    }
+}
+
+
+PetOwner *-r--> "0..*" Pet
+----
+
+=== Solution
+
+[source,bash]
+----
+git checkout tags/04-03-PetOwner-pets-mixin-collection
+mvn clean install
+mvn -pl spring-boot:run
+----
+
+=== Tasks
+
+* create the `PetOwner_pets` mixin class:
++
+[source,java]
+----
+import org.apache.isis.applib.annotation.Collection;
+import org.apache.isis.applib.annotation.CollectionLayout;
+
+import lombok.RequiredArgsConstructor;
+
+@Collection                                             // <.>
+@CollectionLayout(defaultView = "table")
+@RequiredArgsConstructor                                // <.>
+public class PetOwner_pets {                            // <.>
+
+    private final PetOwner petOwner;                    // <.>
+
+    public List<Pet> coll() {
+        return petRepository.findByPetOwner(petOwner);  // <.>
+    }
+
+    @Inject PetRepository petRepository;                // <5>
+}
+----
+<.> indicates that this is a collection mixin
+<.> lombok annotation to avoid some boilerplate
+<.> collection name is derived from the mixin class name, being the name after the '_'.
+<.> the "mixee" that is being contributed to, in other words `PetOwner`.
+<.> inject the `PetRepository` as defined in previous exercise, in order to find the ``Pet``s owned by the `PetOwner`.
+
+* Run the application to confirm that the `pets` collection is visible (it won't have any `Pet` instances in it just yet).
+
+
+* update the `PetOwner.layout.xml` file to specify the position of the `pets` collection.
+For example:
++
+[source,xml]
+.PetOwner.layout.xml
+----
+<bs3:grid>
+    <bs3:row>
+        <!--...-->
+    </bs3:row>
+    <bs3:row>
+        <bs3:col span="6">
+            <!--...-->
+        </bs3:col>
+        <bs3:col span="6">
+            <bs3:tabGroup  unreferencedCollections="true" collapseIfOne="false">
+                <bs3:tab name="Pets">                   <!--.-->
+                    <bs3:row>
+                        <bs3:col span="12">
+                            <c:collection id="pets"/>
+                        </bs3:col>
+                    </bs3:row>
+                </bs3:tab>
+            </bs3:tabGroup>
+        </bs3:col>
+    </bs3:row>
+</bs3:grid>
+----
+<.> define a tab on the right hand side to hold the `pets` collection.
++
+Run the application (or just reload the changed classes) and confirm the positioning the `pets` collection.
+
+
+* Create a column order file to define the order of columns in the ``PetOwner``'s `pets` collection:
++
+[source,xml]
+.PetOwner#pets.columnOrder.txt
+----
+name
+id
+----
++
+Run the application (or just reload the changed classes) and confirm the columns of the `pets` collection are correct.
+
+
+
+
+[#exercise-4-4-add-pets-remaining-properties]
+== Exercise 4.4: Add Pet's remaining properties
+
+In this exercise we'll add the remaining properties for `Pet`.
+
+[plantuml]
+----
+include::partial$skinparam.adoc[]
+
+package pets {
+
+    enum PetSpecies <<desc>> {
+        Dog
+        Cat
+        Hamster
+        Budgerigar
+    }
+
+    class Pet <<ppt>> {
+        +id
+        ..
+        #petOwner
+        #name
+        ..
+        -species
+        -notes
+        ..
+        -version
+    }
+
+}
+
+Pet  "*" -u-> PetSpecies
+----
+
+
+=== Solution
+
+[source,bash]
+----
+git checkout tags/04-04-pet-remaining-properties
+mvn clean install
+mvn -pl spring-boot:run
+----
+
+
+=== Tasks
+
+* declare the `PetSpecies` enum:
++
+[source,java]
+.PetSpecies.java
+----
+public enum PetSpecies {
+    Dog,
+    Cat,
+    Hamster,
+    Budgerigar,
+}
+----
+
+* add in a reference to `PetSpecies`:
++
+[source,java]
+.Pet.java
+----
+@Enumerated(EnumType.STRING)                                // <.>
+@Column(nullable = false)
+@Getter @Setter
+@PropertyLayout(fieldSetId = "details", sequence = "1")     // <.>
+private PetSpecies petSpecies;
+----
+<.> mapped to a string rather than an integer value in the database
+<.> anticipates adding a 'details' fieldSet in the layout xml (see xref:#exercise-4-7-add-pets-ui-customisation[ex 4.7])
+
+* As the `petSpecies` property is mandatory, also update the constructor:
++
+[source,java]
+.Pet.java
+----
+Pet(PetOwner petOwner, String name, PetSpecies petSpecies) {
+    this.petOwner = petOwner;
+    this.name = name;
+    this.petSpecies = petSpecies;
+}
+----
+
+* add in an optional `notes` property:
++
+[source,java]
+----
+@Notes
+@Column(length = Notes.MAX_LEN, nullable = true)
+@Getter @Setter
+@Property(commandPublishing = Publishing.ENABLED, executionPublishing = Publishing.ENABLED)
+@PropertyLayout(fieldSetId = "notes", sequence = "1")
+private String notes;
+----
+
+Run the application and use menu:Prototyping[H2 Console] to confirm the database schema for `Pet` is as expected.
+
+
+[#exercise-4-5-digression-clean-up-casing-of-database-schema]
+== Exercise 4.5: Digression: clean-up casing of database schema
+
+Reviewing the tables in the database we can see that we have a mix between lower- and upper-case table and column names.
+In this exercise we'll take a timeout to make everything consistent.
+
+=== Solution
+
+[source,bash]
+----
+git checkout tags/04-05-db-schema-consistent-casings
+mvn clean install
+mvn -pl spring-boot:run
+----
+
+=== Tasks
+
+* check out the tag and inspect the changes:
+
+** `Pet` entity table name
+** `PetOwner` entity table name and column names
+** JDBC URL
+
+* run the application to check the database schema.
+
+
+
+[#exercise-4-6-add-petowner-action-to-add-pets]
+== Exercise 4.6: Add PetOwner action to add Pets
+
+In this exercise we'll bring in the capability to add ``Pet``s, as a responsibility of `PetOwner`.
+We'll use an mixin action to implement this.
+
+=== Solution
+
+[source,bash]
+----
+git checkout tags/04-06-PetOwner-addPet-action
+mvn clean install
+mvn -pl spring-boot:run
+----
+
+=== Tasks
+
+* create the `PetOwner_addPet` action mixin:
++
+[source,java]
+.PetOwner_addPet.java
+----
+@Action(                                                // <.>
+        semantics = SemanticsOf.IDEMPOTENT,
+        commandPublishing = Publishing.ENABLED,
+        executionPublishing = Publishing.ENABLED
+)
+@ActionLayout(associateWith = "pets")                   // <.>
+@RequiredArgsConstructor
+public class PetOwner_addPet {                          // <.>
+
+    private final PetOwner petOwner;                    // <.>
+
+    public PetOwner act(
+            @PetName final String name,
+            final PetSpecies petSpecies
+            ) {
+        repositoryService.persist(new Pet(petOwner, name, petSpecies));
+        return petOwner;
+    }
+
+    @Inject RepositoryService repositoryService;
+}
+----
+<.> indicates that this class is a mixin action.
+<.> the action is associated with the "pets" collection (defined earlier).
+This means that in the UI, the button representing the action will be rendered close to the table representing the "pets" collection.
+<.> the action name "addPet" is derived from the mixin class name.
++
+Run the application and verify that ``Pet``s can now be added to ``PetOwner``s.
+
+Let's now add some validation to ensure that two pets with the same name cannot be added.
+
+* first, we need a new method in `PetRepository`:
++
+[source,java]
+.PetRepository.java
+----
+Optional<Pet> findByPetOwnerAndName(PetOwner petOwner, String name);
+----
+
+* Now use a supporting xref:userguide:fun:business-rules/validity.adoc[validate] method to prevent two pets with the same name from being added:
++
+[source,java]
+.PetOwner_addPet.java
+----
+public String validate0Act(final String name) {
+    return petRepository.findByPetOwnerAndName(petOwner, name).isPresent()
+            ? String.format("Pet with name '%s' already defined for this owner", name)
+            : null;
+}
+
+@Inject PetRepository petRepository;
+----
++
+NOTE: we could also just rely on the database, but adding a check here will make for better UX.
++
+Run the application and check the validation message is fired when you attempt to add two ``Pet``s with the same name for the same `PetOwner` (but two different ``PetOwner``s should be able to have a ``Pet`` with the same name).
+
+
+* Let's suppose that owners own dogs for this particular clinic.
+Use a xref:refguide:applib-methods:prefixes.adoc#default[default] supporting method to default the petSpecies parameter:
++
+[source,java]
+.PetOwner_addPet.java
+----
+public PetSpecies default1Act() {
+    return PetSpecies.Dog;
+}
+----
++
+Run the application once more to test.
+
+
+
+
+[#exercise-4-7-add-pets-ui-customisation]
+== Exercise 4.7: Add Pet's UI customisation
+
+If we run the application and create a `Pet`, then the framework will render a page but the layout could be improved.
+So in this exercise we'll add a layout file for `Pet` and other UI files.
+
+
+=== Solution
+
+[source,bash]
+----
+git checkout tags/04-07-Pet-ui-customisation
+mvn clean install
+mvn -pl spring-boot:run
+----
+
+
+=== Tasks
+
+* Create a `Pet.layout.xml` file as follows:
++
+[source,xml]
+.Pet.layout.xml
+----
+<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<bs3:grid xsi:schemaLocation="http://isis.apache.org/applib/layout/component http://isis.apache.org/applib/layout/component/component.xsd http://isis.apache.org/applib/layout/links http://isis.apache.org/applib/layout/links/links.xsd http://isis.apache.org/applib/layout/grid/bootstrap3 http://isis.apache.org/applib/layout/grid/bootstrap3/bootstrap3.xsd" xmlns:bs3="http://isis.apache.org/applib/layout/grid/bootstrap3" xmlns:cpt="http://isis.apache.org/applib/layout/component" xmlns:lnk="h [...]
+    <bs3:row>
+        <bs3:col span="12" unreferencedActions="true">
+            <cpt:domainObject bookmarking="AS_ROOT"/>
+        </bs3:col>
+    </bs3:row>
+    <bs3:row>
+        <bs3:col span="6">
+            <bs3:row>
+                <bs3:col span="12">
+                    <bs3:tabGroup>
+                        <bs3:tab name="General">
+                            <bs3:row>
+                                <bs3:col span="12">
+                                    <cpt:fieldSet id="name"/>
+                                </bs3:col>
+                            </bs3:row>
+                        </bs3:tab>
+                        <bs3:tab name="Metadata">
+                            <bs3:row>
+                                <bs3:col span="12">
+                                    <cpt:fieldSet name="Metadata" id="metadata"/>
+                                </bs3:col>
+                            </bs3:row>
+                        </bs3:tab>
+                        <bs3:tab name="Other">
+                            <bs3:row>
+                                <bs3:col span="12">
+                                    <cpt:fieldSet name="Other" id="other" unreferencedProperties="true"/>
+                                </bs3:col>
+                            </bs3:row>
+                        </bs3:tab>
+                    </bs3:tabGroup>
+                    <cpt:fieldSet id="details" name="Details"/>
+                    <cpt:fieldSet id="notes" name="Notes"/>
+                </bs3:col>
+            </bs3:row>
+            <bs3:row>
+                <bs3:col span="12">
+                </bs3:col>
+            </bs3:row>
+        </bs3:col>
+        <bs3:col span="6">
+            <bs3:tabGroup unreferencedCollections="true"/>
+        </bs3:col>
+    </bs3:row>
+</bs3:grid>
+----
+
+* reload changed classes (or run the application), and check the layout.
++
+TIP: if the layout isn't quite as you expect, try using menu:Metadata[Rebuild metamodel] to force the domain object metamodel to be recreated.
+
+* add a `Pet.png` file to act as the icon, in the same package.
++
+This might be a good point to find a better icon for `PetOwner`, too.
+
+* we also need a title for each `Pet`, which we can provide using a
+xref:refguide:applib-methods:ui-hints.adoc#title[title()] method:
++
+[source,java]
+.Pet.java
+----
+public String title() {
+    return getName() + " " + getPetOwner().getLastName();
+}
+----
+
+In the same way that titles are specific an object instance, we can also customise the icon:
+
+* download additional icons for each of the `PetSpecies` (dog, cat, hamster, budgie)
+
+* save these icons as `Pet-dog.png`, `Pet-cat.png` and so on, ie the pet species as suffix.
+
+* implement the xref:refguide:applib-methods:ui-hints.adoc#iconName[iconName()] method as follows:
++
+[source,java]
+.Pet.java
+----
+public String iconName() {
+    return getPetSpecies().name().toLowerCase();
+}
+----
+
+* Run the application.
+You should find that the appropriate icon is selected based upon the species of the `Pet`.
+
+
+* One further tweak is to show both the title and icon for objects in tables.
+This can be done by changing some configuration properties:
++
+[source,yaml]
+.application-custom.yml
+----
+isis:
+  viewer:
+    wicket:
+      max-title-length-in-standalone-tables: 15
+      max-title-length-in-parented-tables: 15
+----
++
+also update the `application.css` file, otherwise the icon and title will be centred:
++
+[source,css]
+.application.css
+----
+td.title-column > div > div > div {
+    text-align: left;
+}
+.collectionContentsAsAjaxTablePanel table.contents thead th.title-column,
+.collectionContentsAsAjaxTablePanel table.contents tbody td.title-column {
+    width: 10%;
+}
+----
+
+
+=== Optional exercise
+
+An alternative way to create the layout file is to run the application, obtain/create an instance of the domain object in question (eg `Pet`) and then download the inferred layout XML from the metadata menu:
+
+image::04-07/download-layout-xml.png[width=400]
+
+
+
+
+[#exercise-4-8-update-fixture-script-using-pet-personas]
+== Exercise 4.8: Update fixture script using Pet personas
+
+By now you are probably tiring of continually creating a Pet in order to perform your tests.
+So let's take some time out to extend our fixture so that each `PetOwner` also has some ``Pet``s.
+
+
+=== Solution
+
+[source,bash]
+----
+git checkout tags/04-08-Pet-personas
+mvn clean install
+mvn -pl spring-boot:run
+----
+
+
+=== Tasks
+
+* First we need to modify the `PetOwnerBuilder` to make it idempotent:
++
+[source,java]
+.PetOwnerBuilder.java
+----
+@Accessors(chain = true)
+public class PetOwnerBuilder extends BuilderScriptWithResult<PetOwner> {
+
+    @Getter @Setter
+    private String name;
+
+    @Override
+    protected PetOwner buildResult(final ExecutionContext ec) {
+
+        checkParam("name", ec, String.class);
+
+        PetOwner petOwner = petOwners.findByLastNameExact(name);
+        if(petOwner == null) {
+            petOwner = wrap(petOwners).create(name, null);
+        }
+        return this.object = petOwner;
+    }
+
+    @Inject PetOwners petOwners;
+}
+----
+
+* Now we create a similar `PetBuilder` fixture script to add ``Pet``s through a `PetOwner`:
++
+[source,java]
+.PetBuilder.java
+----
+@Accessors(chain = true)
+public class PetBuilder extends BuilderScriptWithResult<Pet> {
+
+    @Getter @Setter String name;
+    @Getter @Setter PetSpecies petSpecies;
+    @Getter @Setter PetOwner_persona petOwner_persona;
+
+    @Override
+    protected Pet buildResult(final ExecutionContext ec) {
+
+        checkParam("name", ec, String.class);
+        checkParam("petSpecies", ec, PetSpecies.class);
+        checkParam("petOwner_persona", ec, PetOwner_persona.class);
+
+        PetOwner petOwner = ec.executeChildT(this, petOwner_persona.builder()).getObject(); // <.>
+
+        Pet pet = petRepository.findByPetOwnerAndName(petOwner, name).orElse(null);
+        if(pet == null) {
+            wrapMixin(PetOwner_addPet.class, petOwner).act(name, petSpecies);       // <.>
+            pet = petRepository.findByPetOwnerAndName(petOwner, name).orElseThrow();
+        }
+
+        return this.object = pet;
+    }
+
+    @Inject PetRepository petRepository;
+}
+----
+<.> Transitively sets up its prereqs (`PetOwner`).
+This relies on thefact that `PetOwnerBuilder` is idempotent.
+<.> calls domain logic to add a `Pet` if required
+
+* Now we create a "persona" enum for ``Pet``s:
++
+[source,java]
+.Pet_persona.java
+----
+@AllArgsConstructor
+public enum Pet_persona
+implements PersonaWithBuilderScript<PetBuilder>, PersonaWithFinder<Pet> {
+
+    TIDDLES_JONES("Tiddles", PetSpecies.Cat, PetOwner_persona.JONES),
+    ROVER_JONES("Rover", PetSpecies.Dog, PetOwner_persona.JONES),
+    HARRY_JONES("Harry", PetSpecies.Hamster, PetOwner_persona.JONES),
+    BURT_JONES("Burt", PetSpecies.Budgerigar, PetOwner_persona.JONES),
+    TIDDLES_FARRELL("Tiddles", PetSpecies.Cat, PetOwner_persona.FARRELL),
+    SPIKE_FORD("Spike", PetSpecies.Dog, PetOwner_persona.FORD),
+    BARRY_ITOJE("Barry", PetSpecies.Budgerigar, PetOwner_persona.ITOJE);
+
+    @Getter private final String name;
+    @Getter private final PetSpecies petSpecies;
+    @Getter private final PetOwner_persona petOwner_persona;
+
+    @Override
+    public PetBuilder builder() {
+        return new PetBuilder()                                     // <.>
+                        .setName(name)                              // <.>
+                        .setPetSpecies(petSpecies)
+                        .setPetOwner_persona(petOwner_persona);
+    }
+
+    @Override
+    public Pet findUsing(final ServiceRegistry serviceRegistry) {   // <.>
+        PetOwner petOwner = petOwner_persona.findUsing(serviceRegistry);
+        PetRepository petRepository = serviceRegistry.lookupService(PetRepository.class).orElseThrow();
+        return petRepository.findByPetOwnerAndName(petOwner, name).orElse(null);
+    }
+
+    public static class PersistAll
+    extends PersonaEnumPersistAll<Pet_persona, Pet> {
+        public PersistAll() {
+            super(Pet_persona.class);
+        }
+    }
+}
+----
+<.> Returns the `PetBuilder` added earlier
+<.> Copies over the state of the enum to the builder
+<.> Personas can also be used to lookup domain entities.
+The xref:refguide:applib:index/services/registry/ServiceRegistry.adoc[ServiceRegistry] can be used as a service locator of any domain service (usually a repository).
+
+* Finally, update the top-level `PetClinicDemo` to create both ``Pet``s and also ``PetOwner``s.
++
+[source,java]
+.PetClinicDemo.java
+----
+public class PetClinicDemo extends FixtureScript {
+
+    @Override
+    protected void execute(final ExecutionContext ec) {
+        ec.executeChildren(this, moduleWithFixturesService.getTeardownFixture());
+        ec.executeChild(this, new Pet_persona.PersistAll());
+        ec.executeChild(this, new PetOwner_persona.PersistAll());
+    }
+
+    @Inject ModuleWithFixturesService moduleWithFixturesService;
+}
+----
+
+
+
+
+
+
+
+
+[#exercise-4-9-add-petowner-action-to-delete-a-pet]
+== Exercise 4.9: Add PetOwner action to delete a Pet
+
+We will probably also need to delete an action to delete a `Pet` (though once there are associated ``Visit``s for a `Pet`, we'll need to disable this action).
+
+
+
+=== Solution
+
+[source,bash]
+----
+git checkout tags/04-09-PetOwner-deletePet-action
+mvn clean install
+mvn -pl spring-boot:run
+----
+
+
+=== Tasks
+
++ create a new action mixins, `PetOwner_removePet`:
++
+[source,java]
+.PetOwner_removePet.java
+----
+@Action(
+        semantics = SemanticsOf.IDEMPOTENT,
+        commandPublishing = Publishing.ENABLED,
+        executionPublishing = Publishing.ENABLED
+)
+@ActionLayout(associateWith = "pets", sequence = "2")
+@RequiredArgsConstructor
+public class PetOwner_removePet {
+
+    private final PetOwner petOwner;
+
+    public PetOwner act(@PetName final String name) {
+        petRepository.findByPetOwnerAndName(petOwner, name)
+                .ifPresent(pet -> repositoryService.remove(pet));
+        return petOwner;
+    }
+
+    @Inject PetRepository petRepository;
+    @Inject RepositoryService repositoryService;
+}
+----
+
+* To be explicit, add in an xref:refguide:applib:index/annotation/ActionLayout.adoc#sequence[@ActionLayout#sequence] for "addPet" also:
++
+[source,java]
+.PetOwner_addPet.java
+----
+// ...
+@ActionLayout(associateWith = "pets", sequence = "1")
+// ...
+public class PetOwner_addPet {
+    // ...
+}
+----
+
+* Run the application and test the action; it should work, but requires the ``Pet``'s `name` to be spelt exactly correctly.
+
+* Use a xref:refguide:applib-methods:prefixes.adoc#choices[choices] supporting method to restrict the list of `Pet` ``name``s:
++
+[source,java]
+.PetOwner_removePet.java
+----
+public List<String> choices0Act() {
+    return petRepository.findByPetOwner(petOwner)
+            .stream()
+            .map(Pet::getName)
+            .collect(Collectors.toList());
+}
+----
+
+* We also should xref:refguide:applib-methods:prefixes.adoc#disable[disable] (grey out) the `removePet` action if the `PetOwner` has no ``Pet``s:
++
+[source,java]
+.PetOwner_removePet.java
+----
+public String disableAct() {
+    return petRepository.findByPetOwner(petOwner).isEmpty() ? "No pets" : null;
+}
+----
+
+* As a final refinement, if there is exactly one `Pet` then that could be the xref:refguide:applib-methods:prefixes.adoc#default[default]:
++
+[source,java]
+.PetOwner_removePet.java
+----
+public String default0Act() {
+    List<String> names = choices0Act();
+    return names.size() == 1 ? names.get(0) : null;
+}
+----
+
+
+=== Optional exercise
+
+NOTE: If you decide to do this optional exercise, make the changes on a git branch so that you can resume with the main flow of exercises later.
+
+If we wanted to work with multiple instances of the `pets` collection, we can use the xref:refguide:applib-methods:prefixes.adoc#choices[choices] method using the xref:refguide:applib:index/annotation/Action.adoc#choicesFrom[@Action#choicesFrom] attribute.
+
+Add this mixin to allow multiple ``Pet``s to be removed at the same time:
+
+[source,java]
+.PetOwner_removePets.java
+----
+@Action(
+        semantics = SemanticsOf.IDEMPOTENT,
+        commandPublishing = Publishing.ENABLED,
+        executionPublishing = Publishing.ENABLED,
+        choicesFrom = "pets"                            // <.>
+)
+@ActionLayout(associateWith = "pets", sequence = "2")
+@RequiredArgsConstructor
+public class PetOwner_removePets {                      // <.>
+
+    private final PetOwner petOwner;
+
+    public PetOwner act(final List<Pet> pets) {         // <.>
+        pets.forEach(repositoryService::remove);
+        return petOwner;
+    }
+    public String disableAct() {
+        return petRepository.findByPetOwner(petOwner).isEmpty() ? "No pets" : null;
+    }
+                                                        // <.>
+    @Inject PetRepository petRepository;
+    @Inject RepositoryService repositoryService;
+}
+----
+<.> Results in checkboxes in the table, allowing the user to optionally check one or more instances before invoking the action.
+<.> Renamed as the action now works with a list of ``Pet``s
+<.> Signature changed.
+<.> The `choices` method is removed.
+
+
+
+
+
+[#exercise-4-10-cleanup]
+== Exercise 4.10: Cleanup
+
+Reviewing the contents of the `pets` module, we can see (in the solutions provided at least) that there are a few thing that still need some attention:
+
+* the classes and files for `Pet` are in the same package as for `PetOwner`; they probably should live in their own package
+* the "delete" action for `PetOwner` is not present in the UI, because its "associateWith" relates to a non-visible property
+* the "delete" action for `PetOwner` fails if there are ``Pet``s, due to a referential integrity issue.
+
+In this exercise we clean up these oversights.
+
+
+
+=== Solution
+
+[source,bash]
+----
+git checkout tags/04-10-pets-module-cleanup
+mvn clean install
+mvn -pl spring-boot:run
+----
+
+
+=== Tasks
+
+Just check out the tag above and inspect the fixes:
+
+* the `Pet` entity, `PetRepository` and related UI files have been moved to `petclinic.modules.pets.dom.pet` package
+
+* the `PetOwner_pet`, `PetOwner_addPet` and `PetOwner_removePet` mixins have also been moved.
++
+This means that `PetOwner` is actually unaware of the fact that there are associated ``Pet``s.
+This abliity to control the direction of dependencies is very useful for ensuring modularity.
+
+* the ``PetOwner``'s `delete` action has been refactored into a mixin, and also moved to the `pets` package so that it will delete the child ``Pet``s first.
++
+Also fixes tests.
+
+* the fixtures for `PetOwner` and `Pet` have also been moved into their own packages.
+
+* the tear down fixture for `PetsModule` has been updated to also delete from the `Pet` entity.
diff --git a/antora/components/tutorials/modules/petclinic/pages/050-visit-entity.adoc b/antora/components/tutorials/modules/petclinic/pages/050-visit-entity.adoc
new file mode 100644
index 0000000..06687d1a
--- /dev/null
+++ b/antora/components/tutorials/modules/petclinic/pages/050-visit-entity.adoc
@@ -0,0 +1,317 @@
+= Visit module and entity
+
+: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 [...]
+
+
+Our domain model now consists of the `PetOwner` and `Pet` entities (along with the `PetSpecies`  enum).
+In this section we'll add in the `Visit` entity:
+
+include::partial$domain.adoc[]
+
+Also, note that the `Visit` entity is in its own module.
+We'll look at the important topic of modularity in later exercises.
+
+
+[#exercise-5-1-the-visits-module]
+== Exercise 5.1: The visits module
+
+In this exercise we'll just create an empty visits module.
+
+=== Solution
+
+[source,bash]
+----
+git checkout tags/05-01-visit-module
+mvn clean install
+mvn -pl spring-boot:run
+----
+
+
+
+
+=== Tasks
+
+Just check out the tag above and inspect the changes:
+
+* A new `petclinic-module-visits` maven module has been created
+
+* its `pom.xml` declares a dependency on the ``petclinic-module-visits` maven module
+
+* the top-level pom.xml declares the new Maven module and references it
+
+* the `VisitsModule` class is a Spring `@Configuration` bean that resides in the root of the visits module, and declares an app dependency on the pets module that mirrors the maven dependency:
++
+[source,java]
+.VisitsModule.java
+----
+@Configuration
+@ComponentScan
+@Import(PetsModule.class)
+@EnableJpaRepositories
+@EntityScan(basePackageClasses = {VisitsModule.class})
+public class VisitsModule implements ModuleWithFixtures {
+
+    @Override
+    public FixtureScript getTeardownFixture() {
+        return new FixtureScript() {
+            @Override
+            protected void execute(ExecutionContext executionContext) {
+                // nothing to do
+            }
+        };
+    }
+}
+----
+
+* the webapp Maven module now depends on the new visits maven module, and the top-level `ApplicationModule` Spring `@Configuration` bean now depends upon `VisitsModule` rather than `PetsModule`
++
+It still depends upon `PetsModule`, but now as a transitive dependency.
+
+
+
+
+
+[#exercise-5-2-visit-entitys-key-properties]
+== Exercise 5.2: Visit entity's key properties
+
+Now we have a visits module, we can now add in the `Visit` entity.
+We'll start just with the key properties.
+
+
+
+[source,bash]
+----
+git checkout tags/05-02-visit-entity-key-properties
+mvn clean install
+mvn -pl spring-boot:run
+----
+
+
+
+=== Tasks
+
+* add a `Visit` entity, declaring the `pet` and `visitedAt` key properties:
++
+[source,java]
+.Visit.java
+----
+@Entity
+@Table(
+    schema="visits",        // <.>
+    name = "Visit",
+    uniqueConstraints = {
+        @UniqueConstraint(name = "Visit__pet_visitAt__UNQ", columnNames = {"owner_id", "name"})
+    }
+)
+@EntityListeners(IsisEntityListener.class)
+@DomainObject(logicalTypeName = "visits.Visit", entityChangePublishing = Publishing.ENABLED)
+@DomainObjectLayout()
+@NoArgsConstructor(access = AccessLevel.PUBLIC)
+@XmlJavaTypeAdapter(PersistentEntityAdapter.class)
+@ToString(onlyExplicitlyIncluded = true)
+public class Visit implements Comparable<Visit> {
+
+    @Id
+    @GeneratedValue(strategy = GenerationType.AUTO)
+    @Column(name = "id", nullable = false)
+    @Getter @Setter
+    @PropertyLayout(fieldSetId = "metadata", sequence = "1")
+    private Long id;
+
+    @Version
+    @Column(name = "version", nullable = false)
+    @PropertyLayout(fieldSetId = "metadata", sequence = "999")
+    @Getter @Setter
+    private long version;
+
+
+    Visit(Pet pet, LocalDateTime visitAt) {
+        this.pet = pet;
+        this.visitAt = visitAt;
+    }
+
+
+    public String title() {
+        return titleService.titleOf(getPet()) + " @ " + getVisitAt().format(DateTimeFormatter.ofPattern("dd-MM-yyyy HH:mm"));
+    }
+
+    @ManyToOne(optional = false)
+    @JoinColumn(name = "pet_id")
+    @PropertyLayout(fieldSetId = "name", sequence = "1")
+    @Getter @Setter
+    private Pet pet;
+
+    @Column(name = "visitAt", nullable = false)
+    @Getter @Setter
+    @PropertyLayout(fieldSetId = "name", sequence = "2")
+    private LocalDateTime visitAt;
+
+
+    private final static Comparator<Visit> comparator =
+            Comparator.comparing(Visit::getPet).thenComparing(Visit::getVisitAt);
+
+    @Override
+    public int compareTo(final Visit other) {
+        return comparator.compare(this, other);
+    }
+
+    @Inject @Transient TitleService titleService;
+}
+----
+<.> in the "visits" schema.
+Modules are vertical, cutting through the layers.
+Therefore the database schemas echo the Spring ``@Configuration``s and maven modules.
++
+Run the application, and confirm that the table is created correctly using menu:Prototyping[H2 Console].
+
+
+
+
+[#exercise-5-3-book-visit-action]
+== Exercise 5.3: "Book Visit" action
+
+In addition to the key properties, the `Visit` has one further mandatory property, `reason`.
+This is required to be specified when a `Visit` is created ("what is the purpose of this visit?")
+
+In this exercise we'll add that additional property and use a mixin to allow ``Visit``s to be created.
+
+
+[source,bash]
+----
+git checkout tags/05-03-schedule-visit-action
+mvn clean install
+mvn -pl spring-boot:run
+----
+
+
+=== Tasks
+
+* add the `@Reason` meta-annotation
++
+[source,java]
+.Reason.java
+----
+@Property(maxLength = Reason.MAX_LEN)
+@PropertyLayout(named = "Reason")
+@Parameter(maxLength = Reason.MAX_LEN)
+@ParameterLayout(named = "Reason")
+@Target({ ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER, ElementType.ANNOTATION_TYPE })
+@Retention(RetentionPolicy.RUNTIME)
+public @interface Reason {
+
+    int MAX_LEN = 255;
+}
+----
+
+* add the `reason` mandatory property:
++
+[source,java]
+.Visit.java
+----
+@Reason
+@Column(name = "reason", length = FirstName.MAX_LEN, nullable = false)
+@Getter @Setter
+@PropertyLayout(fieldSetId = "details", sequence = "1")
+private String reason;
+----
+
+
+* update constructor (as this is a mandatory property)
++
+[source,java]
+.Visit.java
+----
+Visit(Pet pet, LocalDateTime visitAt, String reason) {
+    this.pet = pet;
+    this.visitAt = visitAt;
+    this.reason = reason;
+}
+----
+
+* create a "visits" mixin collection as a mixin of `Pet`, so we can see the ``Visit``s that have been booked:
++
+[source,java]
+.Pet_visits.java
+----
+@Collection
+@CollectionLayout(defaultView = "table")
+@RequiredArgsConstructor
+public class Pet_visits {
+
+    private final Pet pet;
+
+    public List<Visit> coll() {
+        return visitRepository.findByPetOrderByVisitAtDesc(pet);
+    }
+
+    @Inject VisitRepository visitRepository;
+}
+----
+
+* create a "bookVisit" mixin action (in the visits module), as a mixin of `Pet`.
++
+We can use xref:refguide:applib:index/services/clock/ClockService.adoc[ClockService] to ensure that the date/time specified is in the future, and to set a default date/time for "tomorrow"
++
+[source,java]
+.Pet_bookVisit.java
+----
+@Action(
+        semantics = SemanticsOf.IDEMPOTENT,
+        commandPublishing = Publishing.ENABLED,
+        executionPublishing = Publishing.ENABLED
+)
+@ActionLayout(associateWith = "visits", sequence = "1")
+@RequiredArgsConstructor
+public class Pet_bookVisit {
+
+    private final Pet pet;
+
+    public Visit act(
+            LocalDateTime visitAt,
+            @Reason final String reason
+            ) {
+        return repositoryService.persist(new Visit(pet, visitAt, reason));
+    }
+    public String validate0Act(LocalDateTime visitAt) {
+        return clockService.getClock().nowAsLocalDateTime().isBefore(visitAt)   // <.>
+                ? null
+                : "Must be in the future";
+    }
+    public LocalDateTime default0Act() {
+        return clockService.getClock().nowAsLocalDateTime()                     // <.>
+                .toLocalDate()
+                .plusDays(1)
+                .atTime(LocalTime.of(9, 0));
+    }
+
+    @Inject ClockService clockService;
+    @Inject RepositoryService repositoryService;
+}
+----
+<.> ensures that the date/time specified is in the future.
+<.> defaults to 9am tomorrow morning.
+
+Also add in the UI files:
+
+* create a `Visit.layout.xml` layout file.
+
+* add a `Visit.png` file
+
+* add a `Pet#visits.columnOrder.txt` file
++
+to define which properties of Visit are visible as columns in ``Pet``'s `visits` collection.
+
+
+
+=== Optional exercises
+
+NOTE: If you decide to do this optional exercise, make the changes on a git branch so that you can resume with the main flow of exercises later.
+
+. Download a separate `Visit-NN.png` for each of the days of the month (1 to 31), and then use `iconName()` to show a more useful icon based on the `visitAt` date.
+
+. Use choices to provide a set of available date/times, in 15 minutes slots, say.
+
+. Refine the list of slots to filter out any visits that already exist
++
+Assume that visits take 15 minutes, and that only on visit can happen at a time.
+
diff --git a/antora/components/tutorials/modules/petclinic/pages/060-unit-testing.adoc b/antora/components/tutorials/modules/petclinic/pages/060-unit-testing.adoc
new file mode 100644
index 0000000..96f4c6c
--- /dev/null
+++ b/antora/components/tutorials/modules/petclinic/pages/060-unit-testing.adoc
@@ -0,0 +1,112 @@
+= Unit Testing
+
+: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 [...]
+
+Domain driven design is intended for complex business domains, and so testing is obviously important.
+In this part of the tutorial we'll cover unit testing, later on we'll look at integration testing.
+
+
+
+[#exercise-6-1-unit-test-the-default-time-when-booking-visits]
+== Exercise 6.1: Unit test the default time when booking visits
+
+The xref:050-visit-entity.adoc#exercise-5-3-book-visit-action["Book Visit"] action has a default time of 9am the next morning.
+In this section we'll write a unit test to verify this logic, using Mockito to "mock the clock".
+
+
+=== Solution
+
+[source,bash]
+----
+git checkout tags/06-01-unit-test-bookVisit-default-time
+mvn clean install
+mvn -pl spring-boot:run
+----
+
+
+=== Tasks
+
+* add test dependencies to the `petclinic-module-visits` maven module:
++
+[source,xml]
+.module-visits/pom.xml
+----
+<dependencies>
+    <!-- ... -->
+
+    <dependency>
+        <groupId>org.apache.isis.mavendeps</groupId>
+        <artifactId>isis-mavendeps-unittests</artifactId>
+        <type>pom</type>
+        <scope>test</scope>
+        <exclusions>
+            <exclusion>
+                <groupId>org.jmock</groupId>
+                <artifactId>jmock-junit4</artifactId>
+            </exclusion>
+        </exclusions>
+    </dependency>
+
+</dependencies>
+----
+
+* add the test:
+
+[source,java]
+----
+@ExtendWith(MockitoExtension.class)                                             // <.>
+class Pet_bookVisit_Test {
+
+    @Mock ClockService mockClockService;                                        // <.>
+    @Mock VirtualClock mockVirtualClock;                                        // <2>
+
+    @BeforeEach
+    void setup() {
+        Mockito.when(mockClockService.getClock()).thenReturn(mockVirtualClock); // <.>
+    }
+
+    @Nested
+    class default0 {
+
+        @Test
+        void defaults_to_9am_tomorrow_morning() {
+
+            // given
+            Pet_bookVisit mixin = new Pet_bookVisit(null);
+            mixin.clockService = mockClockService;                              // <.>
+
+            LocalDateTime now = LocalDateTime.of(2021, 10, 21, 16, 37, 45);
+
+            // expecting
+            Mockito.when(mockVirtualClock.nowAsLocalDateTime()).thenReturn(now);// <.>
+
+            // when
+            LocalDateTime localDateTime = mixin.default0Act();
+
+            // then
+            Assertions.assertThat(localDateTime)                                // <.>
+                    .isEqualTo(LocalDateTime.of(2021,10,22,9,0,0));
+        }
+    }
+}
+----
+
+<.> Instructs JUnit to use Mockito for mocking.
+<.> mocks the `ClockService`, and mocks the `VirtualClock` returned by the `ClockService`.
+Automatically provisioned by Mockito.
+<.> makes the mock `ClockService` return the mock `VirtualClock`.
+<.> inject the mock clock into the domain object
+<.> sets up expectations for this scenario on the mock `VirtualClock`
+<.> use link:http://joel-costigliola.github.io/assertj/[AssertJ] to assert the expected value
+
+IMPORTANT: Unit tests should have a suffix "_Test", to distinguish them from integration tests.
+The top-level pom configures Maven surefire to run the unit tests first and then integration tests as a separate execution.
+
+=== Optional Exercises
+
+NOTE: If you decide to do these optional exercises, make the changes on a git branch so that you can resume with the main flow of exercises later.
+
+. Write a similar unit test to verify the validation logic that visits cannot be in the past.
+
+. Introduce a meta-annotation `@VisitedAt`, and move the validation logic into a xref:refguide:applib-classes:spec.adoc#specification[Specification].
+Verify that the app still works, and write a unit test to check your specification.
diff --git a/antora/components/tutorials/modules/petclinic/pages/070-modularity.adoc b/antora/components/tutorials/modules/petclinic/pages/070-modularity.adoc
new file mode 100644
index 0000000..932d6e0
--- /dev/null
+++ b/antora/components/tutorials/modules/petclinic/pages/070-modularity.adoc
@@ -0,0 +1,158 @@
+= Modularity
+
+: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 [...]
+
+Keeping applications modular is key to their long-term maintainability.
+If every class potentially can depend on any other class, we'll end up with a "big ball of mud" that becomes almost impossible to change.
+
+Instead, we need to ensure that the dependency graph between packages remains acyclic.
+The framework provides two main tools:
+
+* the first we've already seen is mixins.
++
+These allow us to locate busines logic in one module that "appears" to reside in another module.
+Examples are the `visits` mixin collection and `bookVisit` mixin action that are both contributed by the `visits` module to the `Pet` entity in the `pets` module.
+
+* the second is domain events.
++
+These we haven't yet seen, but provide a way for one module to react to (or to veto) actions performed in logic in another module.
+
+In this part of the tutorial we'll look at domain events.
+
+
+
+[#exercise-7-1-refactor-petowners-delete-action]
+== Exercise 7.1: refactor PetOwner's delete action
+
+Currently the `delete` action for `PetOwner` is implemented as a mixin within the `Pet` package.
+That's a nice place for that functionality, because it can delete any `Pet`s for the `PetOwner` if any exist.
+
+However, we also have added `Visit`, which has the same issue: we cannot delete a `Pet` if there are associated ``Visit``s.
+And, in fact, we don't want to allow a `PetOwner` and their ``Pet``s from being deleted if there are ``Visit``s in the database; they might not have paid!
+
+In this exercise we will move the responsibility to delete an action back to `PetOwner`, and then use subscribers for both `Pet` and `Visit` to cascade delete or to veto the action respectively if there are related objects.
+
+
+
+=== Solution
+
+[source,bash]
+----
+git checkout tags/07-01-delete-action-events
+mvn clean install
+mvn -pl spring-boot:run
+----
+
+To test this out:
+
+* try deleting a `PetOwner` where none of their ``Pet``s have any ``Visit``s; the action should succeed, and the `PetOwner` and the ``Pet``s should all be deleted.
+
+* now book a `Visit` for a `Pet`, then navigate back to the parent `PetOwner` and attempt to delete it.
+This time the action should be vetoed, because of that `Visit`.
+
+
+=== Tasks
+
+* in `PetOwner_delete` remove the code that deletes the ``Pet``s.
+In its place, define a subclass of xref:refguide:applib-classes:events.adoc#domain-event-classes[ActionDomainEvent] as a nested class of the mixin, and reference in the xref:refguide:applib:index/annotation/Action.adoc#domainEvent[@Action#domainEvent] attribute.
++
+[source,java]
+.PetOwner_delete.java
+----
+@Action(
+        domainEvent = PetOwner_delete.ActionEvent.class,            // <.>
+        semantics = SemanticsOf.NON_IDEMPOTENT_ARE_YOU_SURE,
+        commandPublishing = Publishing.ENABLED,
+        executionPublishing = Publishing.ENABLED
+)
+@ActionLayout(
+        associateWith = "name", position = ActionLayout.Position.PANEL,
+        describedAs = "Deletes this object from the persistent datastore")
+@RequiredArgsConstructor
+public class PetOwner_delete {
+
+    public static class ActionEvent                                 // <.>
+            extends ActionDomainEvent<PetOwner_delete>{}
+
+    private final PetOwner petOwner;
+
+    public void act() {
+        repositoryService.remove(petOwner);
+        return;
+    }
+
+    @Inject RepositoryService repositoryService;
+}
+----
+<.> specifies the domain event to emit when the action is called
+<.> declares the action event (as a subclass of the framework's xref:refguide:applib-classes:events.adoc#domain-event-classes[ActionDomainEvent]).
+
+* create a subscriber in the `pets` package to delete all ``Pet``s when the `PetOwner_delete` action is invoked:
++
+[source,java]
+.PetOwnerForPetsSubscriber.java
+----
+@Service
+public class PetOwnerForPetsSubscriber {
+
+    @EventListener(PetOwner_delete.ActionEvent.class)
+    public void on(PetOwner_delete.ActionEvent ev) {
+        switch(ev.getEventPhase()) {
+            case EXECUTING:                                             // <.>
+                PetOwner petOwner = ev.getSubject();                    // <.>
+                List<Pet> pets = petRepository.findByPetOwner(petOwner);
+                pets.forEach(repositoryService::remove);
+                break;
+        }
+    }
+
+    @Inject PetRepository petRepository;
+    @Inject RepositoryService repositoryService;
+}
+----
+<.> events are emitted at different phases.
+The `EXECUTING` phase is fired before the delete action itself is fired, so is the ideal place for us to perform the cascade delete.
+<.> is the mixee of the mixin that is emitting the event.
+
+* create a subscriber in the `visits` module to veto the `PetOwner_delete` if there are any `Pet`s of the `PetOwner` with at least one `Visit`:
++
+[source,java]
+.PetOwnerForVisitsSubscriber.java
+----
+@Service
+public class PetOwnerForVisitsSubscriber {
+
+    @EventListener(PetOwner_delete.ActionEvent.class)
+    public void on(PetOwner_delete.ActionEvent ev) {
+        switch(ev.getEventPhase()) {
+            case DISABLE:
+                PetOwner petOwner = ev.getSubject();
+                List<Pet> pets = petRepository.findByPetOwner(petOwner);
+                for (Pet pet : pets) {
+                    List<Visit> visits = visitRepository.findByPetOrderByVisitAtDesc(pet);
+                    int numVisits = visits.size();
+                    if(numVisits > 0) {
+                        ev.disable(String.format("%s has %d visit%s",
+                                titleService.titleOf(pet),
+                                numVisits,
+                                numVisits != 1 ? "s" : ""));
+                    }
+                }
+                break;
+        }
+    }
+
+    @Inject TitleService titleService;
+    @Inject VisitRepository visitRepository;
+    @Inject PetRepository petRepository;
+}
+----
+
+
+=== Optional Exercise
+
+
+Improve the implementation of `PetOwnerForVisitsSubscriber` so that it performs only a single database query to find if there are any ``Visit`` for the `PetOwner`.
+
+
+
diff --git a/antora/components/tutorials/modules/petclinic/pages/080-view-models.adoc b/antora/components/tutorials/modules/petclinic/pages/080-view-models.adoc
new file mode 100644
index 0000000..4c0bd8f
--- /dev/null
+++ b/antora/components/tutorials/modules/petclinic/pages/080-view-models.adoc
@@ -0,0 +1,297 @@
+= View models
+
+: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 [...]
+
+So far the application consists only of domain entities and domain services.
+However, the framework also supports view models.
+
+A classic use case is to provide a home page or dashboard, but they are also used to represent certain specific business processes when there isn't necessarily a domain entity required to track the state of the process itself.
+Some real-world examples include importing/exporting spreadsheets periodically (eg changes to indexation rates), or generating extracts such as a payment file or summary PDF for an quarterly invoice run.
+
+
+
+[#exercise-8-1-extend-the-home-page]
+== Exercise 8.1: Extend the Home Page.
+
+In this exercise we'll extend the home page by displaying additional data in new collections.
+
+
+=== Solution
+
+[source,bash]
+----
+git checkout tags/08-01-home-page-additional-collections
+mvn clean install
+mvn -pl spring-boot:run
+----
+
+
+=== Tasks
+
+* update `PetRepository` and `VisitRepository` to extend `JpaRepository` (rather than simply `Repository`)
++
+This will provide additional finders "for free".
+
+* modify `HomePageViewModel` to show the current ``PetOwner``s, ``Pet``s and ``Visit``s in three separate columns:
++
+[source,java]
+----
+@DomainObject(
+        nature = Nature.VIEW_MODEL,                             // <.>
+        logicalTypeName = "petclinic.HomePageViewModel"
+        )
+@HomePage                                                       // <.>
+@DomainObjectLayout()
+public class HomePageViewModel {
+
+    public String title() {
+        return getPetOwners().size() + " owners";
+    }
+
+    public List<PetOwner> getPetOwners() {                      // <.>
+        return petOwnerRepository.findAll();
+    }
+    public List<Pet> getPets() {                                // <.>
+        return petRepository.findAll();
+    }
+    public List<Visit> getVisits() {                            // <.>
+        return visitRepository.findAll();
+    }
+
+    @Inject PetOwnerRepository petOwnerRepository;
+    @Inject PetRepository petRepository;
+    @Inject VisitRepository visitRepository;
+}
+----
+<.> indicates that this is a view model.
+<.> exactly one view model can be annotated as the xref:refguide:applib:index/annotation/HomePage.adoc[@HomePage]
+<.> renamed derived collection, returns ``PetOwner``s.
+<.> new derived collection returning all ``Pet``s.
+<.> new derived collection returning all ``Visits``s.
+
+* update the `HomePageViewModel.layout.xml`:
++
+[source,xml]
+.HomePageViewModel.layout.xml
+----
+<!-- ... -->
+    <bs3:row>
+        <bs3:col span="12" unreferencedCollections="true">
+            <bs3:row>
+                <bs3:col span="4">
+                    <collection id="petOwners" defaultView="table"/>
+                </bs3:col>
+                <bs3:col span="4">
+                    <collection id="pets" defaultView="table"/>
+                </bs3:col>
+                <bs3:col span="4">
+                    <collection id="visits" defaultView="table"/>
+                </bs3:col>
+            </bs3:row>
+        </bs3:col>
+    </bs3:row>
+<!-- ... -->
+----
+
+* update or add columnOrder.txt files for the 3 collections.
+
+
+
+[#exercise-8-2-add-a-convenience-action]
+== Exercise 8.2: Add a convenience action
+
+View models can have behaviour (actions), the same as entities.
+In this exercise we'll extend the home page by providing a convenience action to book a `Visit` for any `Pet` of any `PetOwner`.
+
+
+=== Solution
+
+[source,bash]
+----
+git checkout tags/08-02-home-page-bookVisit-convenience-action
+mvn clean install
+mvn -pl spring-boot:run
+----
+
+
+=== Tasks
+
+* create a bookVisit action for HomePageViewModel, as a mixin:
++
+[source,java]
+.HomePageViewModel_bookVisit.java
+----
+@Action                                                                                 // <.>
+@RequiredArgsConstructor
+public class HomePageViewModel_bookVisit {                                              // <.>
+
+    final HomePageViewModel homePageViewModel;
+
+    public Object act(
+            PetOwner petOwner, Pet pet, LocalDateTime visitAt, String reason,
+            boolean showVisit) {                                                               // <.>
+        Visit visit = wrapperFactory.wrapMixin(Pet_bookVisit.class, pet).act(visitAt, reason); // <.>
+        return showVisit ? visit : homePageViewModel;
+    }
+    public List<PetOwner> autoComplete0Act(final String lastName) {                     // <.>
+        return petOwnerRepository.findByLastNameContaining(lastName);
+    }
+    public List<Pet> choices1Act(PetOwner petOwner) {                                   // <.>
+        if(petOwner == null) return Collections.emptyList();
+        return petRepository.findByPetOwner(petOwner);
+    }
+    public LocalDateTime default2Act(PetOwner petOwner, Pet pet) {                      // <.>
+        if(pet == null) return null;
+        return factoryService.mixin(Pet_bookVisit.class, pet).default0Act();
+    }
+    public String validate2Act(PetOwner petOwner, Pet pet, LocalDateTime visitAt) {     // <.>
+         return factoryService.mixin(Pet_bookVisit.class, pet).validate0Act(visitAt);
+    }
+
+    @Inject PetRepository petRepository;
+    @Inject PetOwnerRepository petOwnerRepository;
+    @Inject WrapperFactory wrapperFactory;
+    @Inject FactoryService factoryService;
+}
+----
+<.> declares this class as a mixin action.
+<.> The action name is derived from the mixin's class ("bookVisit").
+<.> cosmetic flag to control the UI; either remain at the home page or navigate to the newly created `Visit
+<.> use the xref:refguide:applib:index/services/wrapper/WrapperFactory.adoc[WrapperFactory] to delegate to the original behaviour "as if" through the UI.
+If additional business rules were added to that delegate, then the mistake would be detected.
+<.> Uses an xref:refguide:applib-methods:prefixes.adoc#autoComplete[autoComplete] supporting method to look up matching ``PetOwner``s based upon their name.
+<.> Finds the ``Pet``s owned by the `PetOwner`, once selected.
+<.> Computes a default for the 2^nd^ parameter, once the first two are selected.
+<.> surfaces (some of) the business rules of the delegate mixin.
+
+* update the layout file to position:
++
+[source,xml]
+.HomePageViewModel.layout.xml
+----
+<!-- ... -->
+    <bs3:row>
+        <bs3:col span="12" unreferencedActions="true">
+            <domainObject/>
+            <action id="bookVisit"/>
+            <!-- ... -->
+        </bs3:col>
+    </bs3:row>
+<!-- ... -->
+----
+
+
+
+[#exercise-8-3-using-a-view-model-as-a-projection-of-an-entity]
+== Exercise 8.3: Using a view model as a projection of an entity
+
+In the home page, the ``Visit`` instances show the `Pet` but they do not show the `PetOwner`.
+One option (probably the correct one in this case) would be to extend `Visit` itself and show this derived information:
+
+[source,java]
+.Visit.java
+----
+public PetOwner getPetOwner() {
+    return getPet().getOwner();
+}
+----
+
+Alternatively, if we didn't want to "pollute" the entity with this derived property, we could use a mixin:
+
+[source,java]
+.Visit_petOwner.java
+----
+@Property
+@RequiredArgsConstructor
+public class Visit_petOwner {
+
+    final Visit visit;
+
+    public PetOwner prop() {
+        return visit.getPet().getOwner();
+    }
+}
+----
+
+Even so, this would still make the "petOwner" property visible everywhere that a `Visit` is displayed.
+
+If we instead want to be more targetted and _only_ show this "petOwner" property when displayed on the HomePage, yet another option is to implement the xref:refguide:applib:index/services/tablecol/TableColumnVisibilityService.adoc[TableColumnVisibilityService] SPI.
+This provides the context for where an object is being rendered, so this could be used to suppress the collection everywhere except the home page.
+
+A final option though, which we'll use in this exercise, is to display not the entity itself but instead a view model that "wraps" the entity and supplements with the additional data required.
+
+
+=== Solution
+
+[source,bash]
+----
+git checkout tags/08-03-view-model-projecting-an-entity
+mvn clean install
+mvn -pl spring-boot:run
+----
+
+
+=== Tasks
+
+* create a JAXB style view model `VisitPlusPetOwner`, wrapping the `Visit` entity:
++
+[source,java]
+.VisitPlusPetOwner.java
+----
+@DomainObject(nature=Nature.VIEW_MODEL, logicalTypeName = "petclinic.VisitPlusPetOwner")
+@DomainObjectLayout(named = "Visit")
+@XmlRootElement                                                     // <.>
+@XmlType                                                            // <1>
+@XmlAccessorType(XmlAccessType.FIELD)                               // <1>
+@NoArgsConstructor
+public class VisitPlusPetOwner {
+
+    @Property(
+            projecting = Projecting.PROJECTED,                      // <.>
+            hidden = Where.EVERYWHERE                               // <.>
+    )
+    @Getter
+    private Visit visit;
+
+    VisitPlusPetOwner(Visit visit) {this.visit = visit;}
+
+    public Pet getPet() {return visit.getPet();}                    // <.>
+    public String getReason() {return visit.getReason();}           // <4>
+    public LocalDateTime getVisitAt() {return visit.getVisitAt();}  // <4>
+
+    public PetOwner getPetOwner() {                                 // <.>
+        return getPet().getPetOwner();
+    }
+}
+----
+<.> Boilerplate for JAXB view models
+<.> if the icon/title is clicked, then traverse to this object rather than the view model.
+(The view model is a "projection" of the underlying `Visit`).
+<.> Nevertheless, hide this property from the UI.
+<.> expose properties from the underlying `Visit` entity
+<.> add in additional derived properties, in this case the ``Pet``'s owner.
+
+* Refactor the `getVisits` collection of `HomePageViewModel` to use the new view model:
++
+[source,java]
+.VisitPlusPetOwner.java
+----
+public List<VisitPlusPetOwner> getVisits() {
+    return visitRepository.findAll()
+            .stream()
+            .map(VisitPlusPetOwner::new)
+            .collect(Collectors.toList());
+}
+----
+
+* update the columnOrder file for this collection to display the new property:
++
+[source,java]
+.HomePageViewModel#visits.columnOrder.txt
+----
+petOwner
+pet
+visitAt
+----
+
+Run the application; the `visits` collection on the home page should now show the `PetOwner` as an additional column, but otherwise behaves the same as previously.
diff --git a/antora/components/tutorials/modules/petclinic/pages/090-integration-testing.adoc b/antora/components/tutorials/modules/petclinic/pages/090-integration-testing.adoc
new file mode 100644
index 0000000..aad77d1
--- /dev/null
+++ b/antora/components/tutorials/modules/petclinic/pages/090-integration-testing.adoc
@@ -0,0 +1,206 @@
+= Integration Testing
+
+: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 [...]
+
+In an earlier section of this tutorial we looked at unit testing, but integration tests are at least as important, probably more so, as they exercise the entire application from an end-users perspective, rather than an individual part.
+
+Integration tests are _not_ written using Selenium or similar, so avoid the fragility and maintenance effort that such tests often entail.
+Instead, the framework provides the xref:refguide:applib:index/services/wrapper/WrapperFactory.adoc[WrapperFactory] domain service which simulates the user interface in a type-safe way.
+
+
+== Exercuse 9.1: Testing bookVisit using an integtest
+
+In this exercise we'll test the `bookVisit` mixin action (on `Pet` entity).
+
+
+=== Solution
+
+[source,bash]
+----
+git checkout tags/09-01-bookVisit-integ-test
+mvn clean package jetty:run
+----
+
+=== Tasks
+
+* in the `pom.xml` of the visits module, add the following dependency:
++
+[source,xml]
+.module-visits/pom.xml
+----
+<dependency>
+    <groupId>org.apache.isis.mavendeps</groupId>
+    <artifactId>isis-mavendeps-integtests</artifactId>
+    <type>pom</type>
+    <scope>test</scope>
+</dependency>
+----
+
+* add an abstract class `VisitsModuleIntegTestAbstract` for the `visits` module, for other integ tests to subclass:
++
+[source,java]
+.VisitsModuleIntegTestAbstract.java
+----
+@SpringBootTest(
+        classes = VisitsModuleIntegTestAbstract.TestApp.class
+)
+@ActiveProfiles("test")
+public abstract class VisitsModuleIntegTestAbstract
+        extends IsisIntegrationTestAbstractWithFixtures {
+
+    @SpringBootConfiguration
+    @EnableAutoConfiguration
+    @Import({
+
+            IsisModuleCoreRuntimeServices.class,
+            IsisModuleSecurityBypass.class,
+            IsisModulePersistenceJpaEclipselink.class,
+            IsisModuleTestingFixturesApplib.class,
+
+            VisitsModule.class
+    })
+    @PropertySources({
+            @PropertySource(IsisPresets.H2InMemory_withUniqueSchema),
+            @PropertySource(IsisPresets.UseLog4j2Test),
+    })
+    public static class TestApp {
+    }
+}
+----
+
+* also update the `application-test.yml` file for the `visits` module, to ensure that the database schemas for both modules are created:
++
+[source,yaml]
+.module-visits/src/test/resources/application-test.yml
+----
+isis:
+  persistence:
+    schema:
+      auto-create-schemas: pets,visits
+----
+
+* add a class `Bootstrap_IntegTest` integration test, inheriting from the `VisitsModuleIntegTestAbstract:
++
+[source,java]
+.Bootstrap_IntegTest.java
+----
+public class Bootstrap_IntegTest extends VisitsModuleIntegTestAbstract {
+
+    @Test
+    public void checks_can_bootstrap() {}
+}
+----
++
+Make sure this test runs and passes in both the IDE and using "mvn clean install".
+
+
+Now we can write our actual test:
+
+* Now add a class `Pet_bookVisit_IntegTest` integration test, also inheriting from the `VisitsModuleIntegTestAbstract:
++
+[source,java]
+.Pet_bookVisit_IntegTest.java
+----
+public class Pet_bookVisit_IntegTest extends VisitsModuleIntegTestAbstract {
+
+    @BeforeEach
+    void setup() {
+        fixtureScripts.run(new Pet_persona.PersistAll());                       // <.>
+    }
+
+    @Test
+    public void happy_case() {
+
+        // given
+        Pet somePet = fakeDataService.enums().anyOf(Pet_persona.class)          // <.>
+                        .findUsing(serviceRegistry);
+        List<Visit> before = visitRepository.findByPetOrderByVisitAtDesc(somePet);
+
+        // when
+        LocalDateTime visitAt = clockService.getClock().nowAsLocalDateTime()    // <.>
+                                    .plusDays(fakeDataService.ints().between(1, 3));
+        String reason = fakeDataService.strings().upper(40);                    // <3>
+
+        wrapMixin(Pet_bookVisit.class, somePet).act(visitAt, reason);           // <.>
+
+        // then
+        List<Visit> after = visitRepository.findByPetOrderByVisitAtDesc(somePet);
+        after.removeAll(before);
+        assertThat(after).hasSize(1);                                           // <.>
+        Visit visit = after.get(0);
+
+        assertThat(visit.getPet()).isSameAs(somePet);                           // <.>
+        assertThat(visit.getVisitAt()).isEqualTo(visitAt);                      // <6>
+        assertThat(visit.getReason()).isEqualTo(reason);                        // <6>
+    }
+
+    @Inject FakeDataService fakeDataService;
+    @Inject VisitRepository visitRepository;
+    @Inject ClockService clockService;
+
+}
+----
+<.> uses same fixture script used for prototyping to set up ``Pet``s and their ``PetOwner``s.
+<.> uses the xref:refguide:testing:index/fakedata/applib/services/FakeDataService.adoc[FakeDataService] to select a `Pet` persona at random and uses that person to look up the corresponding domain object.
+<.> sets up some randomised but valid argument values
+<.> invokes the action, using the xref:refguide:applib:index/services/wrapper/WrapperFactory.adoc[WrapperFactory] to simulate the UI
+<.> asserts that one new `Visit` has been created for the `Pet`.
+<.> asserts that the state of this new `Visit` is correct
++
+Run the test and check that it passes.
+
+* write an error scenario which checks that a reason has been provided:
++
+[source,java]
+.Pet_bookVisit_IntegTest.java
+----
+@Test
+public void reason_is_required() {
+
+    // given
+    Pet somePet = fakeDataService.enums().anyOf(Pet_persona.class)
+                    .findUsing(serviceRegistry);
+    List<Visit> before = visitRepository.findByPetOrderByVisitAtDesc(somePet);
+
+    // when, then
+    LocalDateTime visitAt = clockService.getClock().nowAsLocalDateTime()
+                                .plusDays(fakeDataService.ints().between(1, 3));
+
+    assertThatThrownBy(() ->
+        wrapMixin(Pet_bookVisit.class, somePet).act(visitAt, null)
+    )
+    .isInstanceOf(InvalidException.class)
+    .hasMessage("'Reason' is mandatory");
+}
+----
+
+* write an error scenario which checks that the `visitAt` date cannot be in the past:
++
+[source,java]
+.Pet_bookVisit_IntegTest.java
+----
+@Test
+public void cannot_book_in_the_past() {
+
+    // given
+    Pet somePet = fakeDataService.enums().anyOf(Pet_persona.class)
+            .findUsing(serviceRegistry);
+    List<Visit> before = visitRepository.findByPetOrderByVisitAtDesc(somePet);
+
+    // when, then
+    LocalDateTime visitAt = clockService.getClock().nowAsLocalDateTime();
+    String reason = fakeDataService.strings().upper(40);
+
+    assertThatThrownBy(() ->
+            wrapMixin(Pet_bookVisit.class, somePet).act(visitAt, reason)
+    )
+            .isInstanceOf(InvalidException.class)
+            .hasMessage("Must be in the future");
+}
+----
+
+*
+
+
+
+
diff --git a/antora/components/tutorials/modules/petclinic/pages/110-adding-further-business-logic-worked-examples.adoc b/antora/components/tutorials/modules/petclinic/pages/110-adding-further-business-logic-worked-examples.adoc
new file mode 100644
index 0000000..2065caa
--- /dev/null
+++ b/antora/components/tutorials/modules/petclinic/pages/110-adding-further-business-logic-worked-examples.adoc
@@ -0,0 +1,707 @@
+= Adding further business logic - Worked Examples
+
+: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 [...]
+
+
+Let's remind ourselves of the original use cases we identified; some of these have been implemented already (admittedly, not all with tests around them):
+
+* create an `PetOwner` : yes, implemented
+
+* add and remove ``Pet``s for said `PetOwner` : yes, implemented.
+
+* book a `Pet` in for a `Visit`: yes, implemented.
+
+* enter an `outcome` and `cost` of a `Visit`: not yet
+
+* allow an `PetOwner` to pay for a `Visit`: not yet
+
+* find ``Visit``s not yet paid and overdue (more than 28 days old): not yet
+
+* delete an `PetOwner` and its ``Pet``s and ``Visit``s, so long as there are no unpaid ``Visit``s: partly.
+We currently just delete everything.
+
+In this section we'll implement the missing functionality, along with unit or integration tests as necessary.
+
+
+== Enter an outcome
+
+An outcome for a `Visit` consists of a diagnosis, and also the cost to be paid by the ``Pet``'s `PetOwner`.
+
+image::Visit-enterOutcome.png[width="800px",link="_images/Visit-enterOutcome.png"]
+
+=== Solution
+
+[source,bash]
+----
+git checkout tags/330-enter-an-outcome
+mvn clean package jetty:run
+----
+
+
+=== Exercise
+
+* add a new integration test, `Visit_enterOutcome_IntegTest`,
++
+[source,java]
+----
+public class Visit_enterOutcome_IntegTest extends PetClinicModuleIntegTestAbstract {
+
+    Visit visit;
+
+    @Before
+    public void setup() {
+        // given
+        Owner owner = runBuilderScript(Owner_enum.JOHN_SMITH);
+        Pet pet = owner.getPets().first();
+        visit = wrap(mixin(Pet_visits.class, pet)).coll().iterator().next();
+    }
+
+    @Test
+    public void happy_case() {
+
+        // when
+        String diagnosis = someRandomDiagnosis();
+        BigDecimal cost = someRandomCost();
+
+        wrap(visit).enterOutcome(diagnosis, cost);
+
+        // then
+        assertThat(visit.getDiagnosis()).isEqualTo(diagnosis);
+        assertThat(visit.getCost()).isEqualTo(cost);
+    }
+
+    private BigDecimal someRandomCost() {
+        return new BigDecimal(20.00 + fakeDataService.doubles().upTo(30.00d));
+    }
+
+    private String someRandomDiagnosis() {
+        return fakeDataService.lorem().paragraph(3);
+    }
+
+    @Inject
+    FakeDataService fakeDataService;
+}
+----
+
+* in `Visit`, add in the two new properties and action.
++
+[source,java]
+----
+@Action(semantics = SemanticsOf.IDEMPOTENT)
+public Visit enterOutcome(
+        @Parameter(maxLength = 4000)
+        @ParameterLayout(multiLine = 5)
+        final String diagnosis,
+        final BigDecimal cost) {
+    this.diagnosis = diagnosis;
+    this.cost = cost;
+    return this;
+}
+
+@javax.jdo.annotations.Column(allowsNull = "true", length = 4000)
+@Property(editing = Editing.DISABLED, editingDisabledReason = "Use 'enter outcome' action")
+@PropertyLayout(multiLine = 5)
+@Getter @Setter
+private String diagnosis;
+
+@javax.jdo.annotations.Column(allowsNull = "true", length = 6, scale = 2)
+@Property(editing = Editing.DISABLED, editingDisabledReason = "Use 'enter outcome' action")
+@Getter @Setter
+private BigDecimal cost;
+
+----
+
+* update `Visit.layout.xml` for the two new properties and action.
+
+* add in some further integration tests to ensure that the properties cannot be edited directly:
++
+[source,java]
+----
+@Test
+public void cannot_edit_outcome_directly() {
+
+    // expecting
+    expectedExceptions.expect(DisabledException.class);
+    expectedExceptions.expectMessage("Use 'enter outcome' action");
+
+    // when
+    String diagnosis = someRandomDiagnosis();
+    wrap(visit).setDiagnosis(diagnosis);
+}
+
+@Test
+public void cannot_edit_cost_directly() {
+
+    // expecting
+    expectedExceptions.expect(DisabledException.class);
+    expectedExceptions.expectMessage("Use 'enter outcome' action");
+
+    // when
+    BigDecimal cost = someRandomCost();
+
+    wrap(visit).setCost(cost);
+}
+----
+
+
+== Pay for a visit
+
+We'll support this use case through a new action "paid", on the `Visit` domain entity.
+
+To support the testing (and with half an eye to a future use case) we'll also implement a "findNotPaid" query on the `Visits` repository domain service.
+
+=== Solution
+
+[source,bash]
+----
+git checkout tags/340-pay-for-a-visit
+mvn clean package jetty:run
+----
+
+
+=== Exercise
+
+Let's first work on the happy case:
+
+* Update `Visit` with a new `paid()` action and `paidOn` property.
+Also inject `ClockService`:
++
+[source,java]
+----
+@Action(semantics = SemanticsOf.IDEMPOTENT)
+public Visit paid() {
+    paidOn = clockService.now();
+    return this;
+}
+
+@javax.jdo.annotations.Column(allowsNull = "true")
+@Property(editing = Editing.DISABLED, editingDisabledReason = "Use 'paid' action")
+@Getter @Setter
+private LocalDate paidOn;
+
+...
+
+@Inject
+ClockService clockService;
+----
+
+* Update the `Visits` domain service repository to find ``Visit``s that haven't been paid:
++
+[source,java]
+----
+@Programmatic
+public java.util.List<Visit> findNotPaid() {
+    TypesafeQuery<Visit> q = isisJdoSupport.newTypesafeQuery(Visit.class);
+    final QVisit cand = QVisit.candidate();
+    q = q.filter(
+            cand.paidOn.eq(q.parameter("paidOn", LocalDateTime.class)
+        )
+    );
+    return q.setParameter("paidOn", null)
+            .executeList();
+}
+----
+
+* Extend `PetOwnerBuilderScript` so that all but the last `Visit` for each ``PetOwner``'s ``Pet``s has been paid.
++
+Add some further supporting methods:
++
+[source,java]
+----
+private String someDiagnosis() {
+    return fakeDataService.lorem().paragraph(fakeDataService.ints().between(1, 3));
+}
+
+private BigDecimal someCost() {
+    return new BigDecimal(20.00 + fakeDataService.doubles().upTo(30.00d));
+}
+----
++
+In the `execute(...)`, update the `for` loop so that all ``Visit``s have an outcome and all but the last (for each ``PetOwner``) has been paid:
++
+[source,java]
+----
+for (int i = 0; i < petDatum.numberOfVisits; i++) {
+    ...
+    LocalDateTime someTimeInPast = ...
+    Visit visit = ...
+    wrap(visit).enterOutcome(someDiagnosis(), someCost());
+    if(i != petDatum.numberOfVisits - 1) {
+        setTimeTo(ec, someTimeInPast.plusDays(fakeDataService.ints().between(10,30)));
+        wrap(visit).paid();
+    }
+}
+----
+
+
+== Prevent payment for a visit twice
+
+We've already seen that it's possible to validate arguments to actions; for example that a `Visit` can only be booked in the future.
+But if a `Visit` has already been paid for, then we don't want the user to be able to even attempt to invoke the action.
+
+The framework provides three different types of pre-condition checks:
+
+* "See it?" - should the action/property be visible at all, or has it been hidden?
+
+* "Use it" - if visible, then can the action/property be used or has it been disabled (greyed out)
+
+* "Do it" - if the action/property is ok to be used (action invoked/property edited) then are the proposed action arguments or new property value valid, or are they invalid?
+
+Or in other words, "see it, use it, do it".
+
+As with validation, disablement can be defined either declaratively (annotations) or imperatively (supporting methods).
+Let's see how an imperative supporting method can be used to implement this particular requirement (that a visit can't be paid for twice).
+
+=== Solution
+
+[source,bash]
+----
+git checkout tags/350-prevent-payment-for-a-visit-twice
+mvn clean package jetty:run
+----
+
+
+=== Exercise
+
+* update `Visit_pay_IntegTest` to ensure cannot enter into the `paidOn` property directly:
++
+[source,java]
+----
+@Test
+public void cannot_edit_paidOn_directly() {
+
+    // expecting
+    expectedExceptions.expect(DisabledException.class);
+    expectedExceptions.expectMessage("Use 'paid on' action");
+
+    // when
+    wrap(visit).setPaidOn(clockService.now());
+}
+----
+
+* now, add in the test that asserts that a `Visit` cannot be paid more than once:
++
+[source,java]
+----
+@Test
+public void cannot_pay_more_than_once() {
+
+    // given
+    wrap(visit).paid();
+    assertThat(visits.findNotPaid()).asList().doesNotContain(visit);
+
+    // expecting
+    expectedExceptions.expect(DisabledException.class);
+    expectedExceptions.expectMessage("Already paid");
+
+    // when
+    wrap(visit).paid();
+}
+----
+
+* and finally update `Visit`.
+This is done using a supporting method.
++
+[source,java]
+----
+public String disablePaid() {
+    return getPaidOn() != null ? "Already paid": null;
+}
+----
+
+
+== Find ``Visit``s not yet paid and overdue
+
+In the previous scenario we implemented `Visits#findNotPaid()`.
+Since this is pretty important information, let's surface that to the end-user by adding it to the home page dashboard.
+
+We could also go a little further by allowing the user to use the dashboard to update visits that have been paid.
+This is a good example of how a view model can support specific business processes, in this case saving the end-user from having to navigate down to each and every one of the ``Visit``s.
+
+=== Solution
+
+[source,bash]
+----
+git checkout tags/360-find-visits-not-yet-paid-and-overdue
+mvn clean package jetty:run
+----
+
+image::Dashboard-overdue.png[width="800px",link="_images/Dashboard-overdue.png"]
+
+=== Exercise
+
+
+* update `Dashboard`:
++
+[source,java]
+----
+@CollectionLayout(defaultView = "table")
+public List<Visit> getOverdue() {
+    List<Visit> notPaid = visits.findNotPaid();
+    LocalDateTime thirtyDaysAgo = clockService.nowAsLocalDateTime().minusDays(30);
+    return notPaid.stream()
+            .filter(x -> x.getVisitAt().isBefore(thirtyDaysAgo))        // <1>
+            .collect(Collectors.toList());
+}
+
+@Action(semantics = SemanticsOf.IDEMPOTENT, associateWith = "overdue")  // <2>
+public Dashboard paid(List<Visit> visits) {
+    for (Visit visit : visits) {
+        if(visit.getPaidOn() == null) {
+            visit.paid();
+        }
+    }
+    return this;
+}
+
+@javax.inject.Inject
+Visits visits;
+
+@javax.inject.Inject
+ClockService clockService;
+----
+<1> An alternative (better?) design would have been to add a new query method in `Visits` to find those overdue, avoiding the client-side filtering that we see above.
+<2> The "associateWith" annotation results in checkboxes alongside the "overdue" collection, with the collection providing the set of values for the parameter.
+
+
+* update `Dashboard.layout.xml` also
+
+* write a new `Dashboard_paid_IntegTest` integration test:
++
+[source,java]
+----
+public class Dashboard_paid_IntegTest extends PetClinicModuleIntegTestAbstract {
+
+    Dashboard dashboard;
+
+    @Before
+    public void setup() {
+        // given
+        runFixtureScript(new PersonaEnumPersistAll<>(Owner_enum.class));
+        dashboard = homePageProvider.dashboard();
+    }
+
+    @Test
+    public void happy_case() {
+
+        // given
+        List<Visit> overdue = dashboard.getOverdue();
+        assertThat(overdue).isNotEmpty();
+
+        // when
+        wrap(dashboard).paid(overdue);
+
+        // then
+        List<Visit> overdueAfter = dashboard.getOverdue();
+        assertThat(overdueAfter).isEmpty();
+
+        for (Visit visit : overdue) {
+            assertThat(visit.getDiagnosis()).isNotNull();
+            assertThat(visit.getPaidOn()).isNotNull();
+        }
+    }
+
+    @Inject
+    HomePageProvider homePageProvider;
+}
+----
+
+* Running the integration test at this point will produce a null pointer exception.
+That's because the framework has had no opportunity to inject any domain services into the `Dashboard`.
++
+Under normal runtime cases this doesn't matter because the only caller of the method is the framework itself, and when the domain object is rendered the framework will automatically ensure that any domain sevices are injected.
++
+In an integration test this doesn't occur, and so we need to manually inject the services.
+It makes most sense to do this in `HomePageProvider`; we use the framework-provided `ServiceRegistry2` domain service:
++
+[source,java]
+----
+@HomePage
+public Dashboard dashboard() {
+    return serviceRegistry2.injectServicesInto(new Dashboard());
+}
+@Inject
+ServiceRegistry2 serviceRegistry2;
+----
+
+
+
+== Digression: Hiding Columns in Tables
+
+We could improve the dashboard a little.
+After all, in the "overdue" collection there's no point in showing the "paidOn"; the value will always be null.
+Also, the "reason" column is also somewhat superfluous (as, arguably, is the "diagnosis" column):
+
+image::Dashboard-overdue-ui-hints.png[width="800px",link="_images/Dashboard-overdue-ui-hints.png"]
+
+The framework offers two different ways to address this, so we'll show both.
+
+=== Solution
+
+[source,bash]
+----
+git checkout tags/370-digression-hiding-columns-in-tables
+mvn clean package jetty:run
+----
+
+
+=== Exercise
+
+* The first technique is within the Java code; one could think of this as an implication within the "application layer".
++
+We use a domain service that implements `TableColumnOrderService` as an SPI to "advise" the framework on how to render the collection.
+Traditionally such classes are implemented as a nested static class, in this case of `Dashboard`:
++
+[source,java]
+----
+@DomainService(nature = NatureOfService.DOMAIN)
+public static class RemovePaidOnFromOverdue extends TableColumnOrderService.Default {
+    @Override
+    public List<String> orderParented(
+            final Object parent,
+            final String collectionId,
+            final Class<?> collectionType,
+            final List<String> propertyIds) {
+        if (parent instanceof Dashboard && "overdue".equalsIgnoreCase(collectionId)) {
+            propertyIds.remove("paidOn");
+        }
+        return propertyIds;
+    }
+}
+----
++
+The above code removes the "paidOn" column.
+
+* The second technique is to exploit the fact that the HTML generated by the framework is liberally annotated with domain class identifiers.
+The column can therefore be removed by supplying the appropriate CSS.
+We could think of this as an implementation within the presentation layer.
++
+In the `src/main/webapp/css/application.css` file, add:
++
+[source,css]
+----
+.domainapp-modules-impl-dashboard-Dashboard .entityCollection .overdue .Visit-reason {
+    display: none;
+}
+----
+
+
+
+== Another Digression: Icons and CSS
+
+In the same way that titles can be specified imperatively, so too can icons, using the `iconName()` method.
+One use case is for a domain object that has several states: the `iconName()` defines a suffix which is used to lookup different icons (eg "ToDoItem-notDone.png" and "ToDoItem-done.png").
+
+Similarly, it's possible to specify CSS hints imperatively using the `cssClass()`.
+This returns a simple string that is added as a CSS class wherever the object is rendered in the UI.
+
+In this exercise we'll use a different icon for the various species of `Pet`:
+
+image::Pet-icons.png[width="800px",link="_images/Pet-icons.png"]
+
+Let's also use a strike-through text for all ``Visit``s that are paid when rendered within a collection:
+
+image::Visits-paid-strikethrough.png[width="800px",link="_images/Visits-paid-strikethrough.png"]
+
+
+
+
+=== Solution
+
+[source,bash]
+----
+git checkout tags/380-another-digression-icons-and-css
+mvn clean package jetty:run
+----
+
+
+
+=== Exercise
+
+For the icons:
+
+* add new icons for each of the pet species: `Pet-dog.png`, `Pet-cat.png`, `Pet-hamster.dog` and `Pet-budgerigar.png`
+
+* add an `iconName()` method to `Pet`:
++
+[source,java]
+----
+public String iconName() {
+    return getPetSpecies().name().toLowerCase();
+}
+----
+
+
+For the CSS class:
+
+* add a `cssClass()` method to `Visit`:
++
+[source,java]
+----
+public String cssClass() {
+    boolean isPaid = getPaidOn() != null;
+    return isPaid ? "paid": null;
+}
+----
+
+
+* update `application.css`:
+
+[source,css]
+----
+.entityCollection .domainapp-modules-impl-visits-dom-Visit .paid {
+    text-decoration: line-through;
+    color: lightgrey;
+}
+----
+
+
+== Delete an `PetOwner` provided no unpaid ``Visit``s
+
+=== Solution
+
+[source,bash]
+----
+git checkout tags/390-delete-an-owner-provided-no-unpaid-visits
+mvn clean package jetty:run
+----
+
+
+=== Exercise
+
+We don't want `PetOwner` (in the `pets` module) to check for unpaid ``Visit``s, because that would create a cyclic dependency between modules.
+Instead, we'll use a subscriber in the `visits` module which can veto any attempt to delete an owner if there are unpaid visits.
+
+For this, we arrange for the `PetOwner` to emit an action domain event when its `delete()` action is invoked.
+In fact, the event will be emitted by the framework up to five times: to check if the action is visible, if it is disabled, if it's valid, pre-execute and post-execute.
+The subscriber in the ``visits`` module will therefore potentially veto on the disable phase.
+
+* in the `Visits` repository, add `findNotPaidBy` method to find any unpaid ``Visit``s for an `PetOwner`:
++
+[source,java]
+----
+@Programmatic
+public java.util.List<Visit> findNotPaidBy(Owner owner) {
+    TypesafeQuery<Visit> q = isisJdoSupport.newTypesafeQuery(Visit.class);
+    final QVisit cand = QVisit.candidate();
+    q = q.filter(
+            cand.paidOn.eq(q.parameter("paidOn", LocalDateTime.class)
+        ).and(
+                cand.pet.owner.eq(q.parameter("owner", Owner.class))
+            )
+    );
+    return q.setParameter("paidOn", null)
+            .setParameter("owner", owner)
+            .executeList();
+}
+----
+
+* update `PetOwner`'s `delete()` action so that it emits an action domain event.
++
+[source,java]
+----
+import org.apache.isis.applib.services.eventbus.ActionDomainEvent;
+...
+public static class Delete extends ActionDomainEvent<Owner> {}  // <1>
+@Action(
+        domainEvent = Delete.class                              // <2>
+        semantics = SemanticsOf.NON_IDEMPOTENT                  // <3>
+)
+public void delete() {
+    final String title = titleService.titleOf(this);
+    messageService.informUser(String.format("'%s' deleted", title));
+    repositoryService.removeAndFlush(this);
+}
+----
+<1> declare the event, and
+<2> emit it
+<3> change from `NON_IDEMPOTENT_ARE_YOU_SURE` (due to a bug in the framework).
+
+* add a new integration test:
++
+[source,java]
+----
+public class Owner_delete_IntegTest extends PetClinicModuleIntegTestAbstract {
+
+    @Test
+    public void can_delete_if_there_are_no_unpaid_visits() {
+
+        // given
+        runFixtureScript(Owner_enum.FRED_HUGHES.builder());
+
+        Owner owner = Owner_enum.FRED_HUGHES.findUsing(serviceRegistry);
+        List<Visit> any = visits.findNotPaidBy(owner);
+        assertThat(any).isEmpty();
+
+        // when
+        wrap(owner).delete();
+
+        // then
+        Owner ownerAfter = Owner_enum.FRED_HUGHES.findUsing(serviceRegistry);
+        assertThat(ownerAfter).isNull();
+    }
+
+    @Test
+    public void cannot_delete_with_unpaid_visits() {
+
+        // given
+        runFixtureScript(Owner_enum.MARY_JONES.builder());
+
+        Owner owner = Owner_enum.MARY_JONES.findUsing(serviceRegistry);
+        List<Visit> any = visits.findNotPaidBy(owner);
+        assertThat(any).isNotEmpty();
+
+        // expect
+        expectedExceptions.expect(DisabledException.class);
+        expectedExceptions.expectMessage("This owner still has unpaid visit(s)");
+
+        // when
+        wrap(owner).delete();
+    }
+
+    @Inject
+    Visits visits;
+}
+----
+
+* add the subscriber to veto the action if required:
++
+[source,java]
+----
+@DomainService(nature = NatureOfService.DOMAIN)
+public class VetoDeleteOfOwnerWithUnpaidVisits
+        extends org.apache.isis.applib.AbstractSubscriber {
+
+    @org.axonframework.eventhandling.annotation.EventHandler
+    public void on(Owner.Delete ev) {
+
+        switch (ev.getEventPhase()) {
+        case DISABLE:
+            Collection<Visit> visitsForPet = visits.findNotPaidBy(ev.getSource());
+            if (!visitsForPet.isEmpty()) {
+                ev.veto("This owner still has unpaid visit(s)");
+            }
+            break;
+        }
+    }
+
+    @javax.inject.Inject
+    Visits visits;
+}
+----
+
+* finally, in `PetClinicModuleIntegTestAbstract`, we need to make a small adjustment to use the same event bus implementation as the production app:
++
+[source,java]
+----
+super(new PetClinicModule()
+    .withAdditionalServices(DeploymentCategoryProviderForTesting.class)
+    .withConfigurationProperty("isis.services.eventbus.implementation","axon")      // <1>
+    .withConfigurationProperty(TranslationServicePo.KEY_PO_MODE, "write")
+);
+----
+<1> specify Axon as the event bus implementation
+
+
+
+
diff --git a/antora/components/tutorials/modules/petclinic/pages/about.adoc b/antora/components/tutorials/modules/petclinic/pages/about.adoc
new file mode 100644
index 0000000..4f003c8
--- /dev/null
+++ b/antora/components/tutorials/modules/petclinic/pages/about.adoc
@@ -0,0 +1,9 @@
+= Petclinic
+
+: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 [...]
+
+This tutorial develops a variant of the venerable "pet clinic" demo app, broken up into a set of exercises.
+
+To guide you through those exercises, we've provided a git repo (https://github.com/apache/isis-app-demo) with a set of tags, each tag representing the solution to one exercise and the starting point to the next.
+
+
diff --git a/antora/components/tutorials/modules/petclinic/pages/architecture-rules.adoc b/antora/components/tutorials/modules/petclinic/pages/architecture-rules.adoc
new file mode 100644
index 0000000..257a961
--- /dev/null
+++ b/antora/components/tutorials/modules/petclinic/pages/architecture-rules.adoc
@@ -0,0 +1,6 @@
+= architecture rules
+
+: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 [...]
+
+TODO.
+
diff --git a/antora/components/tutorials/modules/petclinic/pages/i18n.adoc b/antora/components/tutorials/modules/petclinic/pages/i18n.adoc
new file mode 100644
index 0000000..e289832
--- /dev/null
+++ b/antora/components/tutorials/modules/petclinic/pages/i18n.adoc
@@ -0,0 +1,6 @@
+= i18n
+
+: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 [...]
+
+TODO.
+
diff --git a/antora/components/tutorials/modules/petclinic/partials/domain.adoc b/antora/components/tutorials/modules/petclinic/partials/domain.adoc
new file mode 100644
index 0000000..87641da
--- /dev/null
+++ b/antora/components/tutorials/modules/petclinic/partials/domain.adoc
@@ -0,0 +1,60 @@
+
+[plantuml]
+----
+include::partial$skinparam.adoc[]
+
+package pets {
+
+    enum PetSpecies <<desc>> {
+        Dog
+        Cat
+        Hamster
+        Budgerigar
+    }
+
+    class Pet <<ppt>> {
+        +id
+        ..
+        #petOwner
+        #name
+        ..
+        -species
+        -notes
+        ..
+        -version
+    }
+
+
+    class PetOwner <<role>> {
+        +id
+        ..
+        #lastName
+        #firstName
+        ..
+        -phoneNumber
+        -emailAddress
+    }
+}
+
+
+package visits {
+
+    class Visit <<mi>> {
+        +id
+        ..
+        #pet
+        #visitAt: LocalDateTime
+        ..
+        -reason
+        ..
+        -cost
+        -paid: boolean
+        -outcome
+    }
+}
+
+
+PetOwner *-r--> "0..*" Pet
+Visit "   \n*" -r->  Pet
+Pet  "*" -u-> PetSpecies
+----
diff --git a/antora/components/tutorials/modules/petclinic/partials/skinparam.adoc b/antora/components/tutorials/modules/petclinic/partials/skinparam.adoc
new file mode 100644
index 0000000..cc0b9dd
--- /dev/null
+++ b/antora/components/tutorials/modules/petclinic/partials/skinparam.adoc
@@ -0,0 +1,11 @@
+
+hide empty members
+hide methods
+
+skinparam class {
+	BackgroundColor<<desc>> Cyan
+	BackgroundColor<<ppt>> LightGreen
+	BackgroundColor<<mi>> LightPink
+	BackgroundColor<<role>> LightYellow
+}
+
diff --git a/antora/components/userguide/modules/btb/pages/hints-and-tips/transactions-and-errors.adoc b/antora/components/userguide/modules/btb/pages/hints-and-tips/transactions-and-errors.adoc
index 5087bd6..2912dc7 100644
--- a/antora/components/userguide/modules/btb/pages/hints-and-tips/transactions-and-errors.adoc
+++ b/antora/components/userguide/modules/btb/pages/hints-and-tips/transactions-and-errors.adoc
@@ -17,7 +17,7 @@ For example:
 
 [source,java]
 ----
-public class SomeLongRunningFixtureScript extends FixtureScript
+public class SomeLongRunningFixtureScript extends FixtureScript {
 
     protected void execute(final ExecutionContext executionContext) {
         // do some work
diff --git a/antora/components/userguide/modules/fun/pages/ui/object-layout.adoc b/antora/components/userguide/modules/fun/pages/ui/object-layout.adoc
index 6b99ce5..314b657 100644
--- a/antora/components/userguide/modules/fun/pages/ui/object-layout.adoc
+++ b/antora/components/userguide/modules/fun/pages/ui/object-layout.adoc
@@ -251,6 +251,7 @@ All of the semantics in these layout annotations can also be specified in the `.
 In addition, xref:refguide:applib:index/annotation/ParameterLayout.adoc[@ParameterLayout] provides layout hints for action parameters.
 There is no way to specify these semantics in the `.layout.xml` file (action parameters are not enumerated in the file).
 
+[#layout-file-styles]
 === Layout file styles
 
 NOTE: TODO v2 - Style has been changed (WIP)
diff --git a/antora/playbooks/site-tutorials.yml b/antora/playbooks/site-tutorials.yml
new file mode 100644
index 0000000..0c7ef78
--- /dev/null
+++ b/antora/playbooks/site-tutorials.yml
@@ -0,0 +1,86 @@
+#  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.
+
+site:
+  title: Apache Isis
+  url: https://isis.apache.org/
+  start_page: docs:ROOT:about.adoc
+
+content:
+  sources:
+
+# antora
+    - url: .
+      start_path: antora/components/docs # docs
+      branches: HEAD
+    - url: .
+      start_path: antora/components/tutorials # tutorials
+      branches: HEAD
+# examples
+    - url: .
+      start_path: examples/demo/domain/src/main/adoc # docs
+      branches: HEAD
+
+# mavendeps
+    - url: .
+      start_path: mavendeps/adoc # docs
+      branches: HEAD
+
+# starters
+    - url: .
+      start_path: starters/adoc # docs
+      branches: HEAD
+
+# tooling
+    - url: .
+      start_path: tooling/adoc # toc
+      branches: HEAD
+    - url: .
+      start_path: tooling/cli/adoc # cli
+      branches: HEAD
+
+
+
+
+ui:
+  bundle:
+#    url: ../isis-antora/build/ui-bundle.zip
+    url: https://github.com/apache/isis-antora/blob/master/build/ui-bundle.zip?raw=true
+    snapshot: true
+  supplemental_files: antora/supplemental-ui
+
+asciidoc:
+  extensions:
+    - asciidoctor-kroki # requires: npm i ... @asciidoctor/core@2.2.0 asciidoctor-kroki
+#    - asciidoctor-plantuml # requires: npm i ... asciidoctor-plantuml@1.5.0
+    - ./antora/lib/lorem
+    - ./antora/lib/jira-issue
+  attributes:
+    experimental: ""
+    idprefix: ""
+    idseparator: "-"
+    # kroki
+    # for more options see https://github.com/Mogztter/asciidoctor-kroki
+    kroki-default-format: svg # (default)
+    kroki-default-options: interactive # only available for SVG
+    kroki-fetch-diagram: true
+    kroki-server-url: https://kroki.io # (default)
+    page-isisprev: "2.0.0-M6"
+    page-isisrel: "2.0.0-M7"
+
+output:
+  dir: antora/target/site
diff --git a/antora/playbooks/site.yml b/antora/playbooks/site.yml
index 51e5ca0..387cbd8 100644
--- a/antora/playbooks/site.yml
+++ b/antora/playbooks/site.yml
@@ -48,6 +48,9 @@ content:
     - url: .
       start_path: antora/components/userguide # userguide
       branches: HEAD
+    - url: .
+      start_path: antora/components/tutorials # tutorials
+      branches: HEAD
 
 # api
     - url: .
diff --git a/core/config/src/main/java/org/apache/isis/core/config/IsisConfiguration.java b/core/config/src/main/java/org/apache/isis/core/config/IsisConfiguration.java
index 9bd71a1..3795045 100644
--- a/core/config/src/main/java/org/apache/isis/core/config/IsisConfiguration.java
+++ b/core/config/src/main/java/org/apache/isis/core/config/IsisConfiguration.java
@@ -2046,7 +2046,7 @@ public class IsisConfiguration {
              *
              * <p>
              * This behaviour is disabled by default; the viewer will use an inline prompt in these cases, making for a smoother
-             * user experience. If enabled then this reinstates the pre-1.15.0 behaviour of using a dialog prompt in all cases.
+             * user experience.
              * </p>
              */
             private PromptStyle promptStyle = PromptStyle.INLINE;
diff --git a/starters/adoc/modules/starters/pages/helloworld.adoc b/starters/adoc/modules/starters/pages/helloworld.adoc
index 321fcb2..d3cda92 100644
--- a/starters/adoc/modules/starters/pages/helloworld.adoc
+++ b/starters/adoc/modules/starters/pages/helloworld.adoc
@@ -505,13 +505,13 @@ This means:
 
 Most of the time you'll probably want to run the app from within your IDE.
 The mechanics of doing this will vary by IDE; see the xref:setupguide:ROOT:about.adoc[Setup Guide] for details of setting up Eclipse or IntelliJ IDEA.
-Basically, though, it amounts to running the `main()` method in the `HelloWorldApp`, but also (and this bit is important) ensuring that the xref:setupguide:hints-and-tips:about.adoc#datanucleus-enhancer[DataNucleus enhancer] has properly processed all domain entities.
+Basically, though, it amounts to running the `main()` method in the `HelloWorldApp` class.
 
 Here's what the setup looks like in IntelliJ IDEA:
 
 image::helloworld/helloworld.png[width="600px"]
 
-If using JDO as the ORM, then the DataNucleus enhancer must be run beforehand.
+If using JDO as the ORM (rather than JPA), then the DataNucleus enhancer must be run beforehand.
 With IntelliJ this can be done by setting up a different _Run Configuration_ to be executed beforehand:
 
 image::helloworld/helloworld-before-launch.png[width="600px"]
diff --git a/starters/adoc/modules/starters/pages/simpleapp.adoc b/starters/adoc/modules/starters/pages/simpleapp.adoc
index 38a9d9c..eead30d 100644
--- a/starters/adoc/modules/starters/pages/simpleapp.adoc
+++ b/starters/adoc/modules/starters/pages/simpleapp.adoc
@@ -54,7 +54,7 @@ To build the app from the latest stable release:
 +
 include::simpleapp-script-jdo.adoc[]
 
-if using JPA as the ORM:
+* if using JPA as the ORM:
 +
 include::simpleapp-script-jpa.adoc[]
 
@@ -64,6 +64,7 @@ This should only take a few seconds to download, compile and run.
 Then browse to link:http://localhost:8080[], and read on.
 
 
+[#using-the-app]
 == Using the App
 
 When you start the app, you'll be presented with a welcome page from which you can access the webapp using either the generic UI provided by xref:vw:ROOT:about.adoc[Web UI (Wicket viewer)] or use Swagger to access the xref:vro:ROOT:about.adoc[REST API (Restful Objects viewer)]:
@@ -652,7 +653,7 @@ Used for prototyping and also integration testing.
 
 <.> The `DomainAppDemo` is the fixture that was run xref:#fixtures[earlier on].
 
-<.> An implementation of the xref:refguide:applib:index/services/health/HealthCheckService.adoc[HealtCheckService].
+<.> An implementation of the xref:refguide:applib:index/services/health/HealthCheckService.adoc[HealthCheckService].
 This integrates with Spring Boot's link:https://docs.spring.io/spring-boot/docs/current/api/org/springframework/boot/actuate/health/HealthIndicator.html[HealthIndicator] SPI, surfaced through the link:https://docs.spring.io/spring-boot/docs/current/reference/html/production-ready-features.html[Spring Boot Actuator].
 
 <.> Annotated with xref:refguide:applib:index/annotation/HomePage.adoc[@HomePage] and so is shown automatically as the home page.
@@ -933,13 +934,13 @@ This means:
 
 Most of the time you'll probably want to run the app from within your IDE.
 The mechanics of doing this will vary by IDE; see the xref:setupguide:ROOT:about.adoc[Setup Guide] for details.
-Basically, though, it amounts to running the `main()` method in the `SimpleApp`, but also (and this bit is important) ensuring that the xref:setupguide:hints-and-tips:about.adoc#datanucleus-enhancer[DataNucleus enhancer] has properly processed all domain entities.
+Basically, though, it amounts to running the `main()` method in the `SimpleApp` class.
 
 Here's what the setup looks like in IntelliJ IDEA:
 
 image::simpleapp/simpleapp-webapp.png[width="600px"]
 
-If using JDO as the ORM, then the DataNucleus enhancer must be run beforehand.
+If using JDO as the ORM (rather than JPA), then the DataNucleus enhancer must be run beforehand.
 With IntelliJ this can be done by setting up a different _Run Configuration_ to be executed beforehand:
 
 image::simpleapp/simpleapp-webapp-before-launch.png[width="600px"]