Nobody had told that client the truth in 4 years.
I was the first. And I almost lost them because of it.
I was assigned what looked like a simple task: add a new feature to an application that had been running in production for years. The previous team's estimate: one week. What nobody told me was that the previous team had quit precisely because of this project.
I opened the repository and understood immediately.
4 years of code with no documentation. Outdated dependencies with known vulnerabilities. Business logic mixed directly into the UI. One "quick fix" slapped on top of another "quick fix," 23 times over. The system worked, but the same way a building with cracked foundations works: as long as you don't touch it.
There's a name for that: technical debt. And in this case, the roof had already caved in.
I had to be honest with the client: it wasn't possible to add that feature as-is. The foundation had to be fixed first. Their response stopped me cold:
"But nobody had ever told me that before."
That was the real problem. It wasn't a code problem. It was a communication problem. Four years of teams that saw the disaster but never translated it into business language. The client assumed "that's just how software is."
It's not.
What followed was a 7-month process. The original feature was delivered in week 7 — after 6 weeks of redesigning the architecture. This article documents that process: the 5 exact steps we followed to dig out of that hole without sinking the project.
Step 1: Make the debt visible (before touching a single line)
The first mistake most teams make is jumping straight into refactoring without mapping the territory. You go into the code, see something that bugs you, fix it, introduce a bug somewhere else you didn't know about, and now the problem is worse.
Before writing a single new line, audit.
What I did was spend the first 3 days just reading and documenting:
- Dependency map: What modules depend on what? Tools like
madge(for Node.js) orpydeps(Python) generate a graph for you. If the graph looks like a plate of spaghetti, that tells you everything. - Test coverage: Run the coverage report. On this project it was 0%. Zero. There wasn't a single automated test.
- Outdated dependencies:
npm auditorpip-audit. I found 47 known vulnerabilities in production. - Highest churn points: Which files have been modified the most in the git history?
git log --stat | grep "file changed" -A 1tells you. Those files are usually where the most painful debt lives. - Cyclomatic complexity: Tools like
complexity-reportor SonarQube identify functions with too many possible execution paths. Numbers above 10 are a red flag.
At the end of this step you have something critical: an inventory with numbers. Not opinions. Numbers.
Step 2: Translate it into business language (the step nobody takes)
This was the step that changed everything.
The previous teams had seen the same mess. They knew. But they never communicated it in a way the client could understand what was at risk. They talked about "legacy code" and "monolithic architecture" — terms that are white noise to a product manager.
Technical debt is invisible to those who don't live with it. Its consequences aren't.
Before the meeting with the client, I translated the technical inventory into business impact:
0% test coverage → Every deploy is a gamble. The risk of a production outage is high.
47 known vulnerabilities → Legal and security risk. User data is exposed.
3-week cycle time per feature → While your competition ships in days, you take weeks.
High churn in the payments module → The most critical business module is the most unstable one.
With that mapping, the conversation completely changed. It was no longer "the code is bad." It was "you have a concrete operational risk, and here are the numbers."
The client approved the budget that same week.
The lesson: Don't ask for permission to fix code. Present the risk of not fixing it.
Step 3: Prioritize with judgment (not everything needs fixing)
One of the most common mistakes in technical rescue projects is trying to fix everything. That's a recipe for failure. There's always more debt than time and budget allow you to address.
I used a simple two-axis matrix: business impact vs resolution effort.
LOW EFFORT HIGH EFFORT
┌─────────────────────────────────────┐
HIGH IMPACT │ ✅ DO IT NOW 📅 PLAN IT │
│ │
LOW IMPACT │ 🧹 FIX OPPORTUNISTICALLY ❌ SKIP IT │
└─────────────────────────────────────┘
✅ Do it now (high impact, low effort): Update dependencies with critical vulnerabilities, add basic CI/CD, write tests for the payment flow. These things build immediate confidence and unblock future work.
📅 Plan it (high impact, high effort): Refactor the core architecture, separate business logic from the UI, migrate the database. These go on the roadmap with realistic estimates.
🧹 Fix opportunistically (low impact, low effort): Rename confusing variables, clean up dead imports, improve error messages. Apply the Boy Scout Rule: leave the code a little better every time you touch it.
❌ Skip it (low impact, high effort): There's code that works, nobody touches, and "fixing" it doesn't move any business needle. Don't touch it.
In this project, 80% of the benefits came from 20% of the changes. Identify that 20%.
Step 4: Refactor incrementally (never a total rewrite)
The temptation on projects with this much debt is to throw everything out and start from scratch. It's the natural instinct of any developer who sees messy code. Resist it.
Total rewrites are one of the most costly mistakes in software history. Netscape tried it and nearly died. The project takes twice as long as estimated, the business stalls, and in the end the new code has its own problems.
The alternative is the Strangler Fig Pattern: you wrap the old code with new code and gradually migrate traffic until the old system dies naturally.
In practice, it looks like this:
// BEFORE: Mixed logic, impossible to test
function saveOrder(data) {
// 300 lines of validation + DB + email + logs + payments
// All together. All mixed. All fragile.
}
// AFTER: Separated responsibilities, testable
class OrderService {
constructor(
private validator: IOrderValidator,
private repo: IOrderRepository,
private notifications: INotificationService,
private payments: IPaymentGateway,
) {}
async process(dto: OrderDTO): Promise<OrderResult> {
const errors = this.validator.validate(dto);
if (errors.length) throw new ValidationError(errors);
const order = await this.repo.save(dto);
await this.payments.charge(order);
await this.notifications.confirm(order);
return order;
}
}
The old module keeps running. The new one is built alongside it. When the new one is tested and verified, traffic migrates. The old one gets removed.
Rule of thumb: Never leave the project in a worse state than you found it. Every PR should improve something, even if it's small.
Step 5: Prevent it from piling up again
Fixing existing technical debt without changing the processes that created it is like bailing out a boat that has water coming in through a hole. In a few weeks you're back in the same place.
These are the practices we put in place so the problem wouldn't come back:
Tests as a merge requirement: No PR reaches main without tests. The coverage of the affected module can't decrease. This is configured in CI in less than an hour and completely changes the team's culture.
20% of the sprint for technical debt: Non-negotiable, not sacrificed for last-minute features. It's an investment in future velocity. Teams that do this consistently ship more, not less.
Tech debt as a backlog item: Technical debt gets tickets, gets estimates, gets priority. It stops being invisible. When the client can see it in the backlog, the conversation about priorities is completely different.
Updated Definition of Done: A feature isn't "done" if it left more debt than there was before. Include criteria like "the affected module has tests" and "no new undocumented dependencies."
ADRs (Architecture Decision Records): Every relevant technical decision is documented in a Markdown file in the repository. Not for bureaucracy, but so the developer who shows up in 2 years understands why things are the way they are. The lack of these records is exactly what generates those 23 stacked "quick fixes."
The result
7 months total. The first 6 weeks were pure architectural work — without delivering a single new feature. The original feature, the one that started everything, was delivered in week 7 of that rescue phase.
By the end of the project, the client had:
- Test coverage at 78% (up from 0%)
- Deploy time reduced from "manual and terrifying" to 12 automated minutes
- Zero known critical vulnerabilities
- A team that could make changes with confidence
But more important than all of that: for the first time in 4 years, someone had explained to them exactly what state their software was in and what it meant for their business.
What I learned
The most valuable technical work sometimes isn't writing code. It's translating the problem so that the person making decisions understands what's at risk and why.
The previous teams had seen the same disaster. What was missing wasn't technical skill — it was communication. Four years of competent professionals who never found the words to make visible something only they could see.
If you're on a project with accumulated technical debt, the first step isn't opening your editor. It's having an honest conversation with the person who makes the decisions.
The code can wait a day. The conversation can't.
Does your project have accumulated technical debt?
If you recognize any of these symptoms:
- Every new feature takes longer than the last
- You're afraid to touch certain modules for fear of breaking something
- Your team turns over more than it should
- Production bugs keep coming back
I can help you audit the current state, design a realistic rescue plan, and execute it without disrupting the business.


