The Quest Begins (The "Why")
Honestly, I still remember the day my tech lead tossed a ticket my way: “Build the loan‑eligibility engine.” At first glance it sounded like a fun little CRUD wrapper, but the spec was a beast—dozens of rules about credit scores, debt‑to‑income ratios, employment history, state‑specific caps, and a handful of promotional offers that could stack in weird ways. I opened the existing codebase and found a single 250‑line function called evaluateApplication() that was a nesting doll of if statements, mutable flags, and comments that read “TODO: fix this later”.
I felt like I’d just walked into the Death Star trench run without a targeting computer. My heart raced, my palms got sweaty, and I wondered if I was about to spend the next week debugging a spaghetti monster that would make even the most seasoned dev sigh. The dragon I needed to slay wasn’t a syntax error—it was overwhelm.
The Revelation (The Insight)
After a couple of hours of staring at that monster, I took a step back and asked myself: What if I treated this problem like a LEGO set? Instead of trying to build the whole Millennium Falcon in one go, I could snap together small, well‑defined bricks and then assemble them. That’s when the mental framework clicked: divide the problem into independent, pure functions that each do one thing well, then compose them.
Top coders don’t magically hold the entire algorithm in their heads; they rely on separation of concerns. They ask:
- What are the distinct responsibilities here?
- Can each responsibility be isolated into a function that takes inputs and returns outputs without side‑effects?
- How do I stitch those functions together in a readable pipeline?
The “aha!” moment came when I realized the loan engine could be broken into four clear stages:
- Validate – make sure the incoming data is sane.
- Calculate – compute raw numbers like monthly debt, income multiplier, etc.
- ApplyRules – evaluate each eligibility rule against those numbers.
- Format – turn the boolean result and any messages into the API response shape.
Each stage could be unit‑tested in isolation, swapped out if a regulation changed, and reasoned about without holding the rest of the system in my head. It felt like Neo finally seeing the Matrix code—except my “code” was just a bunch of tiny, testable functions.
Wielding the Power (Code & Examples)
The Struggle: A Monolithic Mess
Here’s a simplified version of what I inherited (names changed to protect the guilty):
function evaluateApplication(app) {
let eligible = true;
let messages = [];
// ---- validation (inline, mutating) ----
if (!app.ssn || app.ssn.length !== 9) {
eligible = false;
messages.push('Invalid SSN');
}
if (app.income < 0) {
eligible = false;
messages.push('Income cannot be negative');
}
// ... more validation scattered ...
// ---- calculations (mixed with rules) ----
const dti = app.monthlyDebt / app.income;
const creditScore = app.creditScore; // assume already fetched
// ---- rules (deep nesting) ----
if (creditScore < 620) {
eligible = false;
messages.push('Credit score too low');
} else {
if (dti > 0.43) {
eligible = false;
messages.push('DTI too high');
} else {
// promotional offer check
if (app.promoCode === 'SUMMER21' && app.loanAmount > 20000) {
eligible = false;
messages.push('Promo not applicable for this amount');
}
}
}
// ---- output formatting (still inside) ----
return {
eligible,
messages,
details: { dti, creditScore }
};
}
It works, but try to unit‑test the “promo not applicable” branch without also setting up validation, credit score, and DTI. Every change risks breaking something unrelated, and reading it feels like deciphering ancient runes.
The Victory: Pure Functions & Composition
After applying the framework, the same logic looks like this:
// 1️⃣ Validation – pure, returns {isValid, errors}
function validateApplication(app) {
const errors = [];
if (!app.ssn || app.ssn.length !== 9) errors.push('Invalid SSN');
if (app.income < 0) errors.push('Income cannot be negative');
// add more checks as needed
return { isValid: errors.length === 0, errors };
}
// 2️⃣ Calculations – pure, returns derived values
function calculateMetrics(app) {
return {
dti: app.monthlyDebt / app.income,
creditScore: app.creditScore,
loanAmount: app.loanAmount,
promoCode: app.promoCode
};
}
// 3️⃣ Rule engine – each rule is a pure predicate
function ruleCreditScore({ creditScore }) {
return { passed: creditScore >= 620, msg: 'Credit score too low' };
}
function ruleDTI({ dti }) {
return { passed: dti <= 0.43, msg: 'DTI too high' };
}
function rulePromo({ promoCode, loanAmount }) {
const passed = !(promoCode === 'SUMMER21' && loanAmount > 20000);
return { passed, msg: passed ? '' : 'Promo not applicable for this amount' };
}
// 4️⃣ Composition – orchestrates the pipeline
function evaluateApplication(app) {
// Step 1: validate
const { isValid, errors } = validateApplication(app);
if (!isValid) return { eligible: false, messages: errors, details: null };
// Step 2: compute metrics
const metrics = calculateMetrics(app);
// Step 3: run rules
const rules = [ruleCreditScore, ruleDTI, rulePromo];
const ruleResults = rules.map(rule => rule(metrics));
const allPassed = ruleResults.every(r => r.passed);
const messages = ruleResults
.filter(r => !r.passed)
.map(r => r.msg);
// Step 4: final shape
return {
eligible: allPassed,
messages,
details: metrics
};
}
What changed?
- Each function does one thing and has no hidden state.
- Validation errors are collected early and short‑circuit the rest—no deep nesting.
- Rules are isolated; adding a new rule is as simple as writing another predicate and pushing it into the
rulesarray. - Unit testing becomes trivial: you can feed a metrics object straight into
ruleCreditScoreand assert the output.
Common Traps to Avoid
-
Accidentally leaking state – If a validation function mutates the original
appobject, later steps might see dirty data. Keep inputs immutable or work on a copy. - Over‑engineering the pipeline – For a truly tiny script, creating six functions might be overkill. Use the framework when the problem has more than three distinct responsibilities or when you anticipate change.
- Forgetting to handle the “happy path” – It’s easy to focus on error branches and forget to return a clean success object. Always write a test for the expected‑good case first.
Why This New Power Matters
Since I refactored that loan engine, my velocity has shot through the roof. I can now:
- Ship rule changes in minutes – a new state usury law? Just add a rule function, run the existing test suite, and deploy.
- Onboard teammates faster – New hires read the validation, metrics, and rule files separately; they don’t need to hold a 250‑line mental model.
- Sleep better at night – Knowing each piece is covered by its own unit test means regressions are caught early, not during a frantic production fire‑drill.
The mental framework isn’t a silver bullet, but it’s a force multiplier. It turns an intimidating, monolithic dragon into a series of manageable hatchlings you can defeat one by one.
Your Turn
Pick a function in your own codebase that feels like a “God object” – the one that does validation, transformation, and business logic all tangled together. Spend ten minutes extracting its responsibilities into pure functions, then wire them back together with a simple orchestrator.
When you see the tests pass and the code read like a story, you’ll feel that same rush I felt when the loan engine finally clicked.
Go forth, break it down, and compose your victory! 🚀
Top comments (0)