Architecture Tests as a Seatbelt Against Agentic Drift

We just wired ArchUnit into the CrushLog backend. The goal wasn’t to win an architecture-purity contest. It was to answer a question that gets sharper every month we lean harder on AI coding agents:
When a tireless agent makes a hundred plausible-looking changes, what stops the architecture from quietly eroding?
We call that erosion agentic drift, and architecture tests turned out to be a remarkably good seatbelt against it.
Where this fits in the series
Part 1 of this series used Spring Modulith to turn module boundaries into build failures. Part 1 ended on a deliberate note: module boundaries matter even more in the age of AI-assisted coding, because they’re guardrails that don’t depend on a human holding the whole picture in their head.
This post is the next layer down. Here’s a detail worth knowing: Spring Modulith’s verify() is built on ArchUnit under the hood. Modulith gives you a curated, module-boundary-shaped slice of that engine. ArchUnit is the engine itself — and reaching for it directly lets you express invariants that module boundaries can’t: “every mutation must be authorized,” “no field injection,” “this annotation is banned in tests.” Same enforcement philosophy, finer granularity.
What agentic drift actually looks like
A human author of a change carries the whole mental model: “mutations need @PreAuthorize,” “don’t block inside a command handler,” “domain handlers don’t import generated GraphQL types.” An AI agent carries the model of the diff in front of it. Each individual change is locally reasonable. The drift is emergent — it shows up across dozens of edits, none of which looks wrong on its own.
The usual defenses don’t catch this well:
- Code review scales with reviewer attention, and reviewers pattern-match on the lines that changed, not the invariant that silently weakened three files away.
- A
grepaudit finds what you thought to search for. It’s blind to the variant you didn’t anticipate. - A style linter sees syntax, not architecture.
What you want is an executable statement of the invariant itself — one that fails the build the instant any change, human or agent, violates it. That’s ArchUnit.
The rules we locked in
Our CLAUDE.md already encoded the rules in prose. Prose doesn’t fail a build. So we translated the highest-value invariants into tests, with one hard philosophy: fail hard, no tolerated baseline. A baseline file is just drift with a paper trail. If a rule finds a real violation, you fix it in the same change or you don’t ship the rule.
We landed three stacked PRs: security, conventions, and an Axon CQRS boundary. Here’s what the rules actually look like — these are JUnit 5 + ArchUnit, which is the library’s native idiom, even though everything else in our suite is Spock/Groovy.
Security: every mutation is authorized
The headline rule is small and reads almost like the English sentence it replaces:
@ArchTest
static final ArchRule mutationsMustBeAuthorized =
methods()
.that()
.areAnnotatedWith(DgsMutation.class)
.and()
.areNotAnnotatedWith(PublicMutation.class)
.should()
.beAnnotatedWith(PreAuthorize.class)
.because(
"every GraphQL mutation must enforce authorization; intentionally public"
+ " mutations must be marked @PublicMutation");The exemption is the interesting design choice. Instead of keeping a list of “mutations that are allowed to be public” in some file nobody reads, the opt-out lives at the call site and is forced to carry a reason:
@Documented
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface PublicMutation {
/** Human-readable justification for why this mutation is safe to expose unauthenticated. */
String value();
}And a second rule makes the justification non-optional — a blank reason fails the build just like a missing one:
@ArchTest
static final ArchRule publicMutationsMustJustifyTheExemption =
methods()
.that()
.areAnnotatedWith(PublicMutation.class)
.should(/* ArchCondition: reason must be non-blank */ ...)
.because(
"each public mutation exemption must document in code why it is safe"
+ " to expose unauthenticated");There’s a subtle bypass we also had to close. A mutation declared with @DgsData(parentType = "Mutation") instead of the conventional @DgsMutation would slip past the authorization rule entirely — it’s a mutation that the first rule can’t see. So a third rule bans that form outright, forcing every mutation through the annotation the security rule actually checks. This is the kind of gap that’s obvious once stated and invisible in review.
We also keep the security context from leaking out of its package — callers must go through the reactive supplier, never touch the holder directly:
@ArchTest
static final ArchRule securityContextStaysInternal =
noClasses()
.that()
.resideOutsideOfPackage("pro.crushlog.infrastructure.security..")
.should()
.dependOnClassesThat()
.areAssignableTo(CrushlogSecurityContext.class)
.because(
"callers must use ReactiveAuthenticationSupplier.getUserId(), never"
+ " CrushlogSecurityContext directly");Conventions: scheduling and injection
Two more invariants that reviewers used to enforce by memory. Every @Scheduled job must either be distributed-locked (@SchedulerLock) so exactly one pod runs it, or be explicitly marked @PerPodScheduled("reason") when running per-pod is intentional. And production code uses constructor injection only — no field or setter @Autowired:
@ArchTest
static final ArchRule noFieldAutowired =
noFields()
.should()
.beAnnotatedWith(Autowired.class)
.because("constructor injection only — never field/setter @Autowired");These are scoped to production code via ImportOption.DoNotIncludeTests, so legitimate test-side wiring isn’t affected.
Test hygiene: green regression guards
Some rules guard against reintroduction of a problem you’ve already cleaned up. We ban context-cache-poisoning annotations in tests — @DirtiesContext and the Spring-Boot-4-removed @MockBean/@SpyBean — so the Spring test context stays shared across the whole suite. These are matched by fully-qualified string name so they keep guarding even after the legacy types leave the classpath:
@ArchTest
static final ArchRule noDirtiesContext =
bansAnnotation(
"org.springframework.test.annotation.DirtiesContext",
"@DirtiesContext rebuilds the Spring test context, destroying context-cache"
+ " sharing across the suite");These rules are green today. That’s the point — they’re a tripwire for the next agent (or human) who reaches for the convenient-but-wrong annotation.
The Axon boundary
The third PR enforced a CQRS rule: Axon command/event handlers may not depend on generated GraphQL types — the domain stays expressed in domain types. In ArchUnit that’s the classic package-dependency shape:
noClasses()
.that().resideInAPackage("..domain..") // CQRS handlers
.should().dependOnClassesThat().resideInAPackage("..generated..")
.because("the domain must not leak into the GraphQL transport layer");Each rule ships with a negative-control proof: we deliberately break the invariant once and confirm the test goes red. A green architecture test that can’t fail is decoration.
The interesting part: the rules found things we didn’t
This is where the agentic-drift framing stops being theory.
The exemption a human audit missed
Before writing the “mutations must be authorized” rule, we did the responsible thing — a manual grep audit for unauthenticated mutations. It found two (token-based guardian-consent flows, legitimately public).
The ArchUnit rule, on its first red run, found four. It surfaced a second pair — acceptCoachingInvitation / declineCoachingInvitation — that the grep missed. They turned out to be legitimately token-based too, so we marked them @PublicMutation with a justification rather than bolting on auth. But the lesson is the point: the audit I trusted was incomplete, and the executable rule wasn’t. That gap is exactly the shape agentic drift takes.
A boundary violation nobody expected
The “Axon handlers don’t depend on generated types” rule was supposed to fix two known spots. The red run found three — a stray dependency in the admin module’s content-review service that no one had flagged. We fixed it properly (introduced the missing domain ActivityType enum and routed through it) instead of widening the rule to let the violation slide.
A real latent production bug
The most valuable find wasn’t even a rule we shipped. While auditing whether a “no .block() in reactive code” rule was feasible, the scan turned up ~75 blocking call sites. Most clustered into legitimate carve-outs (scheduled jobs, async tasks, Temporal activities). But one stood out: SocialCommandHandler calls .block() on a reactive Neo4j repository inside an @CommandHandler — the exact fingerprint of the 90-second command stalls we’ve chased in production before. The audit found a genuine latent bug hiding in plain sight, flagged for its own fix.
The rule we refused to write
Honesty matters more than green checkmarks. We wanted a rule enforcing “Lombok @RequiredArgsConstructor / @Builder everywhere” to kill boilerplate.
We couldn’t — and didn’t fake it. Lombok annotations are @Retention(SOURCE): they’re deleted before bytecode, so they’re invisible to ArchUnit, which reads bytecode. The fallback — “beans should have a single constructor and only final fields” — we measured: 510 non-final fields across 54 classes, 20 multi-constructor classes (mostly Axon aggregates that need a no-arg constructor). Forcing that rule green would have meant a giant baseline file, i.e. drift with a paper trail.
So we omitted the rule and reached for the right tool instead: a lombok.config plus an OpenRewrite migration. A rule you have to baseline into uselessness is worse than no rule — it gives false confidence, which is precisely what you can’t afford when agents are doing the typing.
Why this matters more in the agent era
None of these techniques are new. Architecture tests predate the current wave of AI coding by a decade. What’s changed is the rate of plausible change. An agent can produce correct-looking diffs faster than any review process can absorb the architectural context behind them. The defenses that scale are the ones that don’t depend on a human holding the whole model in their head at review time:
- Encode the invariant, don’t just document it. Prose in a
CLAUDE.mdguides an agent; an executable test stops it. - Fail hard, no baseline. A tolerated-violation file is a slow leak.
- Prove every rule can fail. Negative controls, or it’s decoration.
- Refuse rules you can’t enforce honestly. A faked-green rule is worse than none.
- Mark exemptions at the call site with a reason.
@PublicMutation("…")beats a list in a file nobody reads — it travels with the code and survives review.
The agent is fast and tireless. Give it guardrails that are equally tireless. The build either stays within the architecture or it goes red — no vigilance required, no context lost, no drift.
This is Part 3 of the Spring Modulith in Practice series. Part 1: Enforcing Architecture in a Growing Monolith. Part 2: From Code to Living Architecture Diagrams.
Cover photo by Maxim Hopman on Unsplash.


