5 min read

Teaching AI Your Codebase's Conventions (And Why It Forgets)

A model follows your conventions for forty lines, then reverts to the internet average. Why that happens — a training prior against three examples — and the practical fix: exemplar files, constraints with reasons, and lint rules that don't decay.

AIAndroidCode QualityDeveloper WorkflowStatic Analysis
HR

Hessam Rastegari

Senior Android Developer · 12 years shipping Android

TL;DR — Convention drift isn't carelessness; it's a training prior asserting itself over a few in-context examples. Reduce it by pointing at exemplar files instead of describing rules, keeping a checked-in conventions file that both humans and tools read, phrasing rules as constraints with a stated consequence, and re-anchoring during long tasks. Then accept the ceiling: anything that must hold every time belongs in detekt/ktlint/API validation, because a CI check is the only enforcement that doesn't decay.

Why it forgets

Three mechanisms, and they call for different fixes.

1. The prior is strong. The model has seen an enormous amount of Android code. Your convention is a few hundred tokens against that. Where they conflict — you use Result<T> and the internet throws exceptions — the generic pattern reasserts itself as soon as your instruction is far enough back.

2. Attention decays with distance. An instruction at the top of a long session competes with two thousand lines of code generated since. The most recent code in the conversation is itself an example, and if it drifted once, it reinforces the drift.

3. Sessions don't persist. Tomorrow's session starts with no memory of today's corrections unless that knowledge lives in a file. Everything you taught it interactively is gone.

Once you see it as a prior-versus-context problem rather than a comprehension problem, the fixes get obvious: make the context stronger, closer, and permanent.

Point at files, not rules

The highest-leverage change, and the cheapest:

Before writing this, read feature/search/SearchViewModel.kt and feature/search/SearchRepository.kt. Follow the same structure, error handling and naming. Ask about anything that looks deliberate but unusual.

This beats a page of description for a reason worth internalising: your conventions have more detail than you can articulate. You'd remember to say "use StateFlow". You'd forget to say that private state is _uiState, that the public property has an explicit type, that errors go through UiState.Error rather than a separate channel, that repositories return domain types and never DTOs. The exemplar carries all of it for free.

Pick exemplars deliberately: recent, reviewed, representative. If your best example is two years old and predates a convention change, you're teaching the old one.

Write it down in the repo

Interactive corrections evaporate. A file doesn't.

# Conventions

## Architecture
- Feature = `ui/` + `domain/` + `data/`. Domain has no Android imports.
- ViewModels expose one `StateFlow<UiState>`. No `LiveData` in new code.

## Error handling
- Repositories return `Result<T>`. **Never** throw across a layer boundary —
  the ViewModel layer has no try/catch and we intend to keep it that way.

## Testing
- Fakes, not mocks. Every repository interface ships a `Fake…` in `testFixtures`.
- **Never** use field injection: it bypasses the fakes and the test compiles but
  exercises production wiring.

Two properties make this file work. It's in the repo, so it's versioned, reviewed, and read automatically by AI tooling. And it's the same document that onboards a human — if you maintain a separate "AI instructions" file, one of the two will go stale, and it'll be the one people don't read.

Keep it short. A 900-line conventions document is worse than a 60-line one, because the parts that matter get diluted by the parts that don't.

Constraints survive; preferences don't

Compare:

  • "Prefer constructor injection." → dropped within a few files.
  • "Never use field injection — it bypasses our test fakes, so the test compiles and silently exercises production wiring." → holds much better.

The difference is the stated consequence. A rule with a reason is a constraint that generalises to cases you didn't enumerate; a rule without one is a stylistic preference competing with a much larger stylistic prior. This is also, not coincidentally, what makes conventions stick with people.

Re-anchor on long tasks

On a multi-file refactor, restate the constraint at each stage:

Same rules as before: Result<T> at every repository boundary, no throwing across layers. Now do the payments module.

It feels redundant. It works, because it moves the instruction from 300 lines back to right now. The alternative — one instruction at the start of a two-hour session — is exactly the setup where drift is guaranteed.

Then stop relying on any of it

Everything above raises adherence. None of it reaches 100%, and treating it as if it does is the actual risk.

So encode what matters mechanically:

// detekt: ban an API outright
ForbiddenMethodCall:
  methodNames: ['android.util.Log.d', 'kotlinx.coroutines.GlobalScope']
  • ktlint / detekt for structure and banned APIs.
  • Custom lint rules for the Android-specific ones — Context held in a field, a hardcoded dispatcher.
  • Binary compatibility validator for public API surface in library modules.
  • A dependency rule (Konsist, or a Gradle module boundary) for "domain has no Android imports".

A CI check enforces the convention identically for the model, for a new hire, and for you at 6pm on a Friday. Nothing else does.

The honest limits

Naming the failures is the point of this post, so here they are:

  • Silent partial compliance. It follows the rule in the file it was shown and not in the one it created three files later. This is harder to catch than outright refusal, because the diff looks fine locally.
  • Plausible extrapolation. Given a convention, it will invent an adjacent one you never had, stated with the same confidence.
  • Stale conventions win. If old code in the repo contradicts your current rule, that code is also context, and it argues against you every time.
  • Explicit beats consistent. It's better at following "always X" than at inferring what your codebase consistently does.

The rule

Aim your effort at the durable layer. If a convention matters enough to enforce, it belongs in CI; if it's too subtle to write a check for, it's probably too subtle to expect a new hire to follow either. Prompting raises the floor. Only the check holds the ceiling.

Keep reading