DEV Community

Timevolt
Timevolt

Posted on

Refactoring Legacy Code: The One Ring of Clean Architecture

The Quest Begins (The "Why")

I still remember the first time I opened a legacy service that felt like stepping into a dungeon with no map. The file was a monolith of 2,300 lines, a tangled web of conditionals, direct database calls, and side‑effects scattered everywhere. A simple request to change how a discount was applied turned into a three‑hour debugging marathon because I had to trace how a mutable global variable was being tweaked in three different places before the final price was even calculated.

When the tests finally passed, I felt relieved—but also uneasy. I knew the next developer (or future me) would face the same nightmare. That moment sparked a question: What if I could isolate the part of the code that actually does the work from the part that just talks to the outside world?

That question led me to a single best practice that has reshaped how I write every line of code since: make functions pure whenever possible.

The Revelation (The Insight)

A pure function is simple in definition but powerful in effect:

  • It depends only on its input arguments.
  • It has no side effects—no touching globals, no I/O, no mutating objects passed in.
  • For the same inputs, it always returns the same output.

At first glance, that sounds like academic fluff. But the real magic appears when you start treating the core business rules as pure functions. Suddenly those rules become:

  • Trivial to test – just call the function with a set of inputs and assert the output. No mocks, no test doubles needed.
  • Easy to reason about – you can look at the function in isolation and know exactly what it does.
  • Safe to refactor – because it doesn’t reach out, you can change its implementation without worrying about breaking something elsewhere.

When I finally grasped this, it was like when Neo sees the Matrix code — everything just clicked. The tangled mess wasn’t impossible to untangle; I just needed to separate the what from the how.

Wielding the Power (Code & Examples)

Before: A Function That Does Too Much

// legacy-discount.js
let globalTaxRate = 0.2; // mutable global, set elsewhere

function calculateFinalPrice(cartItems, user) {
  let subtotal = 0;
  for (const item of cartItems) {
    subtotal += item.price * item.quantity;
  }

  // side effect: reads a global that might change later
  const tax = subtotal * globalTaxRate;

  // side effect: writes to a logging service directly
  logger.info(`Calculating discount for user ${user.id}`);

  // messy conditional tangled with data fetching
  let discount = 0;
  if (user.isPremium && subtotal > 100) {
    discount = subtotal * 0.15;
  } else if (user.isPremium) {
    discount = subtotal * 0.05;
  } else if (subtotal > 200) {
    discount = subtotal * 0.1;
  }

  // more side effects: directly mutates the cart object
  cartItems.discountApplied = discount;
  cartItems.taxApplied = tax;

  return subtotal - discount + tax;
}
Enter fullscreen mode Exit fullscreen mode

What’s wrong here?

  • The function reaches out to a mutable global (globalTaxRate). If another part of the program changes that value mid‑flight, the price calculation becomes unpredictable.
  • It talks to a logger and mutates the cartItems object—side effects that make testing a nightmare. You’d need to mock the logger, spy on the object, and reset globals between tests.
  • The discount logic is buried inside a long procedural block, making it hard to see the rule at a glance.

If a bug appeared in the discount calculation, you’d have to trace through all those side effects to be sure nothing else was affected.

After: Extract the Pure Core

// pure-discount.js
/**
 * Pure function: calculates discount based only on inputs.
 * No globals, no I/O, no mutation.
 */
function calculateDiscount(subtotal, isPremium) {
  if (isPremium && subtotal > 100) {
    return subtotal * 0.15;
  }
  if (isPremium) {
    return subtotal * 0.05;
  }
  if (subtotal > 200) {
    return subtotal * 0.1;
  }
  return 0;
}

/**
 * Pure function: calculates tax based only on inputs.
 */
function calculateTax(subtotal, taxRate) {
  return subtotal * taxRate;
}

/**
 * Orchestrator: handles side effects, calls pure functions.
 */
function calculateFinalPrice(cartItems, user, taxRate, logger) {
  const subtotal = cartItems.reduce(
    (sum, item) => sum + item.price * item.quantity,
    0
  );

  const discount = calculateDiscount(subtotal, user.isPremium);
  const tax = calculateTax(subtotal, taxRate);

  // Side effects are isolated here – easy to mock or replace.
  logger.info(`Calculating discount for user ${user.id}`);

  // Instead of mutating the original object, we return a new snapshot.
  return {
    subtotal,
    discount,
    tax,
    final: subtotal - discount + tax,
  };
}
Enter fullscreen mode Exit fullscreen mode

What changed?

  • The discount and tax calculations are now pure functions. You can unit test them in isolation:
  test('premium user over $100 gets 15% discount', () => {
    expect(calculateDiscount(150, true)).toBe(22.5);
  });
Enter fullscreen mode Exit fullscreen mode
  • The orchestrator (calculateFinalPrice) now only does the wiring: fetching data, calling the pure functions, handling logging, and returning a fresh result object. If you need to swap the logger for a mock in tests, it’s trivial.
  • No global state is touched, and the input objects aren’t mutated, eliminating a whole class of sneaky bugs.

The same logic, but now the core is crystal clear, testable, and safe to reuse elsewhere—perhaps in a batch job, a microservice, or a different UI layer.

Why This New Power Matters

Adopting pure functions as a default has turned my codebase from a haunted house into a well‑lit workshop.

  • Speed of development – I spend far less time setting up mocks and more time writing the actual logic.
  • Confidence in refactoring – When I need to optimise a discount algorithm, I know I won’t accidentally break the logging layer or the tax service because those concerns live elsewhere.
  • Team collaboration – New teammates can look at a pure function and instantly understand its contract without hunting through side‑effects.

In short, treating the what (business rules) as pure functions lets you swap out the how (databases, APIs, globals) without rewriting the core logic. It’s a small shift in mindset, but it pays dividends every time you touch the code.

Your Turn

Try it on the next piece of legacy you encounter: pick a function that mixes calculations with I/O or globals, extract the pure core, and write a test for it. Notice how the friction drops.

What’s the first function you’ll refactor? Drop a comment below—I’d love to hear about your quest! 🚀

Top comments (0)