Date

I recently discovered a Java library, ArchUnit, that makes it easy to test the structure of your codebase. 'Arch' is short for 'Architecture' and in this case means structural properties within a single codebase. It does not test multi-service architecture. From the site:

That is, ArchUnit can check dependencies between packages and classes, layers and slices, check for cyclic dependencies and more. It does so by analyzing given Java bytecode, importing all classes into a Java code structure. ArchUnit’s main focus is to automatically test architecture and coding rules, using any plain Java unit testing framework.

All of this was already possible with reflection, but ArchUnit makes it a piece of cake.

I've added the library to my toolbox of Ratchets -- tools that automatically encourage a healthier codebase. More on this later, but first, some use cases.

Use Cases

ArchUnit has some wonderful introductory examples. I've reproduced one of them here:

noClasses().that().resideInAPackage("..source..")
    .should().dependOnClassesThat().resideInAPackage("..foo..")

Pretty straightforward. Classes in source cannot depend on those in foo. This is already useful if you want to enforce that a particular layer stays uncontaminated with business logic. Take a moment to read through the other examples.

Use Case: Protecting the public API

I've used an equivalent Kotlin library in a multiplatform project to enforce that all publicly exposed classes and functions be are in a particular package like com.hpincket.api. This way there's no accidental addition to the public API. It must happen in the appropriate package. You can then combine this with a code review tool to enforce that modifications within the src/main/kotlin/com/hpincket/api/ folder requires a special approval.

Use Case: Guarding API data models

Suppose in another codebase the public API models consist of JSON represented as java records (or Kotlin data classes). Ideally the

These records can have members which are not themselves records. That would go against the intent of the system, so we can add a test for this case:

ArchRule rule = ArchRuleDefinition.classes()
        .that(residesInChildPackageOf("com.hpincket.api"))
        .and().areRecords()
        .should(new RecordMembersShouldResideInPackage("com.hpincket.api"));

With the associated definition:

@Override
    public void check(JavaClass item, ConditionEvents events) {
        for (JavaField field : item.getFields()) {
            JavaClass fieldType = field.getRawType();
            // Records should be found in `com.hpincket.api`
            if (fieldType.isRecord()) {
                if (!fieldType.getPackageName().startsWith(packageName)) {
                  // Add an error to the events object
                }
                continue;
            }
            if (!isBaseType(fieldType)) {
              // Add an error to the events object
            }
        }
    }

Notice the isBaseType(fieldType) check. That check allows primitive objects in the API, classes like String, Long, Integer, etc. You can find an implementation of this test on Github.

Use Case: Tests cannot import internal packages

While I've yet to write it, you could imagine an ArchTest that ensures other unit tests never import the internal packages. With this approach you'd enforce the testing philosophy that only external behavior should be tested. I've never worked in a codebase with this rule, If you have, send me an email. I'm sure it's a pain initially.

The Theory

By itself ArchUnit is a tool that maintains an invariant across the system, but it could be combined with the concept of Ratchets by qntm. If a particular pattern occurs, say, 11 times, then you can add a test that it never appears 12 times. In this way ArchUnit prevents system degradation and scales feedback. The ideal ratchet always tests for a value of zero.

From qntm's blog:

What this technique does is automate what was previously a manual process of me saying "don't do this, we've stopped doing this" in code review. Or forgetting to say it. Or missing the changes entirely, due to the newcomer having the audacity to request their review from someone else.

Automatically scaling feedback becomes more important as the number of developers grows. When you can no longer mind-meld with every developer during lunch, then it's time to communicate in other ways. And a ratchet is more than scaled feedback, it is a rule. One that requires the developer to explicitly break it.

No matter how well-intentioned the engineer, their incentives rarely align with code quality. And no matter how stringent the reviewer, they will still have days where they capitulate under pressure. Like Odysseus facing the Sirens, we must tie ourselves to the mast in our moments of sanity.

The stronger the rope, the better. The strongest Ratchet I know is a static type system. It can't be bypassed easily. The code must compile. And while manual type casting might satisfy the compiler, it's often more painful than doing the right thing.

ArchUnit isn't as strong as a type system, but it does offer the benefits of any automated Ratchet. The first benefit is that the test failure doesn't come as a surprise. You can run it on your machine at any time. And so while an code review happens once the code is already written, the equivalent ArchUnit test runs at the beginning of development. The second benefit of the Ratchet is that it requires an explicit admission of intent. An offending code change is never blocked by an "unreasonable" reviewer, instead it is the author who must explain why they intentionally defied the test. Early feedback along with a shifted psychological burden offers a stronger protection of the initial design.

This may read as a cynical and uncharitable view, but it's a necessary one. Codebase health is hard to measure, so it often isn't represented in product proposals and performance reviews. And yet engineers at every company complain about software complexity. Constant change gradually degrades the coherence of every system. Our goal is to eek out as much as we can before the next rewrite. Architecture tests are one way to do just that.