← Back to Field Notes

The Real Cost of Salesforce Technical Debt

·9 min read

A few years ago a delivery lead asked me to estimate what should have been a trivial change: add a validation rule to Opportunity so reps couldn't mark a deal Closed Won without a signed contract on file. Ten minutes of work. I gave it three weeks. She thought I was padding the estimate. I wasn't.

The org had four triggers on Opportunity, two of them undocumented, a managed package that also wrote to the close-date fields, and a nightly batch that assumed nothing else touched the record between 1am and 2am. Adding a validation rule meant reading all of that first, because in that org nothing was ever just one change. That gap — between what a change should cost and what it actually cost — is technical debt, and it was quietly charging interest on every story we shipped.

Debt Is a Money Word, So Use It

I'm deliberate about calling it debt and not "legacy code" or "cruft." Debt has a principal and it has interest, and the interest is the part that ruins you. The principal is the shortcut you took — the copy-pasted class, the hardcoded record type ID, the Flow you built at 5pm because standing up the trigger framework felt like overkill that afternoon. The principal is cheap. You could pay most of it back in a day.

The interest is what you pay every single time anyone touches that area afterwards. It shows up as the extra hour a developer spends working out whether it's safe to change a field. It shows up as the regression that only appears in production because nobody understood the third automation in the chain. It shows up as the senior engineer who becomes the only person allowed near a class, which means that class now sets the pace for the whole team.

When I explain debt to a CFO or a delivery lead, I don't talk about code quality. I talk about the interest rate. A quality problem is easy to defer forever. An interest rate that's eating a third of your delivery capacity is a number someone has to answer for.

The Interest Compounds Where You Aren't Looking

The debt people can see is rarely the expensive kind. Everyone can point at the 400-line Apex method and agree it's ugly. But an ugly method that nobody changes costs nothing. It just sits there. The dangerous debt is the debt sitting directly under your most-changed surface area.

On the platform, that surface is almost always automation on the core objects. Account, Contact, Opportunity, Case. If you've got overlapping triggers, record-triggered Flows, and a Process Builder that somehow survived a migration, all firing on the same object, the interest on that is enormous because that object changes constantly. This is exactly why I'm so rigid about one trigger per object and a real handler layer — not for neatness, but because that's the surface where compounding hurts most.

The other quiet compounder is security. Permission drift doesn't crash anything, so it never gets prioritised, and then five years later you have 30-odd profiles that were all cloned from each other and nobody can tell you the difference between them. I've written separately about untangling that by moving to permission sets, but the point here is that the cost was invisible right up until the day an auditor asked "who can export customer data" and the honest answer was "we're not entirely sure."

Archaeology Is the Line Item Nobody Budgets For

Here's the war story. A retail lender, APRA-regulated, roughly 180 users, about seven years into their Salesforce build. Good people, chronic understaffing, and a habit of shipping under deadline and promising to clean it up later. Later never got a sprint of its own.

For one quarter I tagged every hour the dev team logged with what it was actually spent on. Not the Jira category — the real activity. When the numbers came back, around 34% of engineering time that quarter went to a single bucket I'd labelled "understanding existing behaviour before we could safely change it." Not building. Not testing. Archaeology. A third of a well-paid team's time went to reading code before touching it, because the code didn't explain itself and the people who wrote it were gone.

The centre of gravity for all of it was an Apex class called OppTriggerHandler2. The original OppTriggerHandler still existed too, disabled but never deleted, so every new starter lost half a day working out which one actually ran. The 2 had been written by someone named Ravi who'd left eighteen months earlier, and the running team was genuinely frightened of it — it had no tests worth the name, it queried inside loops, and it branched on profile name in three places. Every change to Opportunity went through Ravi's class, and Ravi wasn't there.

We didn't have a heroic fix. We ring-fenced it, wrote characterisation tests against the behaviour we could observe, and rewrote it in pieces over about five months alongside normal delivery. The last hardcoded profile check came out sometime the following quarter. It's better now. It is not a case study with a bow on it.

You Can't Cost What You Won't Measure

The reason debt wins these arguments is that it's abstract and the roadmap is concrete. So I make it concrete. The Tooling API gives you enough to build a rough debt map of any org in an afternoon, and a rough map beats a strong opinion in a steering meeting.

The first thing I pull is large classes with poor coverage — the intersection of "big" and "untested" is where the scary debt lives:

SELECT ApexClassOrTrigger.Name, NumLinesCovered, NumLinesUncovered
FROM ApexCodeCoverageAggregate
WHERE NumLinesUncovered > 40
ORDER BY NumLinesUncovered DESC

Cross-reference that against ApexClass.LengthWithoutComments and you've got a shortlist of the classes that are both load-bearing and unprotected. Then I look at the specific patterns that carry the highest interest. Profile-name branching is my favourite tell, because it's cheap to write and expensive to live with:

// The kind of line that costs you for years.
// Breaks silently the moment someone renames or splits the profile,
// and blocks any move off profiles onto permission sets.
if (UserInfo.getProfileId() == '00e5w000001AbCdEFG') {
    approvalRequired = false;
}

That hardcoded ID is fifteen characters of principal and a decade of interest. It couples business logic to a security artefact that should be free to change, and it fails without an error the day someone edits the profile. The fix is a custom permission and a FeatureManagement.checkPermission() check, but the point of the query above isn't the fix — it's that I can now walk into the room with "we have 11 classes doing this" instead of "the security model feels fragile."

For SOQL and governor-limit debt specifically, the same measure-first discipline applies — I go deeper on the query patterns worth hunting down elsewhere, but you find them by profiling real transactions, not by reading code and guessing.

Not All Debt Is Worth Paying Down

This is the part most technical debt articles get wrong. They treat all debt as a moral failing to be eliminated. It isn't. Some debt is a perfectly rational loan you should keep servicing.

If a shortcut sits under code that never changes, the interest rate is effectively zero, and paying down the principal is just spending money to make a diff look nicer. I've watched a team burn two sprints "refactoring" a batch job that had run untouched for four years. It was cleaner afterwards. It also delivered exactly nothing, because nobody was ever going to change it. That refactor was the debt — the opportunity cost of the work we didn't do instead.

So I don't rank debt by how bad the code is. I rank it by interest rate, which is roughly change-frequency multiplied by blast-radius. High-churn, high-blast-radius debt gets paid down now. Low-churn debt gets a comment, a note in the register, and left alone. The honest version of a debt strategy isn't "fix everything." It's "know which loans are cheap and stop pretending the expensive ones are free."

The one category I never let anyone rationalise as acceptable is missing tests on high-churn code. That's not debt you're servicing — it's debt with no ceiling, because you can't even measure the interest until something breaks in production.

What I'd Do Differently

I'd make the debt visible from day one instead of discovering it in a crisis. On the last two builds I've kept a lightweight debt register in the repo — a plain markdown file, one line per known shortcut, with the principal, the reason it was taken, and a rough interest rate. It's not sophisticated. It just means the shortcut is a decision on record rather than a landmine.

I'd also stop letting "we'll clean it up later" pass without a date. Debt taken with a repayment plan is a tool. Debt taken with a vague promise is how you end up with an OppTriggerHandler2. If the cleanup doesn't have a sprint, it isn't a plan, it's a wish.

And I'd wire the measurement in early. The gap between the org I described at the top and a healthy one wasn't talent — it was that one org could see its interest rate and the other couldn't. If you want a second pair of eyes on where your org is actually bleeding capacity, that's the kind of review I do. Most of the value is just turning "the code feels bad" into a number someone can act on.

A healthy Salesforce org isn't one with no debt. It's one where every loan is on the books and someone knows the interest rate. The org that scares me isn't the one carrying debt — it's the one that thinks it's carrying none.

Liked this?

New essays in your inbox when I publish. No cadence, no spam.