When you've been running a personal app for a few years, there comes a day when you open a file and realize: I can't read my own code anymore. Comment-outs layered on top of each other, functions called from somewhere unknown, logic duplicated in five places with subtle variations. If that sounds familiar, this post is for you.
That was exactly my situation. Running two apps simultaneously while continuously adding features had left my codebase in what I call the "works but untouchable" state — functional, but too fragile to refactor safely.
So at the start of 2026, I committed three months to seriously tackling technical debt using Claude Code. This is the record of what I did, in what order, what worked, and where Claude hit its limits.
Step One: Build a Debt Map — Put Numbers on the Problem
The most common reason refactoring stalls before it starts is the question "where do I even begin?" Without a concrete map, you work on whatever annoys you most rather than what matters most.
The first thing I did was ask Claude Code to generate a technical debt inventory. Running this from the project root:
claude "Analyze this entire repository and produce a Markdown report covering:
1. All TODO/HACK/FIXME/XXX comments with file locations
2. Duplicate code patterns (50+ lines of similar logic)
3. High-complexity functions (5+ levels of nesting or conditionals)
4. Circular module dependencies
5. Files with zero test coverage (if test config exists)
For each item, add a 1-5 star rating on two axes: 'blast radius if it breaks' and 'difficulty to fix'."Claude spent a few minutes scanning the whole repo and produced a surprisingly specific report. My numbers:
- TODO/HACK comments: 47 instances (23 of them over two years old)
- Obvious duplicate code: 8 patterns (~1,200 lines)
- Circular dependencies: 3 module pairs
- Files with zero test coverage: 67% of the codebase
Something shifts when you move from "my code is kind of a mess" to "I have 3 circular dependencies and 8 duplicate patterns." The problem becomes bounded, approachable. That map made it possible to prioritize without getting overwhelmed.
Prioritization: The Urgency × Impact Matrix
With a map in hand, the next challenge is deciding what to tackle first. My mistake in previous attempts was starting with whatever looked the ugliest. Aesthetically offensive code isn't always the most dangerous code.
The key to getting useful prioritization from Claude is including your real-world constraints:
claude "Using the debt report we just created, help me prioritize the work.
Constraints:
- I have 1-2 days per week available for refactoring
- I cannot break live functionality (this is a production app)
- I'm planning to add Feature X in three months, and its surrounding modules need to be clean first
Given these constraints, please categorize items into: 'this week', 'this month', and 'within three months'."The output gave me something concrete: tackle the 8 duplicate code patterns first (this week and next week), skip the circular dependency resolution until month two because the blast radius is too large to handle quickly.
Crucially, telling Claude about the upcoming Feature X caused it to flag which debt items lived in that feature's neighborhood — and those jumped to the front of the queue.
Removing Duplicate Code: Safe Refactoring with Subagents
The highest-priority items were duplicate error handling patterns scattered across 8 API files. Each file had its own slightly different version of the same try/catch logic.
This is where Claude Code's subagent capability with --headless and git worktrees becomes genuinely powerful. You can experiment without touching your main branch at all:
# Create an isolated working environment
git worktree add /tmp/refactor-api-errors -b refactor/api-error-handling
claude --headless "
Working directory: /tmp/refactor-api-errors
Task: Consolidate the duplicated error handling logic found in these 8 files:
src/api/users.ts, src/api/products.ts, src/api/orders.ts, ... (etc.)
Steps:
1. Analyze the error handling patterns across all 8 files
2. Identify what can be shared vs. what must stay per-file
3. Create src/utils/apiErrorHandler.ts as a shared utility
4. Refactor all 8 files to use the new module
5. Verify no TypeScript errors remain
6. Output a final diff of all changes
"The result: 8 patterns collapsed into one shared utility plus thin per-file wrappers. ~340 lines became ~95 lines.
Important caveat: I never merged the output directly. I always reviewed the diff, checked that type definitions were accurate and error messages hadn't changed, then decided whether to merge. The worktree approach means your main branch is never at risk while you're exploring.
Resolving Circular Dependencies: Understand Before You Fix
Circular dependencies took nearly a month — the longest stretch. That's because they're not really a code problem; they're a design problem. Module A importing module B which imports module A means the original separation of concerns was unclear.
Asking Claude to "just fix it" here would have been a mistake. Resolving circular dependencies requires redesigning module responsibilities — a judgment call that belongs to the developer, not the AI.
Instead, I asked Claude to analyze why the dependency existed:
claude "There's a circular dependency between src/store/userStore.ts and src/components/UserProfile.tsx.
Analyze why this pattern emerged, explain the design flaw, and suggest three approaches to resolve it.
Include trade-offs for each approach."The three options it produced (event bus, context separation, dependency injection) gave me the vocabulary to make an informed decision. I chose, then asked Claude to implement. That "analyze → decide → implement" flow is the right one for structural problems.
Adding Tests to Untested Code: The Incremental Approach
With 67% of files having zero test coverage, trying to test everything at once is a trap. I used a narrowly scoped approach:
claude "Write tests for src/utils/priceCalculator.ts.
Don't try to cover everything — instead, identify the 3 functions in this file where a bug would most impact users, and write focused tests for those 3 functions using vitest.""The 3 most impactful functions" rather than "100% coverage" was the prompt that made this sustainable. Over three months, coverage went from 0% to 38%. Not perfect — but the core user-facing paths are now protected.
Automating the Weekly Health Check: The Hook I Actually Run
The single highest-leverage part of these three months wasn't any individual refactor. It was measuring the same four numbers every week with the same ruler. Willpower alone didn't sustain that, so I wired it into a Claude Code hook.
One addition to .claude/settings.json:
{
"hooks": {
"SessionStart": [
{
"matcher": "startup",
"hooks": [
{ "type": "command", "command": ".claude/scripts/debt-healthcheck.sh" }
]
}
]
}
}The script bails out immediately unless seven days have passed since the last run — otherwise it would fire on every session start and become noise you learn to ignore.
#!/usr/bin/env bash
set -euo pipefail
STAMP=".claude/.debt-last-run"
NOW=$(date +%s)
# Skip if the last run was under 7 days ago (604800 seconds)
if [ -f "$STAMP" ] && [ $(( NOW - $(cat "$STAMP") )) -lt 604800 ]; then
exit 0
fi
OUT=".claude/debt/$(date +%Y-%m-%d).md"
mkdir -p "$(dirname "$OUT")"
claude -p "Inventory this repository's technical debt and report ONLY four metrics:
TODO/HACK comment count, duplicate pattern count, circular dependency pairs, and maximum
function complexity. One metric per line, format 'metric: value'. No prose, no preamble." > "$OUT"
# Show only the delta against last week's snapshot
PREV=$(ls .claude/debt/*.md 2>/dev/null | tail -2 | head -1)
if [ -n "$PREV" ] && [ "$PREV" != "$OUT" ]; then
diff "$PREV" "$OUT" || true
fi
echo "$NOW" > "$STAMP"Two details in there are deliberate.
The prompt constrains the output to four lines with no prose. Whatever a SessionStart hook prints to stdout lands directly in the session context. A few thousand words of report would burn context before you've even stated what you came to do. Four numbers are enough to decide whether anything moved.
The reports are also date-stamped rather than overwritten. Keeping the history lets diff surface the change instead of the level — and change is what actually prompts action. Nobody reacts to "19 TODOs." People react to "three more than last week."
One caution: a hook script that exits with code 2 is treated as blocking. A health check exists to inform, not to gate your work, so the diff return value is swallowed with || true and the script always exits clean. Get this wrong and the tool you built to measure debt becomes the thing standing between you and your editor.
Three Things That Didn't Work
A record of only the successful steps isn't reproducible. And as an indie developer with no reviewer to catch me, the time it took to notice each of these was the real cost. Here's what failed outright.
I handed over too many files at once. For the duplicate-code consolidation, my first attempt passed all eight files in a single instruction. The first four came back cleanly refactored against a shared utility — and the last four had quietly grown a second helper with slightly different conventions. It resembles the way a decision made an hour ago fades during a long stretch of work. Splitting into batches of roughly five, and explicitly naming the path of the shared module created in the previous batch, ended the drift.
The generated tests mirrored the implementation. This was my first stumble on the testing work. Feed Claude the implementation and ask for tests, and you get assertions transcribed from the code's own logic — which pass whether or not the code is correct. The fix was to separate the inputs: hand over a short spec note instead of the implementation file, with the instruction "derive expected values from this spec; do not read the implementation." It costs more setup time. Tests written that way caught two real bugs.
I turned a metric into a goal. Maximum function complexity of 18 was the number that bothered me most, so for a stretch I asked directly for lower complexity. Conditionals got mechanically extracted into small functions, and following any single code path started requiring three hops through the call stack. The metric improved while readability got worse. These numbers are for noticing regression — optimizing them directly is a different activity, and not a useful one.
Three Months Later: The Numbers
At the end of month three, I ran the same analysis that generated the original debt map. Results:
| Metric | Start | Month 3 | Change |
|---|---|---|---|
| TODO/HACK comments | 47 | 19 | −60% |
| Duplicate code patterns | 8 | 2 | −75% |
| Circular dependencies | 3 | 0 | −100% |
| Test coverage | 0% | 38% | +38pt |
| Maximum function complexity | 18 | 7 | −61% |
The numbers look good. But the most meaningful change wasn't quantitative — it was that adding new features stopped feeling scary. The "works but untouchable" state became "imperfect but workable."
What I Learned About Claude Code and Technical Debt
A few things that shaped my approach, worth keeping in mind:
Don't ask Claude to "just fix everything." Debt reduction requires design decisions. Claude is excellent at generating options, implementing a chosen approach, and writing tests — but which approach to take is yours to decide.
The worktree pattern is your safety net. Running subagents in isolated worktrees means zero risk to your production code while you experiment. This alone makes Claude Code worth using for refactoring work.
Run the health check weekly. Re-running the same debt analysis once a week takes minutes and tells you immediately if you're sliding backward. I've since automated this via a Claude Code hook.
Done is better than perfect. Technical debt never reaches zero. The goal is moving from "works but untouchable" to "touchable and improving." Once you reach that state, the velocity gains compound on their own.
The next step is automating ongoing quality monitoring — a hook that runs on every PR, flags any metrics that are worsening, and leaves a comment. Building that system is the logical continuation of this three-month effort.
If you're standing in front of a codebase that works but feels untouchable, I hope some of this gives you a place to start.