Coding Agents Write Good Code and Bad Architecture. One Rule Catches Most of It.

Why AI Coding Agents Ignore Your Architecture

I noticed this on one of the projects I got to work on, a modular PHP monolith where every component had its own locking mechanism. Each one had made sense on the day it was written, and each one had been written separately. When an agent needed locking in yet another component, it did what the codebase had taught it: it wrote one more. The code was fine — it compiled, the tests passed, the naming was better than mine. What it missed was the pattern. By then the job was not "add a lock here" but "notice there are already several and extract a shared one", and the agent produced the next workaround instead.

That is the pattern this page is about: LLMs are good at writing code and still poor at extending an existing architecture. They do not reuse what is already there, they do not grow the abstraction that should absorb the change, and they reach for the locally cheapest fix. On a large codebase that is how a clean module turns into a pile of workarounds within a few months. The fix I use is not a better prompt. It is one rule in project memory and a planning step before any task that adds something new.

Agents pass the tests and still bend the architecture

Functional correctness and design quality are separate measurements, and agents now score well on the first and poorly on the second. The clearest study I have found is "Beyond Functional Correctness" (arXiv 2604.06373, April 2026). Its authors built ten projects of 8,600 to 28,500 lines each — React, Spring Boot, Django, CodeIgniter, a WordPress plugin — entirely with Cursor, then tested each against its requirements and ran CodeScene and SonarQube over the result.

The projects met 91% of their functional requirements on average. The design analysis is another story:

Tool Top finding Share of issues
CodeScene (1,305 issues) Code duplication 28.4%
CodeScene Complex methods 27.9%
SonarQube (3,193 issues after removing false positives) Framework best-practice violations 35.3%
SonarQube Dead, duplicate or redundant code 12.4%

The top SonarQube category is the one that matches what I see: the code works, but not the way the framework expects to be extended. One example from the paper is a quiz service with six methods holding about 150 duplicated lines and 30 unique ones — six copies of a thing that wanted to be one parameterised method. The paper also cites earlier work finding that LLM agents "frequently disregard opportunities for code reuse, resulting in greater redundancy than human developers".

Two caveats keep this honest. It is one tool, one researcher and ten projects, and the authors say static analysis is better at file-level problems than at system architecture — coupling and module boundaries mostly go undetected. So the real architectural damage is, if anything, under-counted.

GitClear's 2026 "Maintainability Gap" report points the same way across 623 million changes. Code moved during refactoring fell from 21% of changed lines in 2022 to 3.8% in 2026, duplicated five-line blocks rose 81% since 2023, and new code calls another existing method 35% less often. GitClear does not say how it attributes code to AI, so this is a trend over the years AI adoption grew, not proof. But it is the shape you would expect if more and more code is written alongside the existing code rather than into it.

A workaround is the cheapest correct answer the agent can see

An agent extends what it has read, and it reads what the task description sends it to. Ask it to "send a Telegram notification when an order ships" and it will search for order, ship, maybe notification. It finds the shipping handler. It may never open the directory where the existing notification channels are registered, because nothing in the task points there. From inside that context window, writing the Telegram call into the shipping handler is the simplest correct solution — and the agent is very good at finding the simplest correct solution to what it can see.

Three things make the workaround win:

  1. Search is shaped by the task, not by the architecture. The agent looks for words from the ticket. Extension points are named after the abstraction (ChannelInterface, NotifierRegistry), not after this week's feature.
  2. Creating is cheaper than learning. Reusing a component means reading its interface, its configuration and two of its existing implementations. Writing a new private method means none of that. An agent optimising for a finished task picks the second unless something stops it.
  3. The tests pass either way. A workaround and a proper extension produce the same behaviour today. Nothing in the feedback loop says the first one will cost you the next three features.

None of this is a model being careless. It is a model doing exactly what the context rewards. The fix therefore has to change the context, not the model's diligence.

Why I see it in PHP and not in Go

I run into this constantly on PHP projects and rarely on Go ones, and I think two things explain it. The first is what the models learnt from. There is an enormous amount of PHP on the public internet, and a large share of it is procedural, framework-free, copy-and-adjust code from two decades of plugins and tutorials. A model that has seen that much of it reproduces its habits: the extra if, the helper function, the static call that bypasses the container. Go's public corpus is younger, smaller and written mostly in one style that gofmt and the standard library pushed everyone towards.

The second is that in a modern PHP framework, the architecture is invisible from the file you are editing. In Symfony, a new notification channel is a class that implements an interface and gets picked up by autoconfiguration — the wiring lives in an attribute, a service tag and a compiler pass, not in any code that calls it:

<?php

declare(strict_types=1);

namespace App\Notification;

use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;

// The extension point: every channel is collected into NotificationSender automatically
#[AutoconfigureTag('app.notification_channel')]
interface NotificationChannel
{
    public function supports(Recipient $recipient): bool;

    public function send(Recipient $recipient, Message $message): void;
}

An agent editing OrderShippedHandler sees none of that. It sees a handler that calls $this->sender->send(...) and a task that says "Telegram". What it writes is this:

// WRONG - a second notification path that bypasses every registered channel
if ($order->customer()->prefersTelegram()) {
    $this->httpClient->request('POST', $this->telegramUrl, ['json' => [...]]);
}

The right change is a new TelegramChannel class with two methods and no edit to the handler at all. In Go, the equivalent wiring is explicit — the constructor takes a []Channel, main.go builds the slice, and an agent reading the handler can follow the dependency to where the channels come from. The convention is in the code, so the agent reads it. In Symfony the convention is in the container, the event dispatcher, attributes and YAML, and the agent has to know to look.

So the pattern is worst exactly where frameworks are most helpful to humans: lots of convention, little explicit wiring. Laravel's service providers and facades, Symfony's tagged services and event subscribers, Doctrine listeners — every one is an extension point an agent will route around unless it is told to look for one.

The greppable thing the agent never greps

Existing extension points pass the grep test, and the agent still does not find them — which is why "look for one" belongs in your instructions. In what belongs in CLAUDE.md I argued that a line earns its place only if the agent cannot recover the same fact with a couple of greps. A tagged interface is entirely greppable. grep -r AutoconfigureTag src/ finds every one of them in a second.

The catch is that the agent never runs that search, because nothing in its task tells it to. The fact is discoverable; the habit of discovering it is not. That is the difference between a fact and a procedure, and procedures are the one thing instruction files are reliably good for. The rule below does not tell the agent what the architecture is — it can find that out. It tells it when to go and look.

The rule: say the new unit out loud before creating it

My project memory carries one line for this, and it is the cheapest architectural control I have:

- Before creating a new architectural unit (a service, interface, event, listener, module,
  helper, config key - anything the current code does not have yet), state the decision and
  why first. There may already be a place to reuse, or an existing one that should be
  extended or improved instead.

Two words in it do the work. "Architectural unit" is broad on purpose — the workaround is rarely a whole new module; it is a new private method, a new if, a new helper class. And "state the decision" turns a silent choice into a sentence I can read in two seconds. When the agent writes "I'll add a TelegramNotifier service", I can answer "there's a NotificationChannel interface, implement that" before a single file changes. Often the agent finds the existing component itself while composing that sentence. With the locks from the opening, "I'll add a lock to this component" is exactly the sentence that should have triggered the other answer: there are several of these already, so extract a shared mechanism and move this component onto it. Symfony even ships a Lock component to build it on.

Where the rule lives matters less than that it fires before the code is written:

Where What it catches Cost
A line in project memory or CLAUDE.md Most new units, because it is in context on every task A few dozen tokens per session
A planning step for multi-file tasks The architectural ones — the plan has to name existing modules it will touch One extra round-trip per task
Code review only Everything, but after the code exists and the agent has moved on The rewrite
Nothing Nothing The fourth workaround on the same feature

I use the first two together. The memory line covers small tasks, where a planning step would be overkill. For anything with three or more steps, I ask for a plan first, as I described in my AI coding agent workflow, and the plan has to answer one question explicitly: which existing components will this change extend, and what is genuinely new? If the answer is "all new", that is the moment to push back. A custom instructions generator gives you a lean baseline file for Claude Code, Cursor or Codex to put the rule into.

The rule works because it does not try to make the model smarter about architecture. It moves the architectural decision to the one point where a human can check it cheaply — before the code — and it makes the agent do the search it would otherwise skip. That is the same thing that made narrow, explicitly invoked procedures pay off in my notes on agent skills: you do not get judgement out of an instruction, but you can get a reliable checkpoint.

When writing alongside is the right call

Reuse is not always correct, and in a modular monolith the agent sometimes duplicates for the right reason. Module boundaries exist to stop one module depending on another's internals. If the billing module needs a slightly different address formatter from the shipping module, importing shipping's formatter couples two modules that were deliberately separated. A small, local copy is cheaper than that coupling — DRY is a rule inside a module, not across bounded contexts.

The same applies when the existing component is the problem. If the only "reusable" notification code is a 900-line class that already does five things, extending it makes the debt worse. The right move is the third option in my rule — improve the existing unit, or extract the part you need — and that is a decision I want to make, not one I want the agent to make silently in either direction.

So the rule asks for a stated decision, not for reuse at all costs. "I'm writing a separate formatter in Billing because Shipping's is internal to that module" is a perfectly good answer. What I will not accept is the same separate formatter with no sentence in front of it.

Architecture Is the Part You Still Own

Agents now write the code; they do not yet decide where it should go. On a framework-heavy PHP codebase, assume every new unit an agent proposes might already exist under another name, and make it say so before it builds. One line in project memory and one question in every plan — "what existing component does this extend?" — cost almost nothing, and they are the difference between a codebase that grows and one that just gets bigger.

More Articles

Your CLAUDE.md Describes Your Folders. The Agent Already Knows Them.

ETH Zurich found repo overviews in context files don't help agents find code. What belongs in CLAUDE.md, memory, skills and hooks instead.

31 October, 2026

Six Months with Claude Code: The Workflow Habits That Cut My Token Spend

Six months with AI coding agents: when to delegate, how to manage context, and the habits that separate productive agent use from expensive frustration.

31 October, 2026

WebMCP Lets a Page Hand Agents Its Tools — If the Page Holds a Trial Token

WebMCP's Chrome origin trial now runs to Chrome 162, early 2027. The document.modelContext API, declarative forms, what broke for early adopters, the risks.

31 October, 2026