Workflow Architecture

Preventing Architectural Drift in a Codebase Agents Contribute To

David Guzenburg/ / 9 min read

No single pull request destroys an architecture. A hundred locally reasonable ones, made without a shared model of the system, do it reliably.

architecturedriftlintingcode review

Drift is a property of time, not of any one change

No single agent-authored pull request destroys a codebase's architecture. Each one is locally reasonable. Drift is what happens when a hundred locally reasonable changes, made without a shared model of the system, accumulate.

This is not new — codebases drifted before agents existed. What changed is the rate. A team producing three times as many changes drifts three times as fast, and the mechanism that used to slow it down was that a human held the architecture in their head while writing. That human is now reviewing rather than writing, and review catches different things.

How it actually shows up

DriftWhat you see six months on
Layer violationsDomain code importing the database client "just this once", forty times
Parallel abstractionsThree HTTP wrappers, each used by a third of the codebase
Convention forkingTwo error-handling styles split roughly by when the code was written
Boundary erosionA module that was internal now imported from six places
Duplicated logicThe same rule implemented independently in three services

Row two is the most characteristic of agent-heavy codebases. An agent that needs an HTTP client and does not find the existing one — because it was not in context — writes a reasonable one. It is good code. It is the third good one.

Why review misses it

Drift is structurally invisible to code review, and it is worth being precise about why, because the instinct is to blame reviewer diligence.

Review operates on a diff. A diff shows a change against its immediate parent, not against the shape of the system. A new HTTP wrapper is, in diff form, a well-written eighty-line module with tests. Nothing in the diff says "there are already two of these". Seeing that requires knowledge the review tool does not surface and the reviewer may not hold.

This was survivable when the person writing the code had spent a week in that part of the system. It is less survivable when the code was produced in twenty minutes by something with no memory of last week, and reviewed by someone with six other pull requests waiting.

Which means the answer cannot be "review harder". It has to be making the information available at authoring time — the next two sections — and mechanising what can be mechanised.

Make the existing thing findable

Most parallel abstractions exist because the agent did not know the original was there. That is a discoverability problem, and it has a cheap fix.

## Use these — do not write your own
- HTTP:      `src/adapters/http.py`   (retries, timeouts, tracing)
- Config:    `src/config.py`          (env parsing, validation)
- Logging:   `src/observability/log.py`
- Time:      `src/util/clock.py`      (injectable — never call
             datetime.now() directly, it breaks tests)
- Money:     `src/domain/money.py`    (never float for currency)

If one of these does not do what you need, extend it. Do not add
a second implementation.

Six lines, and they prevent the most common source of structural duplication. The parenthetical reasons matter more than they look — an agent that knows why the clock is injectable will not route around it when tests get awkward.

Enforce boundaries mechanically

Anything checkable should be checked, because a rule in an instruction file reduces violation probability while a lint rule reduces it to zero.

[importlinter]
root_package = src

[importlinter:contract:layers]
name = Layered architecture
type = layers
layers =
    src.adapters
    src.application
    src.domain

[importlinter:contract:no-parallel-http]
name = One HTTP client
type = forbidden
source_modules = src.domain, src.application
forbidden_modules = requests, httpx, urllib3

The second contract is the interesting one. It does not describe architecture; it prevents a specific recurring mistake. Contracts like that accumulate naturally — every time drift is caught in review, ask whether a rule could have caught it, and add one when the answer is yes.

Both, not either

The lint rule stops the violation merging. The instruction stops it being written, and tells the agent what to do instead. Without the instruction, a blocked agent guesses at a fix — and one plausible guess is to edit the lint config, which is why that file belongs on your protected-paths list.

Detecting drift you did not anticipate

Mechanical rules only catch what you thought to encode. For the rest, a periodic look at structural metrics is enough to spot trends early.

#!/bin/bash
# Quarterly structural snapshot. Compare against last quarter.

echo "== Modules importing the DB layer =="
grep -rl "from src.adapters.db" src/ | wc -l

echo "== Distinct HTTP call sites (should be 1) =="
grep -rlE "requests\.|httpx\.|urllib" src/ | wc -l

echo "== Files over 400 lines =="
find src -name '*.py' -exec wc -l {} + | awk '$1 > 400' | wc -l

echo "== Direct datetime.now() calls (should be 0) =="
grep -rn "datetime.now()" src/ --include='*.py' | wc -l

echo "== Distinct exception base classes =="
grep -rhoE "class \w+Error\(" src/ | sort -u | wc -l

The numbers matter less than their direction. A count that grew from 2 to 9 over a quarter is telling you something regardless of whether 9 is objectively bad.

Drift in the instruction file itself

An underappreciated case: the rules drift too.

Instruction files accumulate. A rule written for a module that no longer exists. A convention superseded two refactors ago. A prohibition against a library you removed. None produce errors, and all actively mislead — an agent told to follow a pattern the codebase abandoned will faithfully reintroduce it.

This is worse than an out-of-date README, because a human reading a stale README notices the mismatch against the code in front of them. An agent treats the instruction as authoritative and reconciles in the wrong direction.

Worth putting on the same quarterly cadence as the structural snapshot: read the instruction file top to bottom against the current codebase and delete anything no longer true. Fifteen minutes, and it consistently finds two or three rules that have been quietly making things worse.

The review question that catches it

Drift is invisible in a single pull request by definition, so reviewing for it requires a different question from the usual one. Not "is this change correct" but:

Does this do something the codebase already does, in a different way?

New helper functions, new error types, new configuration patterns, new ways of reaching a service — each is a candidate. Frequently the answer is that the existing thing was not findable, which is a fixable problem in your instruction file rather than a fault in the change.

Periodic consolidation

Some drift arrives regardless. Scheduling consolidation explicitly — an afternoon a quarter — works better than expecting it to happen opportunistically, because opportunistically means never.

Agents are genuinely good at this specific task. "Find every place that parses a date string and list the different approaches used" is exactly the kind of broad, mechanical survey they do well and people find tedious. The survey is the hard part; the consolidation, once you can see the whole picture, is usually straightforward.

Takeaway

Drift comes from volume, and volume went up. List the canonical utilities so the agent stops writing parallel ones, enforce layer boundaries with a linter, snapshot a handful of structural counts quarterly and watch direction, and add one review question: does this already exist here in a different form?

Keep reading
Context Architecture

Encoding Architectural Constraints and Module Boundaries for Agents

Why naming your architecture pattern doesn't work, how to state dependency directions as checkable rules, and why every prohibition needs an escape hatch.

Workflow Architecture

Finding Technical Debt: Parse for Candidates, Then Judge Them

Combining AST metrics with git churn to rank debt, using a model to rule out essential complexity, correlating with modules that actually produce bugs, and writing a register people act on.

Workflow Architecture

CI Gating for Agent-Generated Pull Requests

Why agents pass the traditional gate more easily than humans, seven checks worth adding, diff coverage over total coverage, and why model-graded review should never be the gate.

Tooling & Integration

Static Analysis in the Loop: Linters as Feedback, Not Just Gates

Machine-readable linter output, why agents suppress rather than fix, a CI check that blocks it, and which rules are worth enabling once an agent actions them for free.

← Designing Migration Instructions: Modernising Without Changing Behaviour  ·  Fine-Tuning for an Internal DSL: The Case Is Narrower Than It Looks →

All workflow architecture articles  ·  Every article