Refactoring and Technical Debt
Imagine you borrow money to buy something you need in a hurry. It solves today's problem — but
now you owe more than you took, because the loan charges interest. Ignore it and
the interest keeps stacking up until the debt dominates your budget; pay it down and you are free
again. Writing software has a startlingly similar bargain, and it even has the same name:
technical debt.
Every time you take a quick-and-dirty shortcut to ship faster — a copy-pasted block instead of a
shared function, a mysterious 0.2 hard-coded in three places, a
400-line method nobody dares touch — you "borrow" speed now. The interest is paid later, by
everyone who edits that code afterwards: every future change becomes slower, riskier and
more error-prone. That is technical debt. And the tool that pays it back is
refactoring.
What refactoring actually is (and is not)
Refactoring means improving the internal structure of code
without changing its external behaviour. The program does exactly what it did
before — same inputs produce the same outputs — but the code inside is now cleaner: easier to
read, easier to change, harder to break. You are reorganising the engine, not repainting the car
or adding a sunroof.
That "same behaviour" promise is the whole discipline. Refactoring is not fixing bugs,
not adding features, and not rewriting from scratch. Those are all worthwhile,
but they change what the program does, so they are a different activity. Refactoring
changes only how the code is written, in small, verified steps.
- Behaviour is preserved. Same inputs → same outputs, before and after.
- Only structure changes. You reshape the code, not the feature set.
- Work in small steps. One tiny transformation at a time, each reversible.
- Never mix concerns. Do not refactor and change behaviour in the same step.
- Lean on tests. A passing test suite is your proof that behaviour held.
Code smells: symptoms of hidden debt
How do you know code carries debt? You learn to notice code smells — surface
signs that usually hint at a deeper structural problem. A smell is not a bug; the code may run
perfectly. It is a warning that the code will be hard to change, which is exactly what
debt costs you. Some classic ones:
-
Long method / long function — one function that goes on for screens and does
five different jobs. Hard to read, hard to test, hard to reuse a part of.
-
Duplicated code — the same logic copy-pasted in several places. Fix a bug in
one copy and the others still carry it. (This is the enemy the DRY principle — Don't
Repeat Yourself — warns against.)
-
Large class — a class that has grown to know and do far too much (a "God
object"). It should be split into smaller, focused pieces.
-
Long parameter list — a function taking eight arguments is easy to call
wrongly and hard to remember. Often the arguments want to be grouped into an object.
-
Magic numbers — a bare literal like 0.08 or
100 whose meaning only lives in the author's head. Give it a name.
-
Deep nesting — if inside
if inside if, an arrow of indentation
marching off the right of the screen. Usually flattenable with guard clauses.
No — and that distinction matters. A bug is code that behaves
wrongly; a smell is code that behaves correctly but is
badly shaped. Smelly code passes all its tests and ships happily today. The reason to
care is the future: smells are where bugs love to hide and where change is slow and dangerous.
Refactoring targets smells before they turn into bugs — you clean the code precisely
because it currently works, so you have a safe, behaviour-preserving starting point.
A few safe refactoring moves
Refactoring is not improvisation; it is a catalogue of small, named transformations, each of
which is behaviour-preserving by construction. Four you will use constantly:
-
Extract function (a.k.a. extract method) — lift a chunk of a long function
into its own well-named function and call it. The long method shrinks; the chunk gets a name
that documents its intent.
-
Rename — give a variable, function or class a name that says what it really
means. d becomes daysOverdue. Cheap, and
it removes a whole class of misunderstanding.
-
Replace magic number with a named constant — swap a bare literal for a named
constant so its meaning is explicit and it can be changed in one place.
-
Guard clauses — return (or throw) early for the exceptional cases at the top
of a function, so the main logic stops being buried inside layers of nested
ifs.
None of these changes what the program produces. Each just makes the next change easier — which
is the entire point.
Worked example: a tangled price calculation
Here is a function that computes an order total. It works — but it reeks. Everything is jammed
into one method, the discount and tax rates are magic numbers with no names, and the logic is
nested three deep. Below it is the same function after refactoring: magic numbers
promoted to named constants, the two sub-calculations extracted into their own functions, and a
guard clause flattening the nesting.
The runnable block proves the crucial claim: both versions log identical output
for the same sample orders. Behaviour is preserved — only the structure improved. Press
Run ▶ and read the two columns of results: they match line for line.
// ---------- BEFORE: one smelly function ----------
// Magic numbers, duplicated logic, deep nesting, does several jobs.
function orderTotalSmelly(qty: number, unitPrice: number, member: boolean): number {
let total = qty * unitPrice;
if (qty > 0) {
if (total > 100) {
if (member) {
total = total - total * 0.15; // loyal + big order
} else {
total = total - total * 0.10; // big order
}
} else {
if (member) {
total = total - total * 0.05; // loyal, small order
}
}
total = total + total * 0.2; // add tax
}
return Math.round(total * 100) / 100;
}
// ---------- AFTER: refactored, SAME behaviour ----------
// Named constants + extracted functions + a guard clause.
const TAX_RATE = 0.2;
const BIG_ORDER_THRESHOLD = 100;
const BIG_MEMBER_DISCOUNT = 0.15;
const BIG_DISCOUNT = 0.1;
const MEMBER_DISCOUNT = 0.05;
function discountRate(subtotal: number, member: boolean): number {
if (subtotal > BIG_ORDER_THRESHOLD) {
return member ? BIG_MEMBER_DISCOUNT : BIG_DISCOUNT;
}
return member ? MEMBER_DISCOUNT : 0;
}
function roundMoney(amount: number): number {
return Math.round(amount * 100) / 100;
}
function orderTotal(qty: number, unitPrice: number, member: boolean): number {
if (qty <= 0) return 0; // guard clause: nothing ordered, nothing owed
const subtotal = qty * unitPrice;
const discounted = subtotal * (1 - discountRate(subtotal, member));
const withTax = discounted * (1 + TAX_RATE);
return roundMoney(withTax);
}
// ---------- Prove behaviour is unchanged ----------
const orders = [
{ qty: 3, unitPrice: 50, member: true }, // big order, member
{ qty: 3, unitPrice: 50, member: false }, // big order, non-member
{ qty: 2, unitPrice: 20, member: true }, // small order, member
{ qty: 2, unitPrice: 20, member: false }, // small order, non-member
{ qty: 0, unitPrice: 99, member: true }, // nothing ordered
];
console.log("qty price member | before | after | match?");
for (const o of orders) {
const before = orderTotalSmelly(o.qty, o.unitPrice, o.member);
const after = orderTotal(o.qty, o.unitPrice, o.member);
const row =
`${o.qty} ${o.unitPrice} ${String(o.member).padEnd(5)} | ` +
`${before.toFixed(2).padStart(7)} | ${after.toFixed(2).padStart(7)} | ` +
(before === after ? "yes" : "NO!");
console.log(row);
}
Every row reads yes: the refactored code returns exactly what the
tangled version did. But now the discount policy lives in one small, named function, the tax rate
has a name you can change in one place, and the "nothing ordered" case is handled up front
instead of nested three deep. The next person to change the discount rules will thank you.
Why refactoring is safe: the tests
There is an obvious danger in reshaping working code: what if you break it? The safety net is
automated testing.
Before you refactor, you make sure there is a suite of tests that captures the code's current
behaviour and passes. Then you refactor in small steps, running the tests after each one. As long
as the tests keep passing, you have evidence that behaviour is unchanged — which is the
entire promise refactoring makes. If a step turns a test red, you undo just that step and try
again.
This is why "refactoring without tests" is really just "editing and hoping". The tests are what
let you move quickly and confidently, because they answer the one question that matters after
every change: does it still do the same thing? Our worked example above used a
deliberately simple check — comparing the two versions' outputs — but a real project encodes those
checks as a permanent test suite that runs on every change.
Debt compounds: the cost of putting it off
Why not just leave the mess and keep shipping features? Because technical debt, like financial
debt, compounds. Each shortcut makes the code a little harder to work in, which
makes the next feature a little slower to add, which tempts you into another shortcut. Left alone,
the cost of change climbs steeply. Pay the debt down with regular small refactorings and the cost
of change stays low and roughly flat. The diagram below tells that story: two teams start
together, and their fortunes diverge.
Because "works today" and "cheap to change tomorrow" are different properties. Software that is
never touched again could stay smelly forever with no harm done. But almost all real software
is changed continually — new requirements, new bugs, new platforms. The value of clean
code is entirely about the future edits it makes fast and safe. Refactoring is
an investment: you spend a little effort now to make every later change cheaper. That is exactly
why it is framed as paying down debt rather than as tidying for its own sake.
A common and dangerous misconception is that refactoring means rewriting the code from
scratch, or that you can refactor and add a feature in the same breath.
Both break the discipline. Refactoring changes structure only, in small steps, each one
verified against the tests. The moment you also change behaviour — fix a bug, add a feature,
alter an output — you are no longer refactoring, and if something breaks you can no longer tell
which kind of change caused it. The rule is strict:
never mix a refactor with a behaviour change in one step. Get to green, commit
the refactor, then make the behaviour change as a separate, clearly labelled step. And a
big-bang "let's just rewrite it all" is the riskiest move of all — it throws away years of
hard-won behaviour with no tests to catch what you lose.