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
A dependency is added to the module's build.gradle:
testImplementation 'com.tngtech.archunit:archunit-junit5:1.5.0'
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);
}
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.