Before Blaming AI-Written Code, Stabilize It
The previous two articles in Programmer Craft in the AI Era covered testing and observability. Tests make results reproducible. Observability lets you locate problems when a system fails.
This article is about a more realistic situation: you inherit a piece of AI-written code. It already runs. It may even already have users. But it looks like a tangled ball of thread: long functions, unclear boundaries, mysterious exception handling, and one change easily pulling out three more problems.
Before blaming it, stop. Blame produces nothing. What produces value is splitting it apart, pinning down behavior, cleaning it up, and refactoring it until it moves from “a demo prototype” to “code that can keep being maintained”.
Do Not Start With a Big Rewrite
When inheriting bad code, the most dangerous first move is an immediate rewrite.
This is especially true for AI-generated code. It may have many problems, but it may also hide business details that have already been validated by users. A branch you think is redundant may be working around a real production issue. A field you think is strange may be depended on by the frontend or a script. Functions you think can be merged may actually carry different side effects.
So the first step is not refactoring. It is freezing behavior.
Freezing behavior means: first confirm what the code currently does, then decide what to keep, what to delete, and what to rewrite.
A simple principle:
Refactoring without test protection is not refactoring. It is gambling.
Get It Running and Capture a Baseline
When inheriting AI code, do three things first.
It can start
It can test
It can reproduce critical paths
Do not rush to reorganize directories. Do not rush to rename things. First make the project run locally, and record the startup command, environment variables, dependency versions, and minimum dataset.
For example, for a report-generation service, establish this baseline:
uv sync
uv run pytest
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
Then prepare a minimal request:
curl -X POST http://localhost:8000/reports \
-H 'content-type: application/json' \
-d '{"tool_id":"demo_tool","query":"recent week data","format":"summary"}'
If these cannot run, first add running documentation and fixtures. A project that cannot start reliably is not ready for refactoring.
A common problem with AI-generated code is that it can run once locally, but its dependencies are hidden in the global environment. When inheriting it, make those hidden conditions explicit: .env.example, test data, startup scripts, database migrations, and external-service mocks.
Pin Down Current Behavior With Regression Tests
AI code is not reliable enough to trust only by reading.
Reading code tells you what the author may have intended. Tests tell you what the system actually promises.
Tests during the handoff phase do not need to be elegant first. They need to protect critical behavior first. For a report service, at least add regression tests like:
def test_create_report_returns_queued(client):
response = client.post(
"/reports",
json={"tool_id": "demo_tool", "query": "hello", "format": "summary"},
)
assert response.status_code == 200
assert response.json()["status"] == "queued"
assert "report_id" in response.json()
def test_create_report_rejects_unknown_tool(client):
response = client.post(
"/reports",
json={"tool_id": "missing", "query": "hello", "format": "summary"},
)
assert response.status_code == 400
assert response.json()["error_code"] == "TOOL_NOT_FOUND"
These tests may not be perfect, but they protect entry behavior.
Then add bug cases, boundary cases, and failure cases:
How is an empty query handled?
How is an overly long query handled?
Is duplicate submission idempotent?
Does third-party timeout enter retry?
Does insufficient permission return a stable error code?
If result saving fails, can billing happen twice?
When inheriting AI code, tests are not there to prove you wrote well. They are there to prevent you from breaking existing behavior.
Draw the Module Map
Much AI code is hard to maintain not because the syntax is bad, but because the boundaries are blurred.
One function may do parameter validation, permission checks, database queries, third-party calls, result formatting, billing, logs, and product events. It runs, but any change may pull a wide area with it.
When inheriting it, first draw a rough module map:
API Layer
-> Auth / Permission
-> Use Case / Application Service
-> Domain Rules
-> Repository
-> Provider Client
-> Queue / Worker
-> Billing / Audit
This is not about forcing architecture terminology onto the code. It is about understanding where responsibilities belong.
More concretely:
API layer: only handles HTTP, schema, and error responses.
Use Case: orchestrates business flows, such as creating or cancelling reports.
Domain Rules: pure business rules, such as state machines, amounts, permission policies.
Repository: database reads and writes, without business decisions.
Provider Client: external API calls, including timeout, retry, and error mapping.
Worker: asynchronous execution, not directly depending on HTTP context.
Audit/Billing: critical side effects, traceable and idempotent.
AI-generated code often mixes all of this into one large function. The goal of refactoring is not “pretty layering”. The goal is making each change affect only the place it should affect.
Look at Dependency Direction
A practical way to judge maintainability is to look at dependency direction.
Ideally, core business rules should depend on very little external infrastructure. They should be directly unit-testable without starting a database, an HTTP service, or a third-party SDK.
For example:
def can_cancel_report(status: str, owner_id: str, actor_id: str) -> bool:
if actor_id != owner_id:
return False
return status in {"queued", "running"}
Functions like this should be pure. Clear input, clear output, no database, no network, no time access, no randomness.
By contrast, this is hard to test:
async def cancel_report(report_id: str, actor_id: str):
report = await db.get(report_id)
user = await auth.current_user()
if user.id != report.owner_id:
await logger.warning("permission denied")
return False
await provider.cancel(report.provider_job_id)
await db.update(report_id, {"status": "cancelled"})
await send_notification(user.id)
return True
It mixes database access, authentication, permission rules, external calls, state mutation, and notification. Testing one simple permission rule drags in many dependencies.
When inheriting it, extract the pure rule first:
def decide_cancel_report(report: Report, actor: Actor) -> CancelDecision:
if actor.id != report.owner_id:
return CancelDecision(allowed=False, reason="not_owner")
if report.status not in {"queued", "running"}:
return CancelDecision(allowed=False, reason="invalid_status")
return CancelDecision(allowed=True, reason="ok")
Then handle side effects outside:
decision = decide_cancel_report(report, actor)
if not decision.allowed:
raise PermissionDenied(decision.reason)
await provider.cancel(report.provider_job_id)
await repository.mark_cancelled(report.id)
await audit.record(...)
This is often the key step that moves AI code from “runnable” to “testable”.
Cyclomatic Complexity and Function Length Are Not Mysticism
During refactoring, simple metrics can act as radar.
Functions that are too long, branches that are too many, and nesting that is too deep usually mean too many responsibilities.
Cyclomatic complexity can be roughly understood as: how many independent paths exist in the code. if, for, while, except, and logical operators all increase path count. More paths mean harder test coverage and higher change risk.
For example:
def normalize_result(data, mode, user_type, fallback):
if mode == "summary":
if user_type == "vip":
...
else:
...
elif mode == "table":
if fallback:
...
else:
...
elif mode == "chart":
...
else:
...
This function is not necessarily wrong, but it can easily become a dumping ground for branches.
When inheriting AI code, first look for these signals:
A single function exceeds 80-100 lines
Nesting is deeper than 3 levels
One function touches multiple external dependencies
Exception handling is scattered across layers
The same status field is changed casually by multiple modules
Many boolean parameters control behavior
These are not absolute rules, but they are enough to identify risk points.
Fan-In and Fan-Out Decide Change Risk
Two useful concepts are fan-in and fan-out.
Fan-in means “how many places call me”. Fan-out means “how many things I call”.
High fan-in: many places depend on it; change carefully.
High fan-out: it depends on many things; usually too many responsibilities.
High fan-in + high fan-out: dangerous core; add tests before touching it.
For example, an execute_tool() function may be called by APIs, workers, scheduled jobs, and scripts, while internally touching the database, cache, third-party APIs, billing, logs, and product events. That function is a refactoring minefield.
The answer is not rewriting it in one cut. First add a protective layer:
1. Write black-box regression tests for the existing execute_tool.
2. Record inputs, outputs, and critical side effects.
3. Gradually extract pure rules from inside.
4. Wrap external dependencies behind interfaces.
5. Let new logic take a new path while keeping the old path for a while.
That is small-step refactoring.
Isolate Side Effects First
One of the biggest problems in AI code is side effects flying everywhere.
Side effects include:
Writing to the database
Sending HTTP requests
Publishing queue messages
Billing
Sending notifications
Writing files
Reading current time
Reading randomness
Writing audit records
These operations are not forbidden. They just should not be scattered anywhere.
A maintainable flow usually separates “decision” from “executing side effects”:
def plan_report_execution(request: ReportRequest) -> ExecutionPlan:
# Pure decision: choose provider, check mode, calculate budget, generate steps
return ExecutionPlan(...)
async def execute_report_plan(plan: ExecutionPlan, deps: Dependencies) -> ReportResult:
# Side effects are centralized here: call provider, write database, write audit
...
This has two benefits.
Pure decisions can be heavily unit-tested. They run fast and do not depend on external environments. Once side effects are centralized, timeouts, retries, idempotency, error mapping, and audit records can be handled consistently.
Make this explicit in prompts to AI:
Write business decisions as pure functions. Do not access the database, network, current time, or randomness inside pure functions.
External side effects must be injected through explicit dependency interfaces.
Deleting Code Matters More Than Changing Code
AI is very good at generating code that “looks useful”.
Backup branches, unused parameters, over-abstracted extension points, future-maybe-needed hooks, duplicated error wrappers, and helpers nobody calls can all pile up.
When inheriting it, be willing to delete code, but delete with evidence.
Handle it in this order:
Search call sites and confirm there are no references.
Run tests and type checks.
Check build output or route registration.
If it is an API or script entry, confirm there is no external caller.
Keep the deletion in a separate commit so rollback is easy.
Some code cannot be judged only by searching the repository. Entries called by scheduled jobs, external scripts, or user webhooks should first be marked deprecated, logged for a while, and deleted later.
Deleting code has a direct benefit: less context, less chance that AI is misled by old logic, and lighter human review.
Refactor Along Seams
Refactoring is not rearranging files.
Good refactoring cuts along seams. A seam is a place where the system naturally can be separated: API, database access, external provider, queue job, state machine, permission policy.
For example, a messy execution function:
execute()
- parse input
- check permission
- select provider
- call provider
- normalize result
- save history
- bill user
- emit telemetry
It can first be cut into:
parse_execute_request()
authorize_execute()
select_provider()
call_provider()
normalize_provider_result()
record_execute_history()
charge_usage()
emit_execute_events()
Not every function needs to become a class. Often, several clear functions are enough.
AI-generated code often overuses classes. If you ask it to “optimize the architecture”, it may immediately generate managers, handlers, services, and factories. When inheriting code, suppress that impulse: first clarify boundaries, then decide whether abstraction is needed.
Use Strangler Fig Migration for Large Logic
If old logic is too large for a one-time rewrite, use the Strangler Fig approach: new logic gradually wraps old logic and replaces it step by step.
The process roughly looks like:
Keep the old entry point.
Add a router before the entry.
Send small traffic or specific scenarios to the new implementation.
Compare old and new results in parallel for a while.
Expand the scope after confirming consistency.
Delete the old implementation after it is stable.
Pseudo code:
async def execute(request: ExecuteRequest) -> ExecuteResult:
if should_use_new_executor(request):
return await new_executor.execute(request)
return await legacy_executor.execute(request)
For pure computation logic, you can also use shadow execution:
legacy_result = await legacy_executor.execute(request)
if shadow_enabled(request):
new_result = await new_executor.execute_shadow(request)
compare_and_record(legacy_result, new_result)
return legacy_result
This approach suits large systems. It is slower, but lower risk. The more AI-written code is already in production, the less appropriate it is to treat it with “I can rewrite this better” confidence.
Every Refactor Should Explain Its Benefit
Refactoring is not an aesthetic activity.
Each refactor should ideally explain its benefit:
Extract pure rules so unit tests replace slower integration tests.
Wrap provider clients so external error mapping is unified.
Move billing logic into an independent module to avoid duplicate billing.
Delete unused branches to remove 400 lines of useless context.
Split a large function so permission, execution, and recording failures can be located separately.
If you cannot explain the benefit, do not change it yet.
A common engineering mistake is calling all disliked code “technical debt”. Real technical debt affects delivery speed, stability, cost, or team understanding. Otherwise it may only be personal preference.
Be especially restrained when inheriting AI code. It may not match your habits, but if its boundaries are clear, tests are sufficient, and observability is complete, it may not need an immediate rewrite.
Code Review Should Look at Behavior, Not Only Style
When reviewing AI code, do not stare only at formatting.
Things a formatter can solve are not worth much human attention. Humans should look at:
Whether business rules are explicit
Whether failure scenarios are complete
Whether transaction boundaries are correct
Whether side effects are idempotent
Whether external calls have timeouts
Whether error codes are stable
Whether logs and metrics are sufficient
Whether sensitive data leaks
Whether tests cover critical paths
Whether module dependencies point backward
AI-generated code most often fails in places that look reasonable. It may catch all exceptions and return success. It may call a slow API inside a database transaction. It may write raw user input into logs. It may write history repeatedly during retry.
Those are the real review points.
A Checklist for Inheriting AI Code
Put this checklist into the project:
1. Can the project start with one command?
2. Is there a minimal fixture and local test data?
3. Do core entry points have regression tests?
4. Do known bugs have failing tests?
5. Do large functions carry multiple responsibilities?
6. Can core rules be tested without database and network?
7. Are external side effects centralized?
8. Are there slow external calls inside transactions?
9. Are retries idempotent?
10. Are error codes, logs, metrics, and traces complete?
11. Can unused code be deleted?
12. Do high fan-in and high fan-out functions have protective tests?
13. Do old and new implementations need gray release or shadow comparison?
This checklist is not meant to be completed in one pass. It is the handoff order.
A Refactoring Prompt for AI
AI can help with refactoring, but the prompt must constrain its actions:
Do not directly rewrite the code yet.
Please review this code and output:
1. A list of the main responsibilities in the current code.
2. Entry functions, external side effects, database writes, network calls, and queue operations.
3. High-risk functions: function length, branch count, fan-in/fan-out, whether side effects are mixed.
4. Which regression tests are missing for existing behavior.
5. Pure business rules that can be extracted first.
6. Unused code that can be deleted, but evidence must be provided.
7. A small-step refactoring plan where tests must pass after each step.
8. Places that cannot be changed directly and need gray release or shadow comparison.
Do not generate the final implementation until I confirm.
The point is to make AI analyze first, instead of jumping directly to “optimization complete”.
AI is good at code maps, call-chain summaries, duplicated-logic detection, and test-case generation. Boundary judgment, release risk, and tradeoffs still belong to humans.
A Prototype Is Not a Sin; Unmaintained Code Is
Much AI code starts as a prototype.
A prototype is not the problem. Many good things in engineering grow from prototypes. The problem is treating a prototype as a long-term asset without tests, boundaries, observability, and deletion of useless code.
When inheriting AI code, the best attitude is neither contempt nor blind faith.
It runs, so it has value. It is messy, so engineering is not finished.
The programmer’s job is to keep the value and remove the risk.
This work does not sound cool, but it matters. In the future, many systems will carry traces of AI-generated code. People who can inherit, judge, clean up, and refactor in small steps will be more useful than people who only complain, “Who wrote this code?”
Follow ZiCode on WeChat
If this post was useful, you can follow later updates on WeChat as well.
X / Twitter
Follow @ax2_zicode
Faster technical notes, short thoughts, and new-post alerts are posted on X.