Part 2: The Antipattern That Makes a Codebase Diverge
This is part 2 of Converge or Diverge, a three-part series about AI coding agents in a large codebase: Part 1: Maintaining Long-Term Code Quality With Agentic AI · Part 2: The Antipattern That Makes a Codebase Diverge (you are here) · Part 3: What Claude Code Actually Reads
(TLDR? Click here to jump directly to how to find and fix it in your own codebase.)
Part 1 argued that an AI coding agent multiplies whatever it finds in your codebase. In a healthy codebase the series is telescopic: each change is checked, framed and shaped by what’s already there, so its errors cancel out. In an unhealthy one the series is geometric, and each error becomes the example for the next one.
That article named the antipattern that starts a geometric series, almost in passing: “a second way to do things is exactly how a geometric series starts.” This article is about that one sentence. What the antipattern looks like, why an agent makes it worse, how you can spot it in your own codebase, and what to do when you find it. There’s one example throughout, from Smarter, and it’s a good one, because it grew in the code I had just held up as healthy, from a helper I had just held up as exemplary. 🤓
The antipattern: a second way to do things
Every codebase has a canonical way to do the things it does often: one way to check a permission, one way to read a setting, one way to shape an API error, one way to cache a result. The antipattern is a second way, living alongside the first. It’s never wrong enough to fail a test. It’s just different, and usually a little worse.
At human speed, a second way spreads slowly. Someone notices it in review, or the team simply remembers that there’s a helper for that. At agent speed it spreads quickly, for a mechanical reason.
Claude Code learns your conventions by example. Before it writes a new feature, it searches for code that does something similar and copies its structure. That’s a good habit, and it’s most of why the first article’s eight days went so well. But the agent copies the nearest example, not the best one. If the nearest example happens to be the second way, the new feature is a third copy of it, and that copy is now the nearest example for the next feature. Nothing in that loop ever asks which way is canonical.
So the ratio of the series is set by a single question: when the agent goes looking, which way does it find first?

One example: Smarter’s hand-rolled caches
Smarter has had a caching decorator, @cache_results(), since April 2024. It’s backed by Redis, it builds a repeatable key from the function’s arguments, it handles a cached None correctly, and every decorated function gets an .invalidate(). Its docstring is the code sample I chose in the first article to show how good Smarter’s documentation is.
Smarter also had a handful of direct cache.get() and cache.set() calls, in four files, including short-lived session keys in the chat views, which is a legitimate use. That made two ways to cache: a decorator for results, and raw calls for everything else. The boundary between them was in my head.
In late September, Claude Code built the MCPClient and the WebsearchPlugin for v0.15. Both call remote services, and both needed long-lived caches. Both got hand-rolled ones. Then the dashboard’s service health check got one, then the ImageSearchPlugin, and the semantic guardrail invented a variation of its own. The commits don’t say which example the agent copied first. They do show the count.
Here are the files in Smarter’s apps that call cache.get() or cache.set() directly, counted at each release tag:

Over the same four releases, from v0.14 to v0.19, the lines that use @cache_results grew too, from 91 to 125. Both ways were growing at once. That’s the signature of the antipattern: not that the canonical way is abandoned, but that both ways are thriving, and the agent picks between them by proximity.
What the copies carried with them
A copy carries its original’s design along with its structure. The hand-rolled caches built opaque keys from a sha256 of a JSON payload. This one is from the ImageSearchPlugin, just before the fix:
def cache_key(self, search_params: dict[str, Any]) -> str:
"""Return a cache key that is unique to the plugin, and the search parameters."""
plugin_data = self.plugin_data
payload = json.dumps(
{"plugin_data": plugin_data.pk if plugin_data else None, **search_params}, sort_keys=True, default=str
)
return f"{CACHE_KEY_PREFIX}:{hashlib.sha256(payload.encode('utf-8')).hexdigest()}"
The key was built on a database id, not on the parameters that change the result. API keys passed through the code that built the keys. Failures were handled differently in each copy. And the ImageSearchPlugin cached the last step of its workflow, the final filtered list of image URLs:
urls = cache.get(key) if plugin_data.cache_ttl else None
if urls is not None:
return urls
...
images = self.filtered_images(self.brave_images(api_key, search_params))
urls = self.validated_urls(images, search_params["count"])
if urls and plugin_data.cache_ttl:
cache.set(key, urls, plugin_data.cache_ttl)
return urls
Users could see it. Asking for “show me cats” showed the same cat every time, and changing the plugin’s filters did nothing until the cache expired.
Why nobody noticed
Every copy worked. Every copy had tests, and the tests passed, because they tested the copy. A reviewer reading any single change would have seen a reasonable cache with a reasonable key. The antipattern doesn’t live in any one change. It lives in the comparison between changes, and nothing was making that comparison.
How to spot it in your own codebase
You can’t find this antipattern by reading one diff, so don’t try. Look for it across the whole codebase, and across time.
Count both ways at every release tag
This is the most useful check, and it takes a minute. Pick the primitive that your canonical helper wraps (here, cache.get(), and count it at each tag, next to the helper itself:
for ref in $(git tag --sort=creatordate | grep -E '^v[0-9]+\.[0-9]+\.0$') HEAD; do
printf '%s %s hand-rolled=%s helper=%s\n' "$ref" \
"$(git log -1 --format=%ad --date=short "$ref")" \
"$(git grep -l -E '(^|[^a-z_])cache\.(get|set)\(' "$ref" -- 'smarter/smarter/apps/*.py' ':!*/tests/*' | wc -l)" \
"$(git grep -h 'cache_results(' "$ref" -- 'smarter/smarter/*.py' ':!*/tests/*' | wc -l)"
done
If the second way is flat, you have a few exceptions. If it’s growing, especially if it jumped when you started working with an agent, you have a series.
Grep for the primitive outside the helper’s home
Every canonical helper wraps something lower-level: the cache client, the HTTP library, os.environ, raw SQL, json.dumps in a view. List the files that use the low-level thing directly, outside the module that’s supposed to own it. Each one is either a documented exception or a second way.
Look for helpers that the newest code doesn’t use
If your oldest code uses the helper and your newest code doesn’t, the agent found a different example first. Sort the direct callers by the date of the commit that added them. A cluster of recent ones is the tell.
Look for copies that share a fingerprint
Copies carry their original’s quirks: the same key-building function, the same constant names, the same odd error handling. When three files in three different apps solve the same problem in the same slightly unusual way, they weren’t designed independently.
Treat a recurring bug shape as a symptom
The same kind of bug, in different features, with the same cause: settings that don’t take effect until something expires, errors that look different depending on where they came from, a permission check that one endpoint forgets. When the shape repeats, look for the pattern that produced it rather than fixing the instance.
Ask where the rule is written down
If the boundary between the canonical way and the exceptions lives only in someone’s head, or in a document the agent doesn’t read, the agent doesn’t know it. In the third article in this series I counted what Claude Code actually reads, and the answer is mostly code, tests and skills, which are read in most sessions that change code. Smarter’s architecture decision records were read in about one session in ten.
A second way to do things that nobody has written down is a rule the agent will never hear.
How to fix it: four steps
The fix for Smarter’s caches landed on the morning of October 11, in one commit that changed 59 files. It followed four steps, and I think they suit any codebase that an agent works in.
1. Name the pattern, not the instance
“The ImageSearchPlugin shows the same cat” is a bug. “Plugins hand-roll their caches” is a pattern. Fix the bug, and the pattern produces another one next week. Name the pattern, and you know where all its copies are.
2. Fix every copy, in one change
The commit moved the MCPClient catalog, the WebsearchPlugin, the ImageSearchPlugin, the SkillPlugin, the semantic guardrail and the service health check onto @cache_results, together. The ImageSearchPlugin now caches only the expensive remote call, keyed on the exact parameters sent to Brave, so its filters run on every search:
@cache_results(timeout=plugin_data.cache_ttl)
def get_brave_images(
q: str,
count: int,
country: Optional[str] = None,
search_lang: Optional[str] = None,
safesearch: Optional[str] = None,
spellcheck: Optional[str] = None,
) -> list[ImageResult]:
api_key = self.api_key()
if not api_key:
raise SmarterImageSearchRequestError(f"{self.name} has no Brave Search API key.")
...
The API key is read inside the cached function, never passed to it, and a failure raises, so it’s never cached. (It’s in image_search.py, trimmed here.)
All of the copies, in one change, matters more than it sounds. A fix that leaves two copies behind leaves two examples for the agent to find next week, and the series starts again from there.
3. Write the rule where the agent reads it before it starts
The same commit added a hard rule to smarter-development, the skill that Claude Code loads first for any change in Smarter:
Long-lived caching goes through @cache_results. For anything cached for 600
seconds or longer, use from smarter.lib.cache import cache_results. Never
hand-roll cache.get()/cache.set() with django.core.cache.
A rule alone isn’t enough. It needs the reasons, because a rule without a reason gets reasoned around. So the skill’s Caching section also says why: cache the expensive call, not the workflow around it, so that cheap filtering runs every time and configuration changes apply at once; the function’s parameters are the key, one by one, as plain values; never pass a secret as an argument; never cache a failure. It also says where the exceptions are. Short-lived values may still use raw calls, but through Smarter’s lazy_cache, never through django.core.cache directly.
It even records a mistake from the fix itself. Partway through, Claude Code decided to cache every plugin’s remote calls in one place, in the HTTP transport that they all share. That cache sat underneath each plugin’s own, and silently overrode it: a manifest’s cacheTtl of 0 no longer turned caching off. Fifteen unit tests failed, none of them pointing at the transport. Claude Code worked out why, removed the decorator, and wrote the reason into the transport’s docstring and into the skill: never decorate a generic transport. The canonical way still needs rules about when not to use it.
4. Add a mechanical guard where you can
A written rule is read by an agent that remembers to read it. A test is run whether anyone remembers or not. Smarter’s caching rule doesn’t have a guard yet, and it should. It would be a short test: outside of lazy_cache and one management command, no module imports django.core.cache. That’s true today, and a test would keep it true.
A mistake that’s written down happens once. A mistake that’s checked by a machine shouldn’t happen at all.
Did it work?
So far, yes. In the six days since the fix, the platform gained more than 43,000 lines of Python, nearly all of it from about 80 Claude Code commits: a Model Context Protocol server, installable extensions with a signed registry, API rate limiting and a translated web console, among others. Several of those features needed caches. Every long-lived one went through @cache_results. The short-lived ones, such as the MCP server’s rate-limit counters, went through lazy_cache, as the rule says. Not one new file calls cache.get() or cache.set() directly. Between v0.14 and v0.19, five new files did.
There was one more caching incident in those six days, and it’s worth telling because it’s the same lesson from the other side. On October 16, a console page broke in production because Smarter’s own reverse() shortcut was cached with @cache_results for a year. When a release moved a URL, Redis still held the old one. That decorator was mine, from May, and it was the canonical way, used where no cache belonged: a URL comes from the deployed code, so a cache that outlives a deployment is wrong by design. Claude Code removed it, wrote down why, and added a test that reverse() is never cached. One way to do things is the goal, but it has to be the right way, and the rules have to say when not to use it.
If you want to catch it in your own codebase
In rough order of payoff:
- List your canonical helpers. For each thing your codebase does often, write down the one way to do it, and the low-level primitive it wraps.
- Count both ways at every release tag.
git grepacross your tags shows you the ratio of the series directly. If the second way is growing, you have a series, whatever any single diff looks like. - Find the helpers that the newest code skips. The agent found a worse example first. Make the nearest example the best one.
- Fix every copy in one change. One leftover copy is the next feature’s template.
- Write the rule, its reasons and its exceptions where the agent reads before it starts. In Claude Code, that’s a skill or
CLAUDE.md, in the repository, under review. - Add a test that fails on the second way. A rule that a machine checks doesn’t depend on anyone’s attention, including the agent’s.
Closing thoughts
The first article ended by saying that the series had converged, and that the job now was to keep it that way. I didn’t expect the job to start the next morning. But this is what keeping it that way looks like: not a codebase without a second way to do things, but one where you go looking for them, fix every copy at once, and write down the one way, with its reasons, where the next agent will read it. The telescope doesn’t build itself.
It’s a curated process, and you have to keep cancelling. The third article measures exactly what the agent reads while you do.
Until then, happy Smartering!
