Back to blog

Why AI Coding Agents Cause Architecture Drift (and Why CLAUDE.md Won't Fix It)

AI coding agents generate locally correct code that can silently cross architectural boundaries. Instruction files like CLAUDE.md don't reliably prevent this—they're probabilistic input, not deterministic constraints. This article explains why, with a concrete enforcement example.

You add a new feature with Claude Code. The PR passes tests. The code is readable. A reviewer approves it. Three weeks later, someone notices that a React component is querying the database directly, bypassing the service layer your team spent months establishing.

This is architecture drift: locally correct code that violates structural constraints. It happens with human engineers too, but AI coding agents make it faster and more systematic. The agent solved the immediate problem—get the right data into this component—without tracking the layer boundary it crossed to do so.

Adding a rule to your CLAUDE.md won't reliably prevent this. Here's why, and what actually works.

What architecture drift looks like in AI-generated code

Architecture drift in AI-generated code is rarely obvious at first glance. The agent doesn't produce syntax errors or failed tests. It produces code that works—compiles, passes unit tests, satisfies the PR description—but violates a structural constraint the tests don't cover.

A common pattern: an agent adds a feature to a UI component. The fastest path is to import the database client directly.

// feature added by AI agent
import { db } from '@/lib/db';

export function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    db.user.findUnique({ where: { id: userId } }).then(setUser);
  }, [userId]);

  // ...
}

The code works. But the intended architecture requires UI components to call service functions, which call repository functions, which call the database. The agent skipped two layers.

This pattern repeats across a codebase when multiple agent sessions, each solving a local problem, reach for the same shortcut. Individual changes look defensible in isolation. The accumulated effect breaks the architecture.

Why agents drift even when you have a CLAUDE.md

A CLAUDE.md rule like this is common:

Architecture rules:
- UI components must not import directly from @/lib/db
- Use service functions from @/services/ instead

The agent reads this rule. It might even acknowledge it. Then it still generates the violating import.

This isn't a bug in the model. It's a consequence of how instruction-following works in large language models. Instructions in a CLAUDE.md are text the model receives as context—they influence the probability distribution of the model's output, but they don't constrain it.

Several factors degrade instruction reliability:

Context competition. Every token in the context window—existing code, the user's request, conversation history, the full CLAUDE.md—competes for the model's attention. A rule written 200 lines into a 400-line CLAUDE.md is not equally weighted against the immediate pressure to solve the current task.

Local problem framing. The agent's immediate problem is "make this component fetch user data." The architectural rule is a global constraint. When the agent reasons about the local problem, the global constraint can recede from its effective working context.

Probabilistic compliance. There is no threshold at which the agent simply stops following a rule. Compliance degrades gradually and unpredictably. You can't know in advance which requests will produce violations.

Users of Claude Code have documented this in the project's own issue tracker (issue #7777): after a few prompts in a session, agents begin ignoring instructions even when the CLAUDE.md is present and loaded. The exact degradation mechanism is not publicly documented by model providers, but the pattern is consistent across multiple independently reported cases.

The core issue isn't instruction count. It's that any instruction-based approach is probabilistic by nature.

The difference between an instruction and a check

An instruction is text the model receives as input. A check is a scan that runs on the model's output.

Instructions advise. Checks enforce.

A pre-merge check that scans for forbidden imports doesn't care what the agent intended or what the CLAUDE.md says. It either finds a forbidden import path or it doesn't. The result is deterministic—the same input produces the same output every time.

Consider the two paths:

With a CLAUDE.md rule: the agent generates code, the rule may or may not influence the output, and a reviewer must catch the violation if one occurs.

With a CI check: the agent generates code, the check runs on the PR, and the violation is caught before merge regardless of what the agent intended.

The check doesn't make the agent smarter. It makes the failure mode visible before it enters the codebase.

What pre-merge architecture checks look like

A forbidden import check for the example above can start as a simple command run on files in the UI layer:

# Fail the PR if any component imports @/lib/db directly
grep -r "from '@/lib/db'" src/components/ && exit 1

This runs in CI on every pull request. If an agent—or a human—adds a direct database import to a component file, the PR fails. The check doesn't require a reviewer to notice the violation.

For more complex constraints—import chains, transitive dependencies, layer resolution—tools that understand the module graph are more appropriate. But the principle is the same: the check is deterministic, runs automatically, and produces a definite pass or fail.

Tree-sitter queries can match specific AST patterns in a language-aware way:

; Flag imports of the db client from within component files
(import_statement
  source: (string) @import_path
  (#match? @import_path "lib/db"))

This matches on the AST, not raw text, so it's not fooled by string formatting differences. The query either matches a node or it doesn't.

What matters is that the check runs on every pull request, not just the ones a reviewer happens to scrutinize closely. AI-generated PRs can introduce the same layer violation across multiple files in a single session. Periodic manual review is not a reliable gate.

Where CLAUDE.md still has value

CLAUDE.md is useful for things that don't need to hold without exception: naming conventions, code style, preferred library choices, context about the project structure, how to run tests, what to explain in PR descriptions. These are cases where occasional deviation is acceptable or where a reviewer's judgment is the appropriate final gate.

The mistake is treating CLAUDE.md as a mechanism for enforcing architectural constraints that must hold without exception. It wasn't designed for that role, and it doesn't perform reliably in it.

Think of CLAUDE.md as shaping the agent's default behavior. Checks catch violations when those defaults fail—which will happen.

The minimum enforcement surface for architecture

Most codebases don't need elaborate architecture enforcement to address the common AI-generated failure modes. Two categories of rules cover most of the important cases.

Forbidden import rules. Which modules must not import from which other modules. UI components must not import database clients. Controllers must not import infrastructure adapters. Route handlers must not call external HTTP clients directly. These are expressible as simple path patterns that can be checked in CI.

Layer boundary rules. Which directories may call which other directories. A check that enforces src/components/ → src/services/ → src/repositories/ → src/lib/ can catch cross-layer violations automatically, whether the violation was introduced by an agent or by a developer in a hurry.

These checks don't need to be intelligent. They need to run on every PR and return a deterministic result.

If you're using an AI coding tool and noticing repeated layer boundary violations in generated code, Unbx implements pre-merge architecture checks of this kind—forbidden import rules and layer boundary enforcement that run on every pull request before merge.