In our projects we have been using ArchUnit for years to write unit tests that fail when the code no longer follows the architecture we chose. Over the last year, we’ve written more and more of these tests. What changed? Our audience grew. These tests no longer just help fellow developers stick to the architecture; they help our coding agents too.

Coding agents are getting quite good at making larger changes to existing codebases. Give an agent a task, some context and a test suite, and it often finds its way surprisingly well. But one type of context is much harder to provide: the architecture we intended. That is exactly the gap ArchUnit fills.

In this blog post I explain why ArchUnit becomes more valuable when working with coding agents, and I show some rules that actually help to guide them. The list is far from complete, but I hope it inspires you to capture your own architecture in rules, whether you write them yourself or let your agent do it.

Why ArchUnit and coding agents are a good match

An agent learns a lot from the code that is already there. Unfortunately, that includes the bad parts. If three classes call Instant.now() directly, there is a good chance the fourth one will do the same.

We can write down in an AGENTS.md that we want to use an injected Clock. That gives the agent context about our decision, but it is still just a suggestion. An ArchUnit test turns the constraint into an executable guardrail: it fails when the code violates the rule and explains why.

Agents respond well to clear test failures. When the architecture tests run as part of the build, they become part of the agent’s feedback loop:

Feedback loop of a coding agent: change code, compile, test, ArchUnit, and fix violations when a rule fails

Choosing rules that matter

The rules that matter are the ones that remove implementation choices we already decided we don’t want. Code can compile, work and pass its functional tests while taking a shortcut we never intended. Those shortcuts are exactly what we want to catch.

Below you’ll find some rules I think are valuable when working with agents. Your actual rules might differ depending on the intended architecture of your codebase.

The examples use ArchUnit 1.5.1 with JUnit 6.

You can find all these rules on my GitHub, together with code that complies with them and a Maven profile that adds code violating each rule.

1. No deprecated APIs

An API suggested by an agent may already be deprecated. ArchUnit ships a rule for this in GeneralCodingRules:

@ArchTest
static final ArchRule no_deprecated_api = GeneralCodingRules.DEPRECATED_API_SHOULD_NOT_BE_USED;

2. No forbidden libraries or APIs

An agent may reach for a legacy date API, an old HTTP client or that one JSON library we are trying to get rid of. This rule makes those choices explicit:

@ArchTest
static final ArchRule no_forbidden_apis = noClasses()
    .should().dependOnClassesThat()
    .belongToAnyOf(java.util.Date.class, java.util.Calendar.class)
    .orShould().dependOnClassesThat()
    .resideInAnyPackage("org.apache.http..", "org.json..")
    .because("we use java.time, the Spring RestClient and Jackson");

3. Use the abstractions we introduced

Once we’ve introduced an abstraction, we don’t want an agent to go around it. Time is the classic example: code using Instant.now() is hard to test. This predicate checks both calls and method references to the listed methods, so it also catches Instant::now.

@ArchTest
static final ArchRule no_direct_system_clock_access = noClasses()
    .should().accessTargetWhere(describe("access the system clock directly", access -> Set.of(
        "java.time.Instant.now()",
        "java.time.LocalDateTime.now()",
        "java.lang.System.currentTimeMillis()"
    ).contains(access.getTarget().getFullName())))
    .as("no classes should access the system clock directly")
    .because("time should be retrieved using the injected java.time.Clock");

When an agent does call Instant.now() the test fails with the following message:

java.lang.AssertionError:
Architecture Violation [Priority: MEDIUM] - Rule 'no classes should access the system clock directly, because time should be retrieved using the injected java.time.Clock' was violated (1 times):
Method <com.jdriven.example.order.OrderService.complete(java.lang.String)> calls method <java.time.Instant.now()> in (OrderService.java:20)

Note the because at the end of the rule. It ends up in the failure message, so the agent not only learns what is wrong but also what to use instead. For example, it can replace Instant.now() with Instant.now(clock), where clock is injected into the class. That overload is allowed by the rule.

The same approach works for UUID.randomUUID() when you have an ID generator, System.getenv() when you use configuration properties, or direct filesystem access when you have a storage abstraction.

4. Keep the chosen HTTP APIs in the clients

When an agent needs data from another system, it may introduce an HTTP call directly in the business code. That creates a second integration path without the timeouts, retries and error handling of the existing clients. This rule keeps direct dependencies on the listed HTTP libraries in the client package; add other client APIs if your project uses them.

@ArchTest
static final ArchRule http_apis_only_in_clients = noClasses()
    .that().resideOutsideOfPackage("..client..")
    .should().dependOnClassesThat()
    .resideInAnyPackage(
        "java.net.http..",
        "org.springframework.web.client..",
        "okhttp3..")
    .because("external systems are accessed through the clients in the client package");

5. Don’t bypass security

A functional test can pass even when a controller decodes a JWT itself, a service encrypts data with its own Cipher setup, or business code reads the user from SecurityContextHolder. Security is exactly where we don’t want an agent to improvise. This rule keeps direct dependencies on token, crypto and security context APIs inside the security package.

@ArchTest
static final ArchRule security_apis_only_in_security = noClasses()
    .that().resideOutsideOfPackage("..security..")
    .should().dependOnClassesThat()
    .resideInAnyPackage(
        "io.jsonwebtoken..",
        "com.auth0.jwt..",
        "com.nimbusds..",
        "javax.crypto..")
    .orShould().dependOnClassesThat()
    .haveFullyQualifiedName("org.springframework.security.core.context.SecurityContextHolder")
    .because("authentication, tokens and cryptography are handled by the security package");

6. Controllers don’t return entities

Returning a JPA entity from a controller is a shortcut an agent can take. It exposes our data model in the API, so changes to the entity can unintentionally become API changes. We also want to catch entities inside return types such as List<OrderEntity> and ResponseEntity<OrderEntity>, so we use a custom condition:

@ArchTest
static final ArchRule controllers_do_not_return_entities = methods()
    .that().areDeclaredInClassesThat().areAnnotatedWith(RestController.class)
    .should(new ArchCondition<JavaMethod>("have no entities in their return type") {
        @Override
        public void check(JavaMethod method, ConditionEvents events) {
            boolean returnsEntity = method.getReturnType().getAllInvolvedRawTypes().stream()
                .anyMatch(returnType -> returnType.isAnnotatedWith(Entity.class));
            events.add(new SimpleConditionEvent(method, !returnsEntity,
                method.getDescription() + " has an entity in its return type"));
        }
    })
    .because("controllers return DTOs, not entities");

getAllInvolvedRawTypes() includes generic arguments and array component types, so the rule also rejects wrapped entities and entity arrays.

7. Keep the listed concurrency APIs in the async package

Ask an agent to make something faster and it may introduce a CompletableFuture, an ExecutorService or a Thread. Concurrency is something we want to introduce deliberately. This rule keeps direct dependencies on the listed types in our async package. Not that it does not cover every concurrency mechanism; parallel streams and other executor implementations need their own checks.

@ArchTest
static final ArchRule concurrency_only_in_async = noClasses()
    .that().resideOutsideOfPackage("..async..")
    .should().dependOnClassesThat()
    .belongToAnyOf(
        CompletableFuture.class,
        ExecutorService.class,
        Executors.class,
        Thread.class)
    .because("concurrency is handled in the async package");

8. Don’t disable failing tests

An agent struggling with a failing test may take a shortcut and disable it. The build is green again, and the agent reports the task as done.

ArchUnit can also check our test classes, so we can prohibit @Disabled on both classes and methods with a separate test class that only imports tests:

@AnalyzeClasses(packages = "com.jdriven.example", importOptions = ImportOption.OnlyIncludeTests.class)
class TestConventionsTest {

    @ArchTest
    static final ArchRule no_disabled_tests = noMethods()
        .should().beAnnotatedWith(Disabled.class)
        .because("fix the test or the code, don't disable the test");

    @ArchTest
    static final ArchRule no_disabled_test_classes = noClasses()
        .should().beAnnotatedWith(Disabled.class)
        .because("fix the tests or the code, don't disable the test class");
}

Conclusion

It’s quite satisfying to see an agent run into an ArchUnit violation, read the message and fix its own code, without having to explain the architecture again and again.

The rules I find most useful enforce implementation choices and dependency boundaries. Package boundaries can keep business code away from HTTP clients or JWT libraries; cosmetic naming conventions provide less guidance. These rules take away shortcuts an agent might otherwise take, and the because tells it what to do instead.

ArchUnit tests used to be mainly a way to keep ourselves honest. Now they also tell our coding agents what we expect, and give them the guardrails they need.

shadow-left