Skip to main content
the readable codebase

ArchUnit and IntelliJ Structural Search: Automating the Detection of Cognitive Load Hotspots

5 min read Chapter 6 of 27

ArchUnit and IntelliJ Structural Search: Automating the Detection of Cognitive Load Hotspots

The Smell

The logistics platform has an architecture diagram on the wiki showing clean layers: controllers call services, services call repositories, repositories access the database. The actual code tells a different story. WarehouseController directly imports ShipmentRepository. BillingService calls CarrierController methods. ShipmentRepository contains business logic that should be in a service. The architecture diagram has not been updated in eighteen months. Nobody trusts it. Nobody references it.

The team knows the architecture is eroded, but they discover violations only during code reviews, and only when a reviewer happens to notice. Some violations are caught. Most slip through. The violations accumulate. Each one makes the next violation cheaper to commit because the precedent already exists.

The Cognitive Cost

When a developer reads WarehouseController and sees it importing ShipmentRepository, they must decide: is this intentional or accidental? Is the warehouse module supposed to depend on shipment data? If so, what is the contract? If not, how widespread is this violation? Each unexpected dependency adds one chunk of working memory to track: “this class also depends on that other module, and I do not know if that is by design.”

In the logistics platform, the average service class has 11 direct dependencies. A developer reading any service class must hold a mental model of 11 other classes. Reduce that to 5, and the developer reclaims 6 working memory slots for reasoning about the actual logic instead of tracking the dependency graph.

The Before

// HARD TO READ: No dependency rules enforced.
// Any class can import any other class.
// The architecture exists only in a wiki diagram that nobody updates.

package com.logitrack.service;

import com.logitrack.controller.CarrierController; // Controller imported by service
import com.logitrack.repository.ShipmentRepository;
import com.logitrack.repository.CarrierRepository;
import com.logitrack.repository.WarehouseRepository;
import com.logitrack.model.*;                       // Wildcard import: 87 classes
import com.logitrack.dto.*;                         // Wildcard import: 54 classes
import com.logitrack.util.StringUtils;
import com.logitrack.util.DateUtils;
import com.logitrack.util.CurrencyConverter;

public class BillingService {
    // This service depends on 3 repositories from different domains,
    // 1 controller (a layer violation), and wildcard imports from
    // model and dto packages that could pull in anything.
    // A reader cannot determine the actual dependency surface
    // without reading every method.
}

The Fix

// READABLE: ArchUnit test enforces dependency rules.
// This test runs in CI. It fails with a specific message identifying the violating class.

import com.tngtech.archunit.core.domain.JavaClasses;
import com.tngtech.archunit.core.importer.ClassFileImporter;
import com.tngtech.archunit.lang.ArchRule;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
import static com.tngtech.archunit.library.Architectures.layeredArchitecture;

class ArchitectureRulesTest {

    private final JavaClasses classes = new ClassFileImporter()
        .importPackages("com.logitrack");

    @Test
    void services_should_not_depend_on_controllers() {
        ArchRule rule = noClasses()
            .that().resideInAPackage("..service..")
            .should().dependOnClassesThat()
            .resideInAPackage("..controller..");

        rule.check(classes);
        // Fails with:
        // Rule 'no classes that reside in a package '..service..'
        //   should depend on classes that reside in a package '..controller..''
        //   was violated (1 times):
        // Class com.logitrack.service.BillingService depends on
        //   com.logitrack.controller.CarrierController
    }

    @Test
    void layered_architecture_is_respected() {
        layeredArchitecture()
            .consideringAllDependencies()
            .layer("Controller").definedBy("..controller..")
            .layer("Service").definedBy("..service..")
            .layer("Repository").definedBy("..repository..")
            .whereLayer("Controller").mayNotBeAccessedByAnyLayer()
            .whereLayer("Service").mayOnlyBeAccessedByLayers("Controller")
            .whereLayer("Repository").mayOnlyBeAccessedByLayers("Service")
            .check(classes);
    }

    @Test
    void no_wildcard_imports_in_service_layer() {
        // ArchUnit checks class-level dependencies, not import statements.
        // For import-level checks, use Checkstyle's AvoidStarImport rule.
        // But ArchUnit can verify the actual dependency count:

        ArchRule rule = noClasses()
            .that().resideInAPackage("..service..")
            .should().dependOnClassesThat()
            .resideInAPackage("..model..")
            .andShould().dependOnMoreThan(10, "..model..");
        // Custom predicate: fail if a service class depends on
        // more than 10 model classes, indicating a cohesion problem
    }
}

The ArchUnit tests are not documentation. They are executable assertions. When a developer adds an import from controller to a service class, the build fails with a message that names the violating class and the violated rule. The feedback is immediate. The violation never reaches code review.

For patterns that ArchUnit cannot express, IntelliJ structural search fills the gap:

// IntelliJ structural search: Find methods with more than 3 boolean parameters
// Search template:
$ReturnType$ $MethodName$(boolean $p1$, boolean $p2$, boolean $p3$, $OtherParams$)

// Variables:
// $p1$, $p2$, $p3$: minimum count 1, maximum count 1
// $OtherParams$: minimum count 0, maximum count unlimited

// IntelliJ structural search: Find catch blocks that swallow exceptions
// Search template:
try {
    $TryStatements$;
} catch ($ExceptionType$ $e$) {
    $CatchStatements$;
}

// Variable $CatchStatements$:
//   minimum count: 0
//   maximum count: 0
// Matches: catch blocks with no statements (empty catch)

// IntelliJ structural search: Find classes with more than 10 fields
// Search template:
class $ClassName$ {
    $FieldType$ $field$ = $init$;
}

// Variable $field$:
//   minimum count: 11
//   maximum count: unlimited
// Variable $init$:
//   minimum count: 0
//   maximum count: 1

These structural search patterns can be saved and shared across the team as IntelliJ inspection profiles. A developer running the inspection sees every method with too many boolean parameters, every swallowed exception, and every class with too many fields. The pattern matching is structural, not textual: it works regardless of formatting, spacing, or annotation order.

The Rule

Write ArchUnit tests for the three dependency rules your codebase violates most frequently, and add them to the CI pipeline before doing any refactoring. Refactoring without boundary enforcement is filling a bathtub with the drain open. The ArchUnit tests ensure that every violation you fix stays fixed. Start with: services do not depend on controllers, repositories contain no business logic, and no package has circular dependencies. Run ClassFileImporter on your codebase today. The number of violations is your baseline. Every number below that is progress.