Published by

Il y a 2 ans -

Temps de lecture 15 minutes

Enforcing architecture decisions with ArchUnit

ArchUnit is a library which offers a fluent API to test the architecture of Java applications.

The purpose of this article is to show what it is capable of and to question how it can fit the way you deal with architecture and its documentation.

archunit

Introduction

Scope

ArchUnit is a library that is used to implement automated tests with the goal to enforce architecture decisions.

Martin Fowler defines software architecture as a set of decisions which are both important and hard to change. ArchUnit does not address all architecture decisions: it obviously does not apply to enterprise-level decisions, but only to application design decisions.

Integration into your environment

ArchUnit is primarily designed for Java and Kotlin. Using it along with other JVM languages — such as Scala — is possible, but a few workarounds would prove necessary, which would impact the expressiveness of the fluent API and make it counter-intuitive.

ArchUnit is likely to work with your existing test setup, as it supports all major test frameworks. Besides core library, additional libraries, which provide annotations to improve readability and conciseness, are available specifically for both JUnit 4 and JUnit 5.

Version 0.10.2 is the latest available at the time of writing. The source code is available on the official GitHub repository of ArchUnit, along with a very convenient set of examples.

Glossary

In order to avoid ambiguity in the following paragraphs, it is important to understand the difference between what is meant by “decision” and “rule”.

decision is something that impacts source code and that the team decides to enforce — it may not always be a decision from the team, but a decision from an outsider whose authority is recognized by the team.

rule is a resulting test, as implemented with ArchUnit. It can be considered as the realization of the decision as a test.

Benefits

The most obvious benefit of ArchUnit is similar to what any other type of test would bring you: you get to know whether you are compliant with the rule or not at the time you implement it, and on each modification from then on. Ensuring non-regression is especially relevant as it prevents architecture from diverging.

Team members may or may not know about some past decisions; regardless, we all make mistakes. A team would typically rely on code review to prevent this divergence. ArchUnit automates this in a deterministic way, and the time saved in code reviews can be spent on more valuable topics instead, such as discussing architecture decisions.

Fluent API and use cases

Even though ArchUnit is not meant to address every possible case, it offers 3 layers which can be used to adapt to a lot of contexts:

  • Core layer provides a very flexible API, which can be used to implement pretty much any rule.
  • Lang layer builds upon the Core layer to provide a fluent API which can be used to implement most rules with the benefit of a very expressive syntax.
  • Library layer provides very specific syntax for a set of predefined rules. It makes them even simpler than the Lang layer, but only a few very common use cases are available.

Library layer

The most obvious use case for ArchUnit is to check the global structure of an application, including a breakdown into layers and their interactions.

Since this kind of rule is often implemented, the Library layer provides very specific syntax in order to make them as expressive and concise as possible.

Layered architecture

For instance, you can use it to enforce rules for layered architectures. In the example below, the Service layer, which is supposed to contain all business logic, is defined so that it does not depend on other layers, in an idea that is akin to hexagonal architectures.

hexagonal-architecture
Expected Layers
@ArchTest
public static final ArchRule layer_dependencies_are_respected = layeredArchitecture()
  .layer("Controller").definedBy("test.sdc.archunit.controller..")
  .layer("Service").definedBy("test.sdc.archunit.service..")
  .layer("Adapter").definedBy("test.sdc.archunit.adapter..")
  .whereLayer("Controller").mayNotBeAccessedByAnyLayer()
  .whereLayer("Service").mayOnlyBeAccessedByLayers("Controller", "Adapter")
  .whereLayer("Adapter").mayNotBeAccessedByAnyLayer();

The definition of a layer relies on a syntax in which test.sdc.archunit.controller.. includes everything in the controller package and its subpackages.

Internal structure of layers

You can also define rules related to the internal structure of layers.

For instance, you may want to define a rule to make sure that adapters never depend on one another.

@ArchTest
public static final ArchRule adapters_do_not_depend_on_one_another = slices()
  .matching("test.sdc.archunit.(adapter).(*)..").namingSlices("$1 '$2'")
  .ignoreDependency("test.sdc.archunit.adapter.*..", "test.sdc.archunit.adapter.common..")
  .should().notDependOnEachOther();

To do so, you can define slices of your application by using a pattern matching syntax. For instance, test.sdc.archunit.adapter.*.. includes all classes in packages matching this pattern, i.e. subpackages of the adapter package.

Rules can be defined to check that slices are completely independent from one another, or that their dependencies do not form cycles.

The wildcard character can further be used to enforce consistent structure to similar layers.

Lang layer

Although the Library layer provides a tailored syntax with near-perfect expressiveness and conciseness, it only does so for a predefined set of rules.

The Lang layer provides a fluent API which can be used to implement most rules with a syntax that remains very expressive.

Keep third-party dependencies in check

For instance, you can check which external dependencies are used in your code. You can therefore add constraints on package or on class-level, instead of having to define Maven/Gradle modules specifically.

@ArchTest
public static final ArchRule only_adapters_pull_model_dependencies = noClasses()
  .that().resideOutsideOfPackage("test.sdc.archunit.adapter..")
  .should().accessClassesThat().resideInAnyPackage("com.fasterxml..");

⚠️ This does not apply to annotations. In the example above, even if Jackson annotations are used in source code, the rule will be considered satisfied. This issue is clearly identified and its fix is considered as a must-have for version 1.0.0.

⚠️ A similar problem occurs with type parameters. For instance, if a class manipulates lists of JSON parsers, this is not considered as a dependency towards Jackson library per se.

Core layer

The Library and Lang layers should cover most of your needs. However, just in case they would not, the layer upon which they build, the Core layer, is exposed.

The Core layer is a very flexible API which can be used to implement pretty much any rule.

For instance, until version 0.10.0 of ArchUnit, it was not possible to test methods with the Lang layer. In order to work around this inability, it was possible to use the Core layer directly.

@ArchTest
public static final ArchRule all_entry_points_shoud_be_annotated_with_log_description = all(new AbstractClassesTransformer("methods") {
  @Override
  public Iterable doTransform(final JavaClasses javaClasses) {
    return StreamSupport.stream(javaClasses.spliterator(), false)
        .flatMap(javaClass -> javaClass.getMethods().stream())
        .collect(toList());
  }
})
    .that(HasOwner.Functions.Get.owner().is(resideInAPackage("test.sdc.archunit.controller..")))
    .and(modifier(PUBLIC).as("are public"))
    .should(new ArchCondition("annotated with " + LogDescription.class) {
      @Override
      public void check(final JavaMethod method, final ConditionEvents events) {
        boolean typeMatches = method.isAnnotatedWith(LogDescription.class);
        final String message = format("%s annotated with %s",
            method.getFullName(), method.getAnnotations().stream()
                .map(annotation -> annotation.getType().getSimpleName())
                .collect(toList()));
        events.add(new SimpleConditionEvent(method, typeMatches, message));
      }
    });

The fact that the Core layer is verbose and very generic results in rules that are hard to understand and even harder to maintain. This can easily be solved by implementing an intermediate layer with expressiveness in mind, in the same logic as the Lang layer. This additional layer could then be shared between modules as a custom library. This results in a much simpler syntax:

@ArchTest
public static final ArchRule all_entry_points_shoud_be_annotated_with_log_description = all(methods())
  .that(areDefinedInAPackage("test.sdc.archunit.controller.."))
  .and(arePublic())
  .should(beAnnotatedWith(LogDescription.class));

Grouping rules

Since rules are defined as public constants, they can be defined in one class and used in another. This is especially useful when you share rules among several modules.

public class HexagonalArchitectures {
  @ArchTest
  public static final ArchRule adapters_should_not_depend_on_one_another = ...
}

@RunWith(ArchUnitRunner.class)
@AnalyzeClasses(packages = "test.sdc.archunit")
public class ArchitectureTest {
  @ArchTest
  public static final ArchRule adapters_should_not_depend_on_one_another = HexagonalArchitectures.adapters_should_not_depend_on_one_another;
}

It is also possible to import all rules defined in a class. This makes the definition of rules much less verbose, so you should consider grouping them into consistent bundles.

Documentation

Why you need it

Documenting architecture decisions is a major issue. Its lack implies the loss of critical information, which leads to poor decision-making and lots of wasted time.

However, documentation is usually time-consuming and hard to keep up-to-date. The latter is especially critical: if you always have to check the implementation because you do not trust what the documentation states, that makes the documentation a liability.

What ArchUnit has to offer

As any automated test, rules defined in ArchUnit are documentation on their own.

“Good code is its own best documentation”

— Steve McConnell

In order to improve the relevance of this documentation, ArchUnit provides some methods for documentation purpose: as method is used to define an alias, i.e. the statement of the decision, whereas because method is meant to specify the rationale of the rule.

@ArchTest
public static final ArchRule adapters_do_not_depend_on_one_another = slices()
  .matching("test.sdc.archunit.(adapter).(*)..").namingSlices("$1 '$2'")
  .should().notDependOnEachOther()
  .as("Adapters do not depend on one another")
  .because("Adapters should only depend on one external system; depending on other adapters is likely to imply pulling dependencies towards other external systems");

These methods make the rule easier to understand when you read the source code, as they clarify its intent. Moreover, they are especially useful when a rule is violated, as they are used to compose the test failure message.

com.tngtech.archunit.lang.ArchRule$AssertionError: Architecture 
Violation [Priority: MEDIUM] - Rule 'Adapters do not depend on one another, because Adapters should only depend on one external system; depending on other adapters is likely to imply pulling dependencies towards other external systems' was violated (1 times): 
adapter 'source2' calls adapter 'source1':

Method 
<test.sdc.archunit.adapter.source2.InternalAccountAdapter.getAccounts()> calls method <test.sdc.archunit.adapter.source1.ExternalAccountUtils.accountInEuros(java.lang.String, long)> in (InternalAccountAdapter.java:17)

How ArchUnit fits in

There are many ways to capture architecture decisions. Several factors matter when you choose one:

  • Decisions have to be easily accessible to the relevant people. Above all, the team in charge of their implementation.
  • As already mentioned, maintaining the documentation up-to-date is a major concern. That is why it has to be easy to keep information up-to-date.
  • You need to be able to view the history of decisions. The date of the decision, the changes it underwent, etc. are key to properly understand the decision.

For instance, lightweight Architecture Decision Records (ADR) offer a way to deal with these by focusing on a few key elements and having them stored in source control, which is as close to their implementation as can be.

With regard to these constraints, materializing decisions as automated tests with ArchUnit may seem like an even better solution. In addition, the fact that these tests provide a feedback loop as to which rules are actually applied and which are not, makes them a reliable source of information. So why not use ArchUnit only from now on?

First, this would not be possible, for not all architecture decisions can be implemented as rules in ArchUnit; as already mentioned, ArchUnit only applies to application design decisions.

Besides, an important characteristic of ADRs is that they include key elements that help understand the decision: the decision itself, the context in which it was made, the alternatives which were possibly considered, the consequences which were anticipated… ArchUnit may provide the because method as a free text field, but it would be hard to consider it enough in regard to this list of key elements.

This is why you should use ArchUnit in conjunction with a more extensive documentation mode, such as ADRs:

  • For decisions which cannot be implemented as ArchUnit rules, only ADRs are available.
  • For application design decisions which are self-explanatory (not too often if you focus on significant decisions only), ArchUnit rules are enough.
  • For application design decisions which need proper documentation, both ADRs and ArchUnit rules are relevant. Finding a way to reference one from the other is necessary to make access to documentation easier when you need it. For instance, since ADRs can never be altered or deleted, you could reference corresponding ADR in the because method of an ArchUnit rule.

Common pitfalls

There are a number of pitfalls that you should avoid when getting started with ArchUnit.

First of all, ArchUnit is not a solution to your architecture problems, and it should not be considered as such. It does help enforce decisions, which may sanitize the architecture of your application, but making these decisions is still a prerequisite, and it is obvious that making the wrong decisions, even if you enforce them properly, will lead to mixed results.

You should not try to implement rules systematically for any decision, but focus on the important decisions, lest you spend a lot of time implementing rules with little to no value. This way you will have only a few rules, at least until you get a clear idea of the capabilities of ArchUnit.

You should focus on what ArchUnit is good at dealing with. The line between ArchUnit and other tools has to be clearly defined — even if that means that it has to be questioned again later. Otherwise, you may come to a point where you have to maintain a lot of redundant rules, which adds to confusion and wastes time. On this topic, most rules related to naming or coding rules are best handled by dedicated tools (Checkstyle, Findbugs, etc.). In such tools, rules can be activated by configuration, which is easier than implementing and maintaining tests, however simple they may be, and their integration in the IDE or in SonarQube is much better.

Decision ownership

In “traditional” structures (the kind that have projects following waterfall model), architecture decisions often originate from teams of architects. In this logic, tools enabling their implementation as rules are likely to be perceived as a means to coercing development teams.

However, this issue should not apply to ArchUnit, for it relies on the implementation of rules as unit tests by the development teams. In order to add or update a rule, you have to be part of the development team. People outside the team may have access to test reports. However, ArchUnit tests are just that, tests, and do not distinguish from other tests. This means that decisions originating from outside the team require the team to be convinced before they are implemented as rules. On the other hand, some decisions may be made from within the team without consulting the people in charge.

On a more general note, it makes sense to consider that the people in charge of this level of architecture decision (once again, we are talking about application design, not enterprise-level decisions) are members of the team themselves, and not outsiders who tend to impose decisions with irrelevant consistency in mind even though they lack information regarding the specifics of each context to make the right decision.

Since this level of decision is directly related to the code, we can consider that this is merely an extension of the principle of collective code ownership.

Evolutionary architecture

In their 2017 book Building Evolutionary Architectures, Neal Ford, Rebecca Parsons and Patrick Kua introduce evolutionary architectures, which aim to address the need to adapt to changes that occur continually, by making evolvability a first-class citizen in software projects. Evolutionary architectures revolve around 3 concepts:

  • As most people recommend nowadays, change to your application should be introduced incrementally. Architecture should be no exception.
  • You need to be able to assess objectively how close a design solution is to achieving the set aims, so that evolutions end up improving the system, and not incidentally harming architecture concerns without notice. Architectural fitness functions are defined as basically anything that makes this assessment possible, including metrics, tests, etc.
  • Architectures have multiple dimensions, including requirements as well as technical concerns, security, scalability, etc. Since individual software architects tend to focus on a subset of these dimensions, they need to be able to check the impact of their decisions on other dimensions.

Fitness functions can be considered as functions that protect your architectural characteristics as architecture evolves. ArchUnit is a way to define some, as the compliance with the rules is checked continuously, as part of the CI pipelines.

If you want your application to be able to evolve easily, it is paramount to understand the rules it is supposed to follow, in order to know that it follows them for sure. Updating rules bit by bit helps you ensure that you reach the desired state of the architecture, so it can help you get there. ArchUnit makes it possible to set up new rules and remove obsolete ones very easily, which is in line with the idea that architecture has to evolve continuously.

Conclusion

ArchUnit may not be completely mature yet, as some obvious features are only expected for version 1.0.0. However, as soon as your code has structure, it is already able to provide you with a lot of value for very little effort.

You should try and see for yourself, but please keep in mind:

  • You should focus on a few important decisions first, at least until you are familiar enough with the tool to go further.
  • It only works as long as decisions are made in agreement with the team that implements the corresponding rules.
  • It should be used in conjunction with other documentation methods, not as a replacement.

And we look forward to seeing you in Paris on November for the XebiCon, a must-attend event in the IT ecosystem, featuring 90 technical conferences and feedback from customers. More information and registration on xebicon.fr.

Published by

Commentaire

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Nous recrutons

Être un Sapient, c'est faire partie d'un groupe de passionnés ; C'est l'opportunité de travailler et de partager avec des pairs parmi les plus talentueux.