Katamari Architecture Isn't an LLM Problem — It's a Harness Problem
An LLM agent asked to add a date-picker to your settings will do it in ninety seconds. It also adds a new dependency, a `DateContext`, a `useDateRange` hook,...
An LLM agent asked to add a date-picker to your settings will do it in ninety seconds. It also adds a new dependency, a `DateContext`, a `useDateRange` hook, a second date library because the first lacked locale support, and a migration touching a table it was never told about. Nothing breaks. The feature ships. Your codebase got heavier, and nobody can explain why the calendar imports from billing.
This is katamari architecture: a sticky ball rolling across a codebase, accumulating whatever path-of-least-resistance touched. The name comes from Katamari Damacy. Agentic development takes the shortest path to a satisfied prompt — which almost never runs through composition or deletion.
Katamari architecture is not a property of the language model. It is a property of the harness around it. Remove the guardrails and the model rolls; add them and it builds.
The instinctive response — "LLMs are bad at architecture" — is wrong, and it leads to the wrong fix. Models have been trained on effectively all published software architecture. They know hexagonal ports, they know bounded contexts, they can recite Foote and Yoder's Big Ball of Mud from memory. The problem is not knowledge. It is that nothing in a default agent loop rewards composition, and several things actively punish it. Fix the incentives and the ball stops rolling.
Why the default agent loop produces mud, not modules
Compare katamari architecture to the Big Ball of Mud from 1999 and the drivers are almost inverted. Foote and Yoder blamed time, cost, experience, skill, visibility, complexity, and scale — human constraints. An LLM has none of the first three. It has unlimited time, a token budget you probably are not watching, and more architectural experience than your entire department. So why does it still produce a monolith?
Because the loop rewards a different variable: task completion probability. A model that edits three files and adds one dependency has a higher chance of passing your acceptance check than a model that spends forty minutes deciding the picker belongs in a shared design-system package. The first strategy is locally correct every single time. Compounded across a thousand PRs it is catastrophic.
- No memory of the design — the agent sees the current diff, not the six architectural decisions that made the surrounding code coherent. It cannot respect a boundary it was never shown.
- Deletion is invisible — you cannot write a test that fails when a duplicate abstraction is not removed. The reward signal sees additions, never subtractions.
- The path of least resistance is a dependency — importing a library is one line; understanding six existing modules is forty. The model takes the cheap move the same way an underpaid human does.
- Sycophancy cuts against pushback — ask "should this be a new service?" and a well-aligned model says yes, because your prompt implies you want one. It will not tell you the honest answer: refactor the two methods you already have.
None of those are capability gaps. They are all harness gaps. The model is doing exactly what the loop asked. So change what the loop asks.
Guardrail 1: make the boundaries executable, not advisory
A `CONTRIBUTING.md` that says "keep modules decoupled" is a wish. A lint rule that fails CI when `billing` imports `calendar` is a wall. The entire point of a guardrail is that it costs nothing to comply and everything to violate — which means it has to live in the compiler, the linter, or the test suite, not in prose the agent may or may not read.
The cheapest enforceable boundary is a module-graph check. With Go this is a two-file `depguard` rule; with TypeScript it is `eslint-plugin-boundaries` or an `import/no-restricted-paths` rule. Here is the pattern in a Go backend, where the rule is checked at build time:
// .golangci.yml — the agent cannot argue with this
linters-settings:
depguard:
rules:
domain-purity:
files: ["**/internal/domain/**"]
deny:
- pkg: "myapp/internal/infra"
desc: "domain must not import infrastructure (breaks the ports boundary)"
- pkg: "myapp/internal/http"
desc: "domain must not know about transport"
infra-isolation:
files: ["**/internal/infra/**"]
deny:
- pkg: "myapp/internal/http"
desc: "infra must not depend on transport handlers"
// internal/domain/order/order.go — this import FAILS CI, not review
// import "myapp/internal/infra/postgres" // forbidden by depguard
The value is not the rule itself. It is that the agent discovers the rule the moment it violates it, through a CI failure it can read and act on — rather than through a human reviewer who catches it three days later, after five more features stacked on top. Put the boundary in the failure path and the model self-corrects. Leave it in a doc and the model never learns.
Trade-off worth naming: every guardrail is a constraint on you too. If the domain layer legitimately needs a new port, you now edit `.golangci.yml` before you edit code. That friction is the feature. If you find yourself adding three exemptions a week, the boundary is wrong — fix the boundary, do not delete the rule.
Guardrail 2: budget deletions, not just features
You cannot reward subtraction with a test, but you can reward it with a diff-shape budget. Track the ratio of added to removed lines per PR and flag outliers. Katamari PRs are almost pure addition; healthy refactors hover near parity. This is one line of CI shell, and it catches the failure mode earlier than any human review will.
#!/usr/bin/env bash
# .github/scripts/diff-budget.sh — flag additive-only PRs
set -euo pipefail
base="${1:-origin/main}"
added=$(git diff "$base"...HEAD --numstat | awk '{s+=$1} END {print s+0}')
removed=$(git diff "$base"...HEAD --numstat | awk '{s+=$2} END {print s+0}')
# Katamari detector: >400 net lines with almost no deletion is a smell
net=$(( added - removed ))
if [ "$net" -gt 400 ] && [ "$removed" -lt 40 ]; then
echo "::warning::Additive-only change (+$added/-$removed). Justify why nothing was removed."
fi
echo "diff budget: +$added/-$removed (net $net)"
Run it as a warning first, never a hard block. The goal is to make the agent's own output visible so a human can ask "could this have deleted something instead?", not to reject legitimate new features. After a month you will have data: if 70% of agent PRs are additive-only, your harness is optimizing for accumulation, and no prompt rewrite will fix it.
Guardrail 3: the architecture context window
The agent sees your open files. It does not see your architecture intent. You can fix this by prepending a reference doc that is shorter than this paragraph and more specific than a README. Call it `ARCH.md`. Every agent should read it before writing code. Here is the whole thing:
## ARCH.md — single-page architecture constraint for agents
# Domain (internal/domain/)
- Zero infra imports. Zero HTTP imports. Zero framework imports.
- Talk through Port interfaces defined alongside each aggregate.
# Application (internal/app/)
- Orchestrates ports, maps errors, validates input.
- Never imports an infrastructure implementation directly.
# Infrastructure (internal/infra/)
- Implements ports. One file per port per provider.
- No "util" or "common" packages. Nothing goes here because "multiple things need it".
Seven lines. Three layers. A five-second read. The model will follow it because the harness handed it the boundary information it was missing — and because the lint rules in Part 1 enforce it. Two things together, not either alone.
If your model's system prompt is longer than `ARCH.md`, the arch document will be ignored. Put it in the tool context — the prompt the agent receives on every tool call — not buried in instructions only read at launch. LangChain's middleware system, Vercel's AI SDK tool schema, and raw `system` + `--file` injection in claude-code all support this. Use whatever fits your loop; the principle is the same: architecture must be in the same context as the code being edited.
This is not theoretical. I have seen a group that added a two-page `ARCH.md` to every agent session see its PR rejection rate drop from 35% to 8% over six weeks. The models were the same. The only change was the presence of a single flippable file in every context window.
Guardrail 4: cap the blast radius per agent turn
Agent coding tools move in turns: the model emits code, the CLI writes it to disk, the model sees the result, and repeats. The most effective guardrail I know is a file-change cap per turn — not per task, per single model invocation. A turn that touches more than N files is halted and the agent asked to explain why.
For context: a bad agent turn touches 12 files because it decided to extract a cross-cutting concern it invented. A good turn touches 3-4 files because it already has a bounded context in mind. The cap is not a budget; it is a forcing function for the agent to stop and compose before spreading.
// agent-harness middleward — pseudocode for a per-turn file cap
function guardFileCount(turnFiles, maxFiles = 5) {
if (turnFiles.length > maxFiles) {
return {
action: "PAUSE",
message: `Turn touches ${turnFiles.length} files (cap: ${maxFiles}). ` +
`Explain which internal package should own this change. ` +
`Agents that need 7+ files per turn usually violate a boundary.`,
};
}
return { action: "PROCEED" };
}
The model will immediately reach for workarounds — merging everything into one giant file to dodge the cap. That is fine, because the giant file is now visible to a human reviewer as a named smell. The cap does not prevent good architecture; it makes bad architecture harder to hide inside a diffuse diff across twenty files.
What about the model itself? Capability vs. alignment
I said earlier that this is a harness problem, not a model problem. That is mostly true. But the model does not help. Frontier models pattern-match architecture from training data, and most training data is Big Ball of Mud. The highest-probability output for "add a feature" is the median human solution, and the median human solution adds, adds, adds.
Models also have an output evaluation bias. They can identify an N+1 query problem when you show them one line of SQL, but they will generate that N+1 six times in a row when writing a REST handler because the local distribution for "how do I write an API handler" never includes the extra join. The model is not reasoning globally about the final state of the file; it is predicting the next token, and the next token after for is user, not leftJoin. This is a structural problem with auto-regressive generation that no prompt will fix.
You cannot prompt your way out of a MLE estimator. The model predicts local density, not global coherence. That is why guardrails are not a supplement to good architecture — they are a prerequisite.
The practical consequence: you need a post-hoc structural check. A CI step that does what the model's local predictions cannot — compute cyclomatic complexity, check fan-out, enforce that handleOrderCreate does not reference the HTTP package. These are not review items; they are compiled checks that fail before a human sees the diff. Treat them as .golangci.yml entries, not as bullet points on a PR template.
The hierarchy of effective guardrails
Not all guardrails are equal. Ranked most to least effective:
- Compile-time boundary rules — agent violates a depguard rule, CI fails, agent reads error, agent fixes import. No human. Cost: one `.golangci.yml` edit.
- Per-turn file-change caps — force composition per invocation. Agent cannot scatter across eight files without justifying. Cost: a three-line middleware.
- Diff-shape budgets — flag purely additive changes. A cheap signal that catches the pattern even reviewers rationalize.
- ARCH.md injection — most leverage per byte. Seven lines of boundary intent in every turn, backed by lint rules.
The pattern: the guardrail must fire within the agent's loop. A CI job after push is good. A middleware during generation is better — the model self-corrects before producing the diff. The closest thing to a silver bullet is a middleware that, on every tool result, runs a per-file diff against the branch's entrypoint and blocks if file-set fan-out exceeds a threshold. Under fifty lines in any agent harness. Turn it on and katamari PRs stop.
Three questions to check your own loop
If you answer yes to any, your agent loop is rolling a katamari:
- Can you point to a PR that added a new package because "the old was too coupled," where the old package existed for under two months?
- Is the dependency count higher at sprint end than at planning?
- Has your ARCH.md ever been updated for a boundary the agent violated in the same session because no lint rule enforced it?
Build the ball with a shape
Katamari architecture is the predictable output of a loop that rewards addition, ignores boundaries, and hides architecture intent from the model during generation. The fix is not a better model. It is four guardrails that cost under two hundred lines total and take an afternoon to implement.
In Katamari Damacy, once the ball is large enough you can roll over obstacles, and the music gets louder. That is your codebase when every agent turn passes locally while the system degrades globally. You do not need a bigger ball. You need a harness that stops the roll.