Architecture Test Support

Apache Isis provides a library of ArchUnit tests to verify the structure of your domain applications.

Maven Configuration

Dependency Management

If your application inherits from the Apache Isis starter app (org.apache.isis.app:isis-app-starter-parent) then that will define the version automatically:

pom.xml
<parent>
    <groupId>org.apache.isis.app</groupId>
    <artifactId>isis-app-starter-parent</artifactId>
    <version>2.0.0-M6</version>
    <relativePath/>
</parent>

Alternatively, import the core BOM. This is usually done in the top-level parent pom of your application:

pom.xml
<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.apache.isis.core</groupId>
            <artifactId>isis-core</artifactId>
            <version>2.0.0-M6</version>
            <scope>import</scope>
            <type>pom</type>
        </dependency>
    </dependencies>
</dependencyManagement>

In addition, add a section for the BOM of all the testing support libraries:

pom.xml
<dependencyManagement>
    <dependencies>
        <dependency>
        	<groupId>org.apache.isis.testing</groupId>
	        <artifactId>isis-testing</artifactId>
            <scope>import</scope>
            <type>pom</type>
            <version>2.0.0-M6</version>
        </dependency>
    </dependencies>
</dependencyManagement>

Dependencies

In the webapp module(s) of your application, add the following dependency:

pom.xml
<dependencies>
    <dependency>
        <groupId>org.apache.isis.testing</groupId>
        <artifactId>isis-testing-archtestsupport-applib</artifactId>
        <scope>test</scope>     (1)
    </dependency>
</dependencies>
1 this assumes that the architecture tests reside in src/test/java (but see notes on excluding tests, in the next section).

Architecture_Test Boilerplate

It’s generally sufficient to have a single Architecture_Test class that runs all architecture tests against all classes. This should look something like:

ArchitectureTests.java
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;

import static org.apache.isis.testing.archtestsupport.applib.domainrules.ArchitectureDomainRules.*;

@AnalyzeClasses(
    packagesOf = {
        CustomerModule.class                                                (1)
        , OrderModule.class                                                 (1)
        , ProductModule.class                                               (1)
    },
    importOptions = { ImportOption.DoNotIncludeTests.class }                (2)
)
public class Architecture_Test {                                            (3)

  @ArchTest                                                                 (4)
  public static ArchRule every_DomainObject_must_specify_logicalTypeName =  (5)
      every_DomainObject_must_specify_logicalTypeName();                    (6)

  ...
}
1 the modules of the application to be scanned
2 excludes running against the tests themselves. But see caveat, below.
3 Recommended name. Architecture tests run quickly, so it generally makes sense to name them as unit tests (with a Test suffix, to be picked up by surefire).
4 indicates this is an architecture test. There can be many such tests in a single file; all are expressed as static fields.
5 the field name is unimportant (but must be unique, of course).
6 the architecture test itself, imported from ArchitectureXxxRules
Don’t architecture check your tests

In theory tests can be skipped from checking using the ImportOption.DoNotIncludeTests annotation shown above.

If this does not work for you, consider creating a separate Maven module as a peer of your webapp, and place the architecture tests in src/main/java. Also remember to change the <scope>test</scope> to <scope>compile</scope>.

Domain rules

Checks for rules defined against domain classes reside in the ArchitectureDomainRules library.

  • Strongly Recommended

    • logical type name

      This ensures that persisted/serialized bookmarks of domain objects are stable even if the domain class' physical canonical name changes over time due to refactoring etc.:

      • every_DomainObject_must_specify_logicalTypeName()

      • every_DomainService_must_specify_logicalTypeName()

    • JAXB view models are annotated with @DomainObject(nature=VIEW_MODEL)

      This ensures that the framework is able to correctly detect the view models in the metamodel:

      • every_jaxb_view_model_must_also_be_annotated_with_DomainObject_nature_VIEWMODEL()

    • allow injected fields in view models (strongly recommended)

      Ensures that view models safely ignore injected services.

      • every_injected_field_of_jaxb_view_model_must_be_annotated_with_XmlTransient()

      • every_injected_field_of_serializable_view_model_must_be_transient()

  • Recommended

    • consistency with repository finders

      Requires that the caller of a repository finder handle the case that there may be no such object in the database:

      • every_finder_method_in_Repository_must_return_either_Collection_or_Optional()

    • mixin naming and coding conventions

      For teams that rely on the Mixee_memberName convention to find mixins of domain objects:

      • every_Action_mixin_must_follow_naming_convention()

      • every_Property_mixin_must_follow_naming_convention()

      • every_Collection_mixin_must_follow_naming_convention()

  • Optional

    • layout annotations

      For teams that prefer to use annotations instead of layout files:

      • every_DomainObject_must_also_be_annotated_with_DomainObjectLayout()

      • every_DomainService_must_also_be_annotated_with_DomainServiceLayout()

    • repository class naming/annotation conventions (optional)

      For teams that prefer that repositories are easily identifiable by their name XxxRepository, (and are annotated accordingly):

      • every_Repository_must_follow_naming_conventions()

      • every_class_named_Repository_must_be_annotated_correctly()

    • controller class naming/annotation conventions (optional)

      For teams that prefer that custom controllers are easily identifiable by their name XxxController, (and are annotated accordingly):

      • every_Controller_must_be_follow_naming_conventions()

      • every_class_named_Controller_must_be_annotated_correctly()

None of these rules take any arguments, so simply include (as a static field) those that you wish to enforce.

Entity Checks

Entity rule checks are provided for both JPA/Eclipselink and JDO/DataNucleus, in ArchitectureJpaRules and ArchitectureJdoRules respectively:

  • Strongly Recommended

    • ensure JPA entities can support auditing and dependency injection:

      Required in almost all cases:

      • every_jpa_Entity_must_be_annotated_as_an_IsisEntityListener()

    • ensure entities can be referenced in JAXB view models:

      Required to allow JAXB view models to transparently reference entities:

      • every_jpa_Entity_must_be_annotated_with_XmlJavaAdapter_of_PersistentEntityAdapter()

      • every_jdo_PersistenceCapable_must_be_annotated_as_XmlJavaAdapter_PersistentEntityAdapter()

    • ensure JPA entity defines a surrogate key:

      Enforces a very common convention for JPA entities:

      • every_jpa_Entity_must_have_an_id_field()

  • Recommended

    • encourage entities be organised into schemas:

      for teams adopting a "(micro) service-oriented" mindset:

      • every_jpa_Entity_must_be_annotated_as_Table_with_schema()

      • every_jdo_PersistenceCapable_must_have_schema()

    • ensure JPA enum fields are persisted as strings:

      For teams that prefer to query the database with enum values persisted as strings rather than as ordinal numbers:

      • every_enum_field_of_jpa_Entity_must_be_annotated_with_Enumerable_STRING()

    • ensure entities can be used in SortedSet parented collections:

      For teams that like to use SortedSet for parented collections, and rely on the entity to render itself in a "sensible" order:

      • every_jpa_Entity_must_implement_Comparable()

      • every_jdo_PersistenceCapable_must_implement_Comparable()

    • ensure entities have a unique business key:

      For teams that insist on business keys in addition to surrogate keys

      • every_jpa_Entity_must_be_annotated_as_Table_with_uniqueConstraints()

      • every_jdo_PersistenceCapable_must_be_annotated_as_Uniques_or_Unique()

  • Optional

    • ensure conformance of persistence vs Isis annotations

      For teams that like to emphasise entities vs view models:

      • every_jpa_Entity_must_be_annotated_with_DomainObject_nature_of_ENTITY()

      • every_jdo_PersistenceCapable_must_be_annotated_with_DomainObject_nature_of_ENTITY

    • ensure if JDO entity has a surrogate key then its annotations are consistent:

      For teams that like to be explicit about the use of surrogate keys:

      • every_jdo_PersistenceCapable_with_DATASTORE_identityType_must_be_annotated_as_DataStoreIdentity()

    • enable injected fields in entities:

      For teams that use type-safe queries (and want to ensure that services do not appear superfluously as fields in the "QXxx" classes):

      • every_injected_field_of_jpa_Entity_must_be_annotated_with_Transient()

      • every_injected_field_of_jdo_PersistenceCapable_must_be_annotated_with_NotPersistent()

    • encourage optimistic locking:

      For teams that prefer optimistic locking as a standard everywhere:

      • every_jpa_Entity_must_have_a_version_field()

      • every_jdo_PersistenceCapable_must_be_annotated_with_Version()

    • encourage static factory methods for entities:

      For teams that prefer the use of factory methods to vanilla constructors:

      • every_jpa_Entity_must_have_protected_no_arg_constructor()

None of these rules takes any arguments, so simply call those (from a static field) those that you wish to enforce.

Module Rules

The module rules, residing in ArchitectureModuleRules class, are used to check the coarse-grained dependencies between modules [1], to ensure proper layering. A further check checks at a more fine-grained level for the placement of classes into subpackages of those modules.

code_dependencies_follow_module_Imports

The @Import statements between modules form an acyclic dependency graph. If there is a cycle between those modules, then Spring itself will fail to boot.

This architecture check doesn’t check the the modules' graph itself, but rather ensures that the dependencies between classes that live within each module honour the direction of the module graph.

For example, if the OrderModule depends on (@Imports) the CustomerModule, then we allow for the Order entity to reference the Customer entity. However, we do not allow the opposite reference.

To run the test, use:

@ArchTest
public static ArchRule code_dependencies_follow_module_Imports =
    code_dependencies_follow_module_Imports(                        (1)
        analyzeClasses_packagesOf(Architecture_Test.class));        (2)
}
1 architecture test itself, imported from ArchitectureModuleRules
2 utility that passes in for all analysis all of the module classes that are annotated on Architecture_Test.

Aside

You might well ask: if deleting a customer requires deleting its orders, then how is this done if a customer does not "know" abouts its related orders?

There are two main options:

  1. use a mixin. Provide a Customer_delete action, but place it in the OrderModule. That way the mixin code has access to delete the associated Orders

    However, this trick won’t work if there are multiple referencing modules to customer, all of which need to do their work but none of which know about the other. For example, if there is also an AddressModule that maintains the customer’s address, and neither OrderModule nor AddressModule know about the other, then there is no one place to put the mixin that has knowledge of all of the related "stuff" of customer to be dealt with.

  2. use a domain event.

    Here we keep the Customer_delete mixin in the CustomerModule (indeed, it could even be a regular action) and instead it emits a domain event:

    Customer_delete.java
    @Action(domainEvent = Customer_delete.ActionEvent.class)
    @RequiredArgsConstructor
    public class Customer_delete {
        public static class ActionEvent                     (1)
            extends ActionDomainEvent<Customer_delete>{}
        private final Customer customer;
        public void act() {
            customerRepo.delete(customer);
        }
    }
    1 event to be fired.

Module Package Tests

The module package tests builds upon the module layering tests and also checks that that each class is in the appropriate subpackage within each module, and that those subpackages access only the allowed subpackages of its own module or of other modules. Different sets of subpackages can be defined for both the "local" (same) module and "foreign" (referencing) modules.

Because different teams will undoubtedly have different standards, the exact rules for subpackages are pluggable, with the Subpackage interface defining the information that the architecture test requires:

Subpackage
public interface Subpackage {

    String getName();                                               (1)
    List<String> mayBeAccessedBySubpackagesInSameModule();          (2)
    List<String> mayBeAccessedBySubpackagesInReferencingModules();  (3)

    default String packageIdentifier() {                            (4)
        return "." + getName() + "..";
    }
}
1 The name of the subpackage, for example "dom", "api", "spi" or "fixtures".
2 A list of the (names of the) subpackages where classes in the same module as this package have access.

For example, the "dom" subpackage can probably be referenced from the "menu" subpackage, but not vice versa.

The special value of "*" is a wildcard meaning that all subpackages (in the same module) can access.

3 A list of the (names of the) subpackages where classes in the packages of other referencing modules may have access.

For example, in some cases the the "dom" subpackage may <i>not</i> be accessible from other modules if the intention is to require all programmatic access through an "api" subpackage (where the classes in dom implement interfaces defined in api).

The special value of "*" is a wildcard meaning that all subpackages (in other modules) can access.

4 allows a subpackage to more precisely control the classes that it includes. Useful if want to declare a subpackage representing that of the module’s own package (where the XxxModule class resides).

The SubpackageEnum provides an off the shelf implementation; you will probably want to copy this and adjust as necessary.

To run the test, use:

@ArchTest
public static ArchRule code_deps_follow_module_Imports_and_subpackage_rules =
      code_dependencies_follow_module_Imports_and_subpackage_rules(     (1)
          analyzeClasses_packagesOf(ModulesArchTests.class),            (2)
          asList(SimplifiedSubpackageEnum.values()));                   (3)
1 architecture test itself, imported from ArchitectureModuleRules
2 utility that passes in for all analysis all of the module classes that are annotated on Architecture_Test.
3 list of the Subpackages whose relationships are to be checked

where for example Subpackage is implemented using with:

SimplifiedSubpackageEnum.java
@RequiredArgsConstructor
public enum SimplifiedSubpackageEnum implements Subpackage {

    dom(
            singletonList("*"),         (1)
            emptyList()                 (2)
    ),
    api(
            singletonList("*"),         (1)
            singletonList("*")          (3)
    ),
    spi(
            singletonList("dom"),       (4)
            singletonList("spiimpl")    (5)
    ),
    spiimpl(
            emptyList(),                (6)
            emptyList()                 (2)
    ),
    ;

    final List<String> local;
    final List<String> referencing;

    @Override
    public String getName() { return name(); }

    @Override
    public List<String> mayBeAccessedBySubpackagesInSameModule() {
        return local;
    }
    @Override
    public List<String> mayBeAccessedBySubpackagesInReferencingModules() {
        return referencing;
    }
    private static String[] asArray(List<String> list) {
        return list != null ? list.toArray(new String[] {}) : null;
    }
}
1 wildcard means that all subpackages in this module can access this module
2 no direct access from other modules
3 wildcard means that all subpackages in other modules can access this module
4 callers of a module’s own SPI
5 other modules should only implement the SPI
6 no direct access from this module

1. By "module" we mean a classes annotated with @Configuration