DEV Community

Timevolt
Timevolt

Posted on

Refactoring Legacy Code: The Jedi Way to Pure Functions

The Quest Begins (The "Why")

I still remember the first time I opened a 10‑year‑old codebase at my first real job. It felt like stepping into the Death Star’s trash compactor—walls closing in, strange noises everywhere, and a lingering smell of… technical debt. A teammate asked me to add a simple discount rule for VIP customers. Sounds easy, right? I found the method buried in a 300‑line monster that did everything: fetched data from the database, applied tax, formatted output, logged to a file, and even sent a Slack notification.

I spent half a day tracing through conditionals, trying to figure out where the discount calculation actually lived. When I finally changed it, I broke the tax calculation for international orders because the method was tangled like a bundle of holiday lights. The QA team pinged me at midnight, and I felt like I’d just triggered a trap in an old arcade game—one wrong move and the whole screen flashed red.

That experience taught me a hard lesson: when code does too many things, changing one thing risks breaking everything else. I needed a way to isolate the logic I cared about, make it testable, and keep the rest of the system blissfully unaware of my changes.

The Revelation (The Insight)

The treasure I uncovered wasn’t a new framework or a shiny library—it was a mindset shift: write pure functions. A pure function is a function that, given the same inputs, always returns the same output and has no side effects (no DB calls, no file writes, no mutable globals). It’s like a lightsaber: elegant, predictable, and deadly accurate when you know how to wield it.

When you extract a piece of business logic into a pure function, you gain three immediate superpowers:

  1. Testability – you can unit‑test the function in isolation with a handful of assertions. No mocks, no fixtures, no spinning up a database.
  2. Reusability – the same function can be called from anywhere: a controller, a background job, a script, even a REPL.
  3. Safety – because it doesn’t touch the outside world, you can refactor it fearlessly. Change its internals, and you know you won’t accidentally silence a logger or corrupt a file.

The moment I saw a 20‑line monster split into a handful of pure functions, my anxiety dropped. I felt like Luke finally seeing the Force flow through him—clear, calm, and ready to act.

Wielding the Power (Code & Examples)

Let’s look at the kind of monster I was fighting. Imagine a PHP class that handles order processing:

class OrderProcessor {
    public function processOrder(Order $order): void {
        // 1️⃣ Fetch customer data (side effect)
        $customer = $this->customerRepo->findById($order->customerId);

        // 2️⃣ Apply tax (side effect – reads external config)
        $taxRate = $this->config->get('tax_rate');
        $subtotal = $order->amount * (1 + $taxRate);

        // 3️⃣ Calculate discount (business logic buried here)
        if ($customer->isVip() && $order->amount > 100) {
            $discount = $subtotal * 0.15;
        } else {
            $discount = 0;
        }

        // 4️⃣ Apply discount applied amount after tax and discount
        $finalAmount = $subtotal - $discount;

        // 5️⃣ Persist updated order (side effect)
        $order->setAmount($finalAmount);
        $this->orderRepo->save($order);

        // 6️⃣ Send confirmation email (side effect)
        $this->mailer->send($customer->email, 'Order Confirmed', $this->view->render('email/order', compact('order')));
    }
}
Enter fullscreen mode Exit fullscreen mode

The discount calculation is tangled with data fetching, tax retrieval, persistence, and mailing. If I wanted to change the discount rule—for example, to give VIPs a flat $20 instead of 15%—I’d have to wade through all that noise, risking a slip that could break email delivery or tax calculations.

The Jedi Move

Step one: pull the discount logic out into a pure function. Nothing else touches it.

/**
 * Pure function: given an order amount and VIP status,
 * returns the discount amount.
 *
 * No external state, no side effects.
 */
function calculateVipDiscount(float $amount, bool isVip): float {
    if (!isVip || $amount <= 100) {
        return 0.0;
    }
    return $amount * 0.15; // 15% discount
}
Enter fullscreen mode Exit fullscreen mode

Now the processor becomes a thin orchestrator:

class OrderProcessor {
    public function processOrder(Order $order): void {
        $customer = $this->customerRepo->findById($order->customerId);
        $taxRate = $this->config->get('tax_rate');
        $subtotal = $order->amount * (1 + $taxRate);

        // 🎇 Pure function call – easy to test, easy to change
        $discount = calculateVipDiscount($subtotal, $customer->isVip());

        $finalAmount = $subtotal - $discount;
        $order->setAmount($finalAmount);
        $this->orderRepo->save($order);
        $this->mailer->send(
            $customer->email,
            'Order Confirmed',
            $this->view->render('email/order', compact('order'))
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

What changed?

  • The discount rule is now a single, testable unit. I can write a test like:
test('VIP discount applies correctly', function () {
    expect(calculateVipDiscount(150, true))->toBe(22.5);
    expect(calculateVipDiscount(80, true))->toBe(0);
    expect(calculateVipDiscount(200, false))->toBe(0);
});
Enter fullscreen mode Exit fullscreen mode
  • If the business decides to switch to a flat $20 discount, I edit only that function:
function calculateVipDiscount(float $amount, bool isVip): float {
    if (!isVip || $amount <= 100) {
        return 0.0;
    }
    return min($amount * 0.15, 20.0); // cap at $20
}
Enter fullscreen mode Exit fullscreen mode

All callers stay exactly the same. No risk of accidentally breaking the mailer or the tax calculation.

Common Traps to Avoid

  • Accidental impurity – reaching into a global config or a static service inside the “pure” function. Keep those parameters explicit.
  • Over‑extracting – turning every tiny line into its own function creates noise. Extract only when you see a clear piece of business logic that could vary independently.
  • Ignoring return values – a pure function that’s called but its result ignored defeats the purpose. Always use the result or reconsider the extraction.

Why This New Power Matters

By treating pieces of logic as pure functions, you turn a sprawling, brittle monolith into a set of Lego bricks: each brick has a defined shape, snaps together predictably, and can be swapped out without tearing down the whole structure.

  • Speed of delivery – you can add features or fix bugs in minutes instead of hours because the surface area you need to understand shrinks dramatically.
  • Confidence in refactoring – you can safely rename variables, change algorithms, or even replace an entire module knowing the outside world won’t notice.
  • Team scalability – new teammates can jump in, write a test for a pure function, and ship changes without needing to memorize the entire call graph.

The best part? This practice compounds. The more you extract, the easier it becomes to spot the next candidate for purity. It’s like leveling up a character in an RPG—each pure function you add gives you +1 to maintainability and +0 to technical debt.

Your Turn

Grab a legacy method that makes you wince every time you open it. Identify the smallest piece of logic that decides something based only on its inputs (a discount, a fee, a validation rule, a formatting step). Pull it out into a pure function, write a couple of tests, and watch the rest of the code breathe easier.

What’s the first function you’ll purify? Drop a comment below—I’d love to hear your war stories and celebrate your victories!


May the refactoring be with you. 🚀

Top comments (0)