Do not over-engineer: keeping AI coding tools on task
Ask an AI coding assistant to add a retry to a single HTTP call and there is a fair chance you get back a RetryPolicy interface, three implementations of it, a BackoffStrategy enum, a configuration class, a factory, and a test suite for all of it. The retry works. It is also six files and two abstractions more than the problem needed. Nobody asked for a retry framework.
This is the most common failure mode in AI-assisted development, and it is not really a bug. It is the natural consequence of how these tools are built and how we prompt them. Worth understanding, because once you know why it happens you can prompt around it fairly reliably.
Over-building costs you in three separate currencies, and it is worth separating them up front, because the fixes differ:
- Code you have to maintain — the extra abstractions, files and options that outlive the task.
- Tokens — the context, time and money burned producing and re-reading work nobody asked for.
- Code you have lost — the lines an agent quietly removed on its way to the change you did want, which is the one that bites hardest because it is invisible in the output and only shows up in the diff.
The prompts in this post are written to be copied and adapted. Most of them are short.
Why AI tools over-build
A few forces push in the same direction.
They are trained on published code. The material these models learned from is skewed towards libraries, frameworks, tutorials and well-regarded open source — code written to be reused by strangers, extended in unknown directions, and demonstrated in blog posts. That code is correctly full of interfaces, options and extension points. Your internal service handling one known case is not that code, but it looks superficially similar, so the same patterns come out.
Helpfulness reads as thoroughness. A model optimised to be useful will tend to cover the cases you did not mention. You asked for a CSV parser; it handles quoted fields, BOM markers, custom delimiters and a streaming mode, because each of those is plausibly helpful. Each addition is defensible on its own. The sum is a small library you now own.
They cannot feel the cost. A human developer who adds an abstraction pays for it later — in review, in debugging, in the next person’s onboarding. The model has no stake in the file six months from now. There is no friction pushing back against another layer.
The prompt is usually underspecified. “Add caching to this” does not say where the boundary is. The model fills the gap with assumptions, and its default assumption is generality: make it configurable, make it swappable, make it work for cases you did not mention.
Context is partial. The tool often cannot see that you already have a retry helper in utils/http.py, or that the project has a house convention for configuration. So it builds its own. Duplication is a form of bloat too, and it is the kind that tends to survive review, because the new code looks fine in isolation.
flowchart TD
A["Vague request"] --> B["Model fills gaps with assumptions"]
B --> C["Default assumption: make it general"]
C --> D["Extra abstractions, options, files"]
D --> E["Code that passes review in isolation"]
E --> F["Maintenance cost paid later by humans"]
A2["Scoped request with stated constraints"] --> B2["Few gaps to fill"]
B2 --> G["Minimal change to existing structure"]
The key point of the diagram: over-engineering is not the model being wrong, it is the model resolving ambiguity in the direction of generality. Remove the ambiguity and most of it disappears.
The practical guide
1. State the scope in files, not in adjectives
“Keep it simple” is nearly useless as an instruction — simplicity is subjective and the model already believes its output is simple. A file budget is not subjective.
Add retry-on-timeout to the fetchInvoice call in services/billing.ts.
Change that one file only. No new files, no new classes.
If you think this needs more than one file, stop and tell me why
instead of writing it.
If the change genuinely needs a new file, the model will say so, and now you are having a useful conversation about scope instead of reviewing code you did not want.
2. Name the one case you are solving, and the ones you are not
Over-building thrives on unstated cases. Close them explicitly.
We only ever read UTF-8 CSVs written by our own exporter, always with
the same six columns. Do not handle other encodings, custom delimiters,
missing columns, or malformed rows beyond raising an error.
This single instruction prevents more bloat than any amount of “be concise”. A useful variant when you genuinely do not know the case list yet:
Before writing anything, list the edge cases you are planning to handle
and wait. I will tell you which ones are real.
3. Keep tasks small and incremental
This is the rule that makes all the others easier to follow, and the one most often broken, because handing over a big task feels like the whole point of having an assistant.
Two prompts beat one. First: make it work for the exact case, in the existing structure. Then, once you have seen the diff, decide whether it needs generalising. The model is good at both jobs; it is just bad at guessing which one you wanted.
Step 1 only: make this work for the single case I described, in the
existing structure, with the fewest lines you can. Do not generalise,
do not add options. Once I have reviewed it I will tell you whether
step 2 is needed.
The inversion matters. Generalising working code you have read is cheap. Reviewing a speculative framework to find the 10 lines that do the work is expensive — in review time and in tokens, since every follow-up re-reads the whole thing.
Size the increment so that you can still meaningfully review it. A good working limit is a change you would be happy to review as a pull request from a colleague: one behaviour, a diff you can hold in your head, a clear answer to “did this do what I asked?” Past that point, review quality collapses — you start skimming, and skimming is exactly how both bloat and silent deletions get merged.
Small increments also fail cheaply. If the model misunderstands the task, you find out after one reviewable change, not after eleven files that all assume the same wrong thing. And because each increment lands in a clean tree, every diff is attributable.
We are doing this in steps, not all at once. Step one is only:
add the endpoint and return a hard-coded response. Do not touch the
database layer, auth, or tests yet — I will ask for those separately.
Stop when step one works.
The exception, with a caveat. A larger task is reasonable when there is a genuine specification behind it: written requirements, defined acceptance criteria, named files or interfaces, and an explicit statement of what is out of scope. That is exactly what closes the ambiguity gap the model would otherwise fill with invention, and it is why teams with real task documents get better results from agentic tools than teams working from one-line tickets.
But a specification is not a substitute for checking. It changes what you check for, not whether you check. With a spec, the failure mode is rarely “the model did the wrong thing” — it is the model satisfying every stated criterion while adding structure nobody specified, or quietly dropping an existing behaviour the spec did not mention because the spec described the feature, not the code around it. Nothing in a document prevents that; only the diff shows it.
So even with a good spec, work through it in stages and review at each boundary:
Here is the specification. Work through it in stages and stop after
each one for review. Do not implement anything the spec does not
call for, and if the spec is ambiguous or silent on something, ask
rather than deciding for me. List anything you had to assume.
The last sentence is the valuable one. Assumptions the model announces are cheap to correct; assumptions it bakes into code are not.
4. Point at the code it should imitate
Most bloat is the model inventing structure because it does not know yours.
Follow the pattern in handlers/orders.py exactly. Use the existing
get_config() helper rather than adding configuration. If something in
that pattern does not fit, say so rather than inventing a new one.
This also fixes the duplication problem: told where to look, the model reuses instead of rebuilding.
5. Ban speculative generality by name
The specific phrases work better than a general plea for restraint, because they map to recognisable patterns:
- No new interfaces or abstract base classes unless there are two real implementations today.
- No configuration options for values that have exactly one value in this codebase.
- No new dependencies without asking first.
- No backwards-compatibility shims — nothing is calling this yet.
Put these in a project instructions file (CLAUDE.md, .cursorrules, or whatever your tool reads) so you are not retyping them. That is the single highest-leverage habit here: the rules apply to every prompt without costing you anything per prompt.
6. Be explicit about tests, both ways
Left alone, assistants either write no tests or write twenty, including tests for the abstractions they just invented. Say which you want:
Add exactly one test, for the timeout path, to the existing test file.
Do not restructure that file, do not add fixtures, and do not test
anything that already had coverage.
7. Review the diff for structure, not just correctness
When you read the output, the question is not only “does this work?” but “is anything here that I did not ask for, and is anything missing that used to be there?” New files, new abstractions, new configuration — and removed lines. Each is a place where the model made a scope decision on your behalf. This matters enough that it gets its own section below.
Deleting the extra work is fast when you spot it in review. It is very slow once it has been merged, imported elsewhere, and become load-bearing.
8. Reset the conversation when scope drifts
Long sessions accumulate context, and accumulated context tends to grow the perceived scope of the task — as well as the cost of every subsequent message, since the whole history is re-read each turn. If the fourth follow-up in a thread is producing more machinery than the first, start a fresh conversation with a tight restatement of the remaining work. It is cheaper than steering a drifting one, in both senses of cheaper.
9. Ask for the plan before the edits
For anything beyond a small change, make the first response a plan rather than code. A plan is a few hundred tokens and takes twenty seconds to read; the implementation it would have written is thousands of tokens and takes ten minutes to review.
Do not write any code yet. In under 15 lines, tell me:
which files you will change, roughly how many lines in each,
any new files or dependencies, and anything you are unsure about.
Then stop and wait.
This is the cheapest possible point to catch “I was going to add a strategy pattern here”. It also gives you a scope you can hold the model to afterwards: “That plan said two files. You changed five. Explain the other three.”
Watch the token burn
Over-engineering has a twin that gets much less attention: the tokens spent getting there. Every file the tool reads, every file it re-reads, every unchanged line it echoes back, every dead end it explores — all of it is context consumed, and on metered plans, money. On agentic tools it compounds, because the agent is reading and writing on its own initiative between your messages.
The pattern to watch for is a straightforward task that quietly costs several times what it should. A one-line config change that ends with forty files read. A small feature that runs the full test suite four times. A refactor where the assistant reads a 2,000-line file three separate times because it forgot it had already seen it.
Some of that burn is the tool’s doing, but most of it is caused by the same thing that causes bloat: an unbounded request. “Fix the failing test” invites a hunt. “Fix the failing test in test_billing.py::test_refund — the cause is in refund() in billing.py, do not read anything else unless you need to” does not.
Where the tokens actually go
- Broad search instead of targeted search. Reading whole files to absorb conventions, when a few grep matches would show them.
- Re-reading. Files read once, then read again later in the same session.
- Echoing unchanged content. Some editing styles reproduce an entire file to change one line. On a large file that is the whole file, every time.
- Over-wide verification. Running the complete test suite to confirm a change that touches one module.
- Exploratory sprawl. A search that keeps widening because nothing told it when to stop.
- Long sessions. Accumulated history is re-sent with every turn, so a drifting thread gets more expensive per message as it goes.
Prompts that cap the burn
Put this in your project instructions file so it applies to every task:
Minimise token use. Prefer targeted grep and narrow file reads over
reading whole files. Do not re-read a file you have already read in
this session. Do not read a large file end to end to learn conventions
when a few matches would show them. Run only the tests that cover the
change, not the whole suite. If usage looks unexpectedly high for the
size of the task, stop and tell me before continuing.
Give large tasks an explicit heads-up rule, so you find out about cost before it is spent rather than after:
Before starting anything that touches more than a few files, tell me in
one or two lines: roughly how many files, whether it needs new packages,
migrations or tests, and whether it should be split. Then start — this
is a heads-up, not a request for permission.
And scope individual investigations rather than opening them up:
The bug is somewhere in the checkout flow. Start with
services/checkout.py and the two functions it calls in cart.py.
Read only those. If the cause is not there, tell me what you would
look at next instead of going and looking.
When a session is already expensive, the right move is usually not to keep steering it. Summarise and restart:
Summarise in 10 lines: what we changed, what is left, and the
constraints I gave you. I am going to start a fresh session from that
summary.
One last habit, cheap and surprisingly effective: if the pace or the cost looks wrong, say so mid-task. “That seems like a lot of reading for a one-line change — what are you looking for?” A tool that has to justify its search usually narrows it.
Read the diff twice: before and after
The most damaging thing an AI agent does to a codebase is not adding code. It is removing code you did not ask it to remove.
This happens more than people expect, and the mechanism is mundane. When a model rewrites a function or a file, it reproduces it from its own understanding rather than surgically patching it. Anything it did not consider important — or did not have in context — can simply fail to reappear. What gets lost is predictable and, in each case, quiet:
- An early-return guard for a null case that only occurs in production.
- A
try/exceptaround a call that fails once a month. - A comment explaining why a line that looks wrong is actually correct.
- A feature flag check, or a permissions check, sitting in the middle of a function being “cleaned up”.
- Logging or metrics calls, removed as noise.
- A workaround for a third-party bug, with no obvious reason for existing.
None of these break a test suite reliably. Several of them are precisely the code that exists because of a past incident. And because the model’s summary of its work says “refactored processPayment for clarity”, the deletion never appears in the conversation at all. It exists only in the diff.
The practice
Start every task from a clean tree. Commit or stash first. If the working tree already has changes in it, you cannot tell the agent’s edits from your own, and the diff — your only real record — becomes useless.
Read the diff, not the summary. The model’s description of its change is a description of what it intended. The diff is what happened. Review the diff before you run anything, and treat removed lines as first-class findings rather than noise around the addition.
Read removals specifically. Additions get scrutiny naturally because they are new and interesting. Deletions are easy to skim past, especially in a large diff. Look at them on purpose:
# Only the removed lines, across the whole change
git diff -U0 | grep '^-' | grep -v '^---'
Ask for the deletions to be declared. You can make the agent surface them itself, which turns an invisible change into a reviewable statement:
After making the change, list every line you deleted or replaced that
was not part of what I asked for, and why it went. If the answer is
"nothing", say that explicitly.
Forbid unrequested removal outright for narrow fixes:
This is a surgical fix. Do not delete or rewrite any existing line that
is not directly required by the change — including comments, logging,
error handling, and guard clauses. If something looks dead or wrong,
tell me about it, do not remove it.
Prefer patches over rewrites. The prompt-level version of this is simply saying so:
Edit the existing function in place with the smallest possible change.
Do not rewrite it from scratch and do not reformat the surrounding code.
Diff the before and after of the file itself when the change is large enough that a rewrite was likely:
git stash # or: git switch -c ai-change
git diff --stat HEAD # scope of change, file by file
git diff HEAD -- path/to/file # then read the ones that grew or shrank unexpectedly
A file that shrank during a task that was supposed to add something is the single most reliable signal that scope was silently traded away. It takes one line of output to spot and can save you an incident.
flowchart LR
A["Clean tree"] --> B["Scoped prompt"]
B --> C["Agent makes change"]
C --> D["git diff --stat"]
D --> E{"Anything shrink<br/>unexpectedly?"}
E -->|"Yes"| F["Read removed lines<br/>ask why they went"]
E -->|"No"| G["Review additions<br/>for scope creep"]
F --> H["Restore what mattered"]
G --> H
The point of that loop is that both halves of the review matter. Scope creep is what the agent added; silent deletion is what it took away. A review that only asks “is this code good?” catches the first and misses the second.
The cycle this fits into
None of this works as a set of isolated prompting tricks. It works when the prompting sits inside a loop that assumes the AI’s output needs checking, and gives you defined places to do the checking.

The diagram shows that loop as nine steps, each with a named owner, plus two feedback paths.
It starts with a specification, not a prompt. Step 1 is the task document: requirements and objectives, the work broken into tasks with acceptance criteria, references to the sources of truth (project bible, architecture, standards, policies), and an explicit statement of scope and constraints. That is the same content as a well-scoped prompt, just written down once and reusable — and it is where the ambiguity that causes over-building gets removed, before any code exists.
The build step is explicitly incremental. Step 2 is implementation with AI assistance, and its own guidance is to follow the coding standards and keep changes focused and incremental. The AI is a tool inside the step, not the owner of it; the developer still owns the step.
Three different checks follow, and they are not the same check. Step 3 is review for quality, logic, security and maintainability — by a peer, an AI, or both. Step 4 is testing against the acceptance criteria, including edge cases and error scenarios. Step 6 is verification against the sources of truth: does this align with the existing codebase, the project bible, and organisational policy — and, stated directly on the diagram, were any unintended deviations introduced by the AI? That last question is the one this post has been circling. It is a distinct check, and it belongs in the process rather than in someone’s good intentions.
The Git review step is about diffs, not just code. Step 9 asks the reviewer to compare diffs to see exactly what changed — noted on the diagram as especially important with AI-generated code — and to look for unexpected or unnecessary changes. Unexpected covers the silent deletion; unnecessary covers the over-engineering. Same review, both failure modes.
The two loops are the point. The top path sends anything incomplete or broken back through the cycle. The bottom path, from a rejected change review, returns all the way to step 1 — feedback, changed requirements or new requirements become a revised specification rather than another improvised prompt on top of a drifting session. That is the structural version of “reset the conversation when scope drifts”.
If the diagram cannot render for you, the summary is: specify, build small with AI help, review, test, fix, verify against your standards, document, commit, review the diff, then merge or loop back — with a human owning every step and the AI assisting inside them.
Worth being honest about the cost of this: it is more ceremony than “ask the assistant and merge what comes out”. The pay-off is that each step is small enough to actually perform. A nine-step cycle around a two-day change is bureaucracy. The same cycle around a one-behaviour increment is about twenty minutes, most of which you would have spent anyway.
Scenarios
A configuration value becomes a configuration system
Ask: “Make the request timeout configurable.”
What tends to come back: A Config class, environment-variable loading with type coercion, a defaults file, validation, and timeout wired through three layers of constructor injection.
What was needed: A module constant, or one environment variable read at the call site.
Prevention: Name the mechanism, not the goal. “Read the timeout from an environment variable at the call site in client.py, defaulting to 30 seconds. Do not add a configuration class or file.” Rule 1 and rule 5 do the work here.
One-off script becomes a CLI application
Ask: “Write a script to rename these files.”
What tends to come back: Argument parsing, --dry-run, --verbose, --recursive, logging configuration, a main() guard, progress output, and a docstring describing usage.
What was needed: Fifteen lines you run once and delete.
Prevention: State the lifespan. “This is a throwaway script I will run once on this directory and then delete. No arguments, no flags, no logging — hard-code the path.” Telling the model the code is disposable is remarkably effective, because it removes every justification for extensibility.
A bug fix becomes a refactor
Ask: “Fix the off-by-one in the pagination.”
What tends to come back: The fix, plus the surrounding function extracted into three smaller ones, renamed variables, added type hints, and a docstring — a 4-line fix inside a 60-line diff.
What was needed: The 4 lines. The tidying may even be good, but it is now impossible to review the fix in isolation, and if the page breaks tomorrow you cannot tell which part did it.
Prevention: Separate the jobs explicitly. “Change only the lines required to fix the off-by-one. Do not rename, reformat, or extract anything. If you see other issues, list them at the end instead of fixing them.” The “list them instead” clause is worth keeping — you get the model’s observations without the unrequested diff, and you can accept them as a separate change.
A CRUD endpoint arrives with a service layer
Ask: “Add an endpoint to fetch a user by ID.”
What tends to come back: A repository interface, a service class, a DTO, a mapper between the DTO and the model, a custom exception hierarchy, and the endpoint.
What was needed: A handler that queries and returns, matching the five endpoints already in the file.
Prevention: This is rule 4 almost exclusively. “Add it to routes/users.py following the exact pattern of the existing get_order endpoint. No new files.” When an existing pattern is named, the model copies rather than architects.
A parser grows a plugin system
Ask: “Parse this log format.”
What tends to come back: A format-detection layer, a registry of parser strategies, and an extension point for adding formats later — because logs come in many formats, and the model does not know that yours does not.
What was needed: One regex and a loop.
Prevention: Close the case list, as in rule 2. “There is exactly one log format and it will not change. Parse it directly — no strategy pattern, no format detection.”
A shared helper gets duplicated
Ask: “Add date formatting to this report.”
What tends to come back: A new formatDate function in the report module — while utils/dates.ts already exports one.
What was needed: An import.
Prevention: Make searching part of the instruction, and make the result visible:
Before writing any helper, grep utils/ for an existing one and tell me
what you found. If something close already exists, use it or extend it
rather than adding a second version.
This one is worth putting in your project rules permanently, since it applies to every task and the model cannot be expected to have your whole tree in context.
A “cleanup” quietly removes error handling
Ask: “Tidy up processPayment, it has got messy.”
What tends to come back: A shorter, more readable function — minus the try/except around the gateway call, minus the idempotency-key check, and minus a comment reading // do not remove: gateway double-charges on retry. Tests pass, because nothing tests a gateway that fails twice.
What was needed: The same function, tidier, with every guard intact.
Prevention: Two layers. First, constrain the edit:
Improve readability only. Do not remove or alter any error handling,
retry logic, idempotency check, comment, or log line. Behaviour must be
identical.
Second, verify rather than trust:
Show me a list of every line you removed, with the reason for each.
Then read the diff yourself anyway. This is the scenario that justifies the whole diff habit — the output looks better and is worse, and the only place the truth is recorded is in the removed lines.
A one-line change costs a hundred thousand tokens
Ask: “The date on the invoice PDF is in the wrong format.”
What tends to come back: The fix — after the agent has read the PDF renderer, the template system, three model files, the test suite, the configuration loader, and the changelog, run the full test suite twice, and explored a theory about timezones that turned out to be unrelated.
What was needed: One grep for the format string, and one edit.
Prevention: Point at the target and cap the search before it starts:
The invoice date format is wrong. Grep for the format string in
templates/ first. Change only where it is defined. Do not read the
renderer or the test suite unless the grep comes back empty, and run
only the invoice tests.
The general form is worth internalising: when you already know roughly where the problem lives, saying so is not spoon-feeding, it is the single biggest cost control you have.
A prompt block worth keeping
Most of the constraints above are not per-task decisions — they are how you want the tool to behave always. Written once into a project instructions file (CLAUDE.md, .cursorrules, a system prompt, whatever your tool reads), they cost nothing per prompt and apply to work you are not watching closely:
## Scope
- Do the task asked, and nothing adjacent. If you see other problems,
list them at the end instead of fixing them.
- No new files unless I asked for one, or you tell me first and I agree.
- No new interfaces or abstract classes unless there are two real
implementations today.
- No configuration options for values that have exactly one value in
this codebase.
- No new dependencies without asking first.
- Reuse what exists: search before writing a helper, and follow the
nearest existing pattern rather than inventing one.
## Edits
- Make the smallest change that works. Edit in place; do not rewrite
files or functions wholesale.
- Never remove existing error handling, guards, comments, logging or
tests that are not part of the change. Flag them instead.
- After each change, list anything you deleted that I did not ask you
to delete.
## Cost
- Prefer targeted grep and narrow reads over reading whole files.
- Do not re-read files you have already read this session.
- Run only the tests covering the change.
- Before anything touching more than a few files, give me a one-line
heads-up on size and whether it should be split.
- If cost looks high for the size of the task, stop and say so.
Trim it to taste. The value is not in these exact words but in the fact that they are written down somewhere the tool reads every time, instead of being remembered by you in some prompts and forgotten in others.
The pattern underneath
Every one of those preventions is the same move: replace an assumption the model would have to make with a fact you supply. Scope, lifespan, the case list, the pattern to follow, what already exists. The model is not trying to gold-plate your codebase. It is answering a question you asked less precisely than you thought, and generality is the safest-looking answer to an imprecise question.
That reframing is also the reason this is a tractable problem rather than a complaint about the tools. You cannot make a model want less code. You can remove the ambiguity that makes more code look like the right answer — and you only have to write most of those constraints down once.
The same move handles the other two currencies. Unbounded search burns tokens for exactly the reason unbounded scope burns files: nothing said where the edges were. And silent deletion happens because “improve this” gave the model licence to decide what mattered in code it could not see the history of. Say where the edges are, and say what must survive.
Four habits, then, and none of them take long:
- Keep the increment small enough that you will genuinely review it — and when a real specification lets you go bigger, still stop and check at each stage.
- Before sending a prompt, spend ten seconds asking what the model would have to guess — then say it.
- Before starting anything big, ask for the plan and the cost, not the code.
- After every change, read the diff — the removed lines first.