DEV Community

l3o6
l3o6

Posted on

ArchUnit as a Guardrail for AI-Generated Code

Agent-Based Code Generation and Architectural Drift

Today, an AI agent can quickly generate a significant amount of code: add new endpoints, services, or repositories. At the same time, it does not necessarily follow the project's architecture. It often favors local optimization, creates arbitrary abstractions, avoids importing ready-made solutions in favor of its own implementations, and suffers from verbosity. Therefore, the problem of defining architectural boundaries is acute. Tests, and evals in a broader sense, are responsible for enforcing certain rules so that a controller does not start accessing a repository directly, a service does not receive a dependency on a controller, and a repository does not depend on upper layers. This reduces the risk of unnoticed architectural drift and makes automated code generation more effective over a longer horizon.

What Is ArchUnit

ArchUnit is a library for testing the architecture of Java code. It analyzes bytecode and allows rules to be described for packages, classes, annotations, and dependencies between them. Such rules are represented as ordinary automated tests, which makes it possible to stop a build when the architecture is violated.

What Checks Can Be Performed

ArchUnit makes it possible to control, in particular:

  • dependencies between layers and the direction of those dependencies;
  • prohibiting controllers from accessing repositories;
  • prohibiting service dependencies on controllers;
  • prohibiting repository dependencies on services and controllers;
  • placing classes in packages based on name, annotation, or inheritance;
  • the presence and use of annotations (@Controller, @Service, @Repository);
  • class and package naming rules;
  • prohibiting cyclic dependencies between packages;
  • the visibility of classes, methods, and fields;
  • dependencies between modules and restrictions on external libraries;
  • the placement of tests and production classes;
  • inheritance, interface implementation, and calls to specific types.

Some architectural rules are worth implementing as a hidden test suite: they run in CI but are not exposed to the AI agent or included in its working context. These checks verify that a change genuinely follows the project’s architectural principles rather than merely adapting to rules the agent already knows about. The hidden suite should complement the explicit rules that describe the expected project structure.

Example in a Project

├── Application.java
├── controller
├── model
├── monitoring
├── repository
├── service
├── utils
└── validation
Enter fullscreen mode Exit fullscreen mode

A dependency is added to the module's build.gradle:

testImplementation 'com.tngtech.archunit:archunit-junit5:1.5.0'
Enter fullscreen mode Exit fullscreen mode

The test combines restrictions on dependencies between layers with repository naming rules, a prohibition on Spring Web in services, and a project requirement for SAP processors: all DataProcessor implementations must inherit from StandardProcessor. The last check is shown both as a standard ArchUnit rule and through a custom ArchCondition.

@AnalyzeClasses(packages = "org.foo.bar")
class ArchitectureTest {

    @ArchTest
    static final ArchRule controllersDoNotAccessRepositories = noClasses()
            .that().resideInAnyPackage("..controller..")
            .should().dependOnClassesThat().resideInAnyPackage("..repository..");

    @ArchTest
    static final ArchRule servicesDoNotAccessControllers = noClasses()
            .that().resideInAnyPackage("..service..")
            .should().dependOnClassesThat()
            .resideInAnyPackage("..controller..");

    @ArchTest
    static final ArchRule repositoriesDoNotAccessUpperLayers = noClasses()
            .that().resideInAnyPackage("..repository..")
            .should().dependOnClassesThat()
            .resideInAnyPackage("..controller..", "..service..");


    @ArchTest
    static final ArchRule repositoryInterfacesUseRepositorySuffix =
        classes()
                .that().areInterfaces()
                .and().resideInAPackage("..repository..")
                .should().haveSimpleNameEndingWith("Repository");

    @ArchTest
    static final ArchRule servicesDoNotUseSpringWeb =
        noClasses()
                .that().resideInAPackage("..service..")
                .should().dependOnClassesThat()
                .resideInAnyPackage("org.springframework.web..");


    @ArchTest
    static final ArchRule sapProcessorsUseTheStandardProcessingPipeline = classes()
            .that()
            .resideInAPackage("..sap.integration..")
            .and()
            .implement(DataProcessor.class)
            .should()
            .beAssignableTo(StandardProcessor.class);

    // Custom condition to check if a class implements DataProcessor but does not extend StandardProcessor.
    // It allows you to provide a more descriptive error message when the rule is violated.

    private static final ArchCondition<JavaClass> customCondition = new ArchCondition<>("extend StandardProcessor") {
        @Override
        public void check(JavaClass processor, ConditionEvents events) {
            if (processor.isAssignableTo(StandardProcessor.class)) return;

            var message = "%s implements DataProcessor but does not extend StandardProcessor"
                .formatted(processor.getFullName());
            var event = SimpleConditionEvent.violated(processor, message);

            events.add(event);
        }
    };

    @ArchTest
    static final ArchRule dataProcessorsExtendStandardProcessors = classes()
            .that().resideInAPackage("..sap.integration..")
            .and().implement(DataProcessor.class)
            .should(customCondition);

}
Enter fullscreen mode Exit fullscreen mode

Conclusion

ArchUnit, of course, does not completely eliminate the need for review and business logic testing, but it reliably protects agreements about the project's structure. A small set of rules is especially useful in teams where code is created or modified by AI agents: CI can immediately identify architectural violations. ArchUnit provides additional and almost unlimited flexibility through the ability to create custom conditions and rules, making it possible to adapt checks to the specific requirements of a project.

Top comments (4)

Collapse
 
raknaos profile image
Raknaos

The part I keep turning over after reading this: a rule set catches architectural drift, but an agent optimising against the rules drifts in a way the rules cannot see. "Services must not depend on controllers" does not stop a service from calling something that is a controller under another name — it just moves the violation behind a package that satisfies the predicate. I have watched exactly that happen when the agent is told the build is red.

Same gap for the behaviour you name first: reinventing a utility instead of importing the existing one is architecturally legal, so no layer rule fires on it. Do you have anything in the suite that fails on duplication of an existing helper, or is the honest answer that ArchUnit covers the structure and the verbosity problem still lands on the reviewer?

Collapse
 
lbobylev profile image
l3o6

There is no fully reliable static analysis method for semantic duplication. Code duplication can be detected reasonably well by advanced linters like SonarQube.

Collapse
 
jo-do profile image
Jo Do

Architectural drift is the slow leak of agent-generated code, and executable rules are a good match for it because agents are tireless at re-violating whatever is not enforced. The layering examples are the right starting set. One I would add from experience: a rule against new top-level packages without an explicit module boundary - agents love inventing a fresh home for code instead of finding the existing one, and import-level rules alone will not catch it.

Collapse
 
lbobylev profile image
l3o6

Agreed. A custom ArchUnit rule can help here by restricting new top-level packages unless they are explicitly declared as module boundaries.